KK KTP arsip
8
.gemini/settings.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"context7": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@upstash/context7-mcp@latest"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
KK/5103040808220001.jpg
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
KTP/3303080307040003.jpg
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
KTP/3529245512000002.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
KTP/3671092111950003.jpg
Normal file
|
After Width: | Height: | Size: 255 KiB |
BIN
KTP/5102045811690001.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
KTP/5103022906800001.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
KTP/5171042004950004.jpg
Normal file
|
After Width: | Height: | Size: 237 KiB |
BIN
KTP/7306046502850001.jpg
Normal file
|
After Width: | Height: | Size: 216 KiB |
BIN
__pycache__/app.cpython-313.pyc
Normal file
BIN
__pycache__/database.cpython-313.pyc
Normal file
BIN
__pycache__/image_processor.cpython-313.pyc
Normal file
BIN
__pycache__/models.cpython-313.pyc
Normal file
39
database.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""
|
||||||
|
Database Configuration for OCR Application
|
||||||
|
Using Flask-SQLAlchemy with MySQL (PyMySQL driver)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
|
||||||
|
# Database configuration
|
||||||
|
DB_CONFIG = {
|
||||||
|
'host': os.environ.get('DB_HOST', 'localhost'),
|
||||||
|
'port': os.environ.get('DB_PORT', '3306'),
|
||||||
|
'database': os.environ.get('DB_NAME', 'ocr_db'),
|
||||||
|
'user': os.environ.get('DB_USER', 'ocr_user'),
|
||||||
|
'password': os.environ.get('DB_PASSWORD', 'ocr_password123')
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_database_uri():
|
||||||
|
"""Generate SQLAlchemy database URI"""
|
||||||
|
return f"mysql+pymysql://{DB_CONFIG['user']}:{DB_CONFIG['password']}@{DB_CONFIG['host']}:{DB_CONFIG['port']}/{DB_CONFIG['database']}?charset=utf8mb4"
|
||||||
|
|
||||||
|
def init_db(app):
|
||||||
|
"""Initialize database with Flask app"""
|
||||||
|
app.config['SQLALCHEMY_DATABASE_URI'] = get_database_uri()
|
||||||
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
|
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
|
||||||
|
'pool_recycle': 3600,
|
||||||
|
'pool_pre_ping': True
|
||||||
|
}
|
||||||
|
|
||||||
|
db.init_app(app)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
print(f"✓ Database connected: {DB_CONFIG['database']}@{DB_CONFIG['host']}")
|
||||||
|
|
||||||
|
return db
|
||||||
21
docker-compose.yml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: mysql-server
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: root123
|
||||||
|
MYSQL_DATABASE: ocr_db
|
||||||
|
MYSQL_USER: ocr_user
|
||||||
|
MYSQL_PASSWORD: ocr_password123
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
command: --default-authentication-plugin=mysql_native_password
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
|
driver: local
|
||||||
174
image_processor.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
KTP Image Processor - Enhanced Version
|
||||||
|
Crop, resize, dan enhanced preprocessing untuk OCR yang lebih akurat
|
||||||
|
|
||||||
|
Standar e-KTP: 85.6mm x 53.98mm = 1011x638 px @300dpi
|
||||||
|
|
||||||
|
Improvements based on Context7 documentation:
|
||||||
|
- Pillow ImageEnhance for contrast/sharpness
|
||||||
|
- OpenCV CLAHE for adaptive histogram equalization
|
||||||
|
- Denoising for cleaner text detection
|
||||||
|
"""
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import os
|
||||||
|
from PIL import Image, ImageEnhance, ImageFilter
|
||||||
|
|
||||||
|
KTP_WIDTH = 1011
|
||||||
|
KTP_HEIGHT = 638
|
||||||
|
|
||||||
|
|
||||||
|
def enhance_image_pil(image_path: str, output_path: str = None) -> str:
|
||||||
|
"""
|
||||||
|
Enhance image using Pillow (from Context7 docs)
|
||||||
|
- Contrast enhancement
|
||||||
|
- Sharpness enhancement
|
||||||
|
- Detail filter for text clarity
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path: Path to input image
|
||||||
|
output_path: Optional path to save enhanced image
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to enhanced image
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
img = Image.open(image_path)
|
||||||
|
|
||||||
|
# Contrast enhancement (factor 1.3 from Context7)
|
||||||
|
contrast = ImageEnhance.Contrast(img)
|
||||||
|
img = contrast.enhance(1.3)
|
||||||
|
|
||||||
|
# Sharpness enhancement
|
||||||
|
sharpness = ImageEnhance.Sharpness(img)
|
||||||
|
img = sharpness.enhance(1.2)
|
||||||
|
|
||||||
|
# Apply detail filter for text clarity
|
||||||
|
img = img.filter(ImageFilter.DETAIL)
|
||||||
|
|
||||||
|
# Save
|
||||||
|
if output_path is None:
|
||||||
|
base, ext = os.path.splitext(image_path)
|
||||||
|
output_path = f"{base}_enhanced.jpg"
|
||||||
|
|
||||||
|
img.save(output_path, quality=95)
|
||||||
|
print(f" [ENHANCE] Pillow enhanced: {output_path}")
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [ENHANCE] Pillow error: {e}")
|
||||||
|
return image_path # Return original if enhancement fails
|
||||||
|
|
||||||
|
|
||||||
|
def enhance_image_cv(image: np.ndarray) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Enhance image using OpenCV (from Context7 docs)
|
||||||
|
- CLAHE for adaptive histogram equalization
|
||||||
|
- Denoising
|
||||||
|
- Sharpening using Laplacian kernel
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image: OpenCV image (BGR)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Enhanced image (BGR)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Convert to grayscale for processing
|
||||||
|
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||||
|
|
||||||
|
# Denoise (from Context7)
|
||||||
|
denoised = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21)
|
||||||
|
|
||||||
|
# Enhanced CLAHE for documents
|
||||||
|
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
|
||||||
|
enhanced = clahe.apply(denoised)
|
||||||
|
|
||||||
|
# Sharpen using kernel (from Context7)
|
||||||
|
kernel = np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]], dtype=np.float32)
|
||||||
|
sharpened = cv2.filter2D(enhanced, -1, kernel)
|
||||||
|
|
||||||
|
# Convert back to BGR
|
||||||
|
return cv2.cvtColor(sharpened, cv2.COLOR_GRAY2BGR)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [ENHANCE] OpenCV error: {e}")
|
||||||
|
return image # Return original if enhancement fails
|
||||||
|
|
||||||
|
|
||||||
|
def crop_by_ocr_bounds(image, ocr_results, padding=0.03):
|
||||||
|
"""Crop image based on OCR bounding boxes"""
|
||||||
|
if not ocr_results:
|
||||||
|
return image
|
||||||
|
|
||||||
|
h, w = image.shape[:2]
|
||||||
|
all_x = []
|
||||||
|
all_y = []
|
||||||
|
|
||||||
|
for r in ocr_results:
|
||||||
|
box = r.get('box', [])
|
||||||
|
if len(box) >= 4:
|
||||||
|
try:
|
||||||
|
for point in box:
|
||||||
|
if isinstance(point, (list, tuple)) and len(point) >= 2:
|
||||||
|
all_x.append(float(point[0]))
|
||||||
|
all_y.append(float(point[1]))
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not all_x or not all_y:
|
||||||
|
return image
|
||||||
|
|
||||||
|
x1 = int(max(0, min(all_x) - w * padding))
|
||||||
|
y1 = int(max(0, min(all_y) - h * padding))
|
||||||
|
x2 = int(min(w, max(all_x) + w * padding))
|
||||||
|
y2 = int(min(h, max(all_y) + h * padding))
|
||||||
|
|
||||||
|
return image[y1:y2, x1:x2]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_ktp_image(image_path, output_path=None, ocr_results=None):
|
||||||
|
"""
|
||||||
|
Normalisasi gambar KTP:
|
||||||
|
1. Crop berdasarkan OCR bounds
|
||||||
|
2. Ensure landscape
|
||||||
|
3. Resize ke ukuran standar
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
image = cv2.imread(image_path)
|
||||||
|
if image is None:
|
||||||
|
return None, False, "Gagal membaca gambar"
|
||||||
|
|
||||||
|
h, w = image.shape[:2]
|
||||||
|
print(f" [IMAGE] Original: {w}x{h}")
|
||||||
|
|
||||||
|
# Crop
|
||||||
|
if ocr_results:
|
||||||
|
image = crop_by_ocr_bounds(image, ocr_results)
|
||||||
|
h, w = image.shape[:2]
|
||||||
|
print(f" [IMAGE] Cropped: {w}x{h}")
|
||||||
|
|
||||||
|
# Landscape
|
||||||
|
if h > w:
|
||||||
|
image = cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE)
|
||||||
|
|
||||||
|
# Resize
|
||||||
|
resized = cv2.resize(image, (KTP_WIDTH, KTP_HEIGHT),
|
||||||
|
interpolation=cv2.INTER_LANCZOS4)
|
||||||
|
|
||||||
|
# Save
|
||||||
|
if output_path is None:
|
||||||
|
base, ext = os.path.splitext(image_path)
|
||||||
|
output_path = f"{base}_normalized.jpg"
|
||||||
|
|
||||||
|
cv2.imwrite(output_path, resized, [cv2.IMWRITE_JPEG_QUALITY, 95])
|
||||||
|
print(f" [IMAGE] Saved: {output_path}")
|
||||||
|
|
||||||
|
return output_path, True, f"Normalized to {KTP_WIDTH}x{KTP_HEIGHT}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return None, False, f"Error: {str(e)}"
|
||||||
750
ktp_extractor.py
@@ -2,17 +2,42 @@
|
|||||||
KTP Field Extractor
|
KTP Field Extractor
|
||||||
Ekstraksi data terstruktur dari hasil OCR KTP Indonesia
|
Ekstraksi data terstruktur dari hasil OCR KTP Indonesia
|
||||||
Mendukung berbagai format output OCR (full-width colon, standard colon, tanpa colon)
|
Mendukung berbagai format output OCR (full-width colon, standard colon, tanpa colon)
|
||||||
|
|
||||||
|
OPTIMIZED: Pre-compiled regex patterns for better performance
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Dict, Optional, List
|
from typing import Dict, Optional, List
|
||||||
|
import difflib
|
||||||
|
|
||||||
|
# Debug mode - set to False for production
|
||||||
|
DEBUG_MODE = False
|
||||||
|
|
||||||
class KTPExtractor:
|
class KTPExtractor:
|
||||||
"""Ekstrak field dari hasil OCR KTP"""
|
"""Ekstrak field dari hasil OCR KTP"""
|
||||||
|
|
||||||
# Pattern colon yang berbeda-beda (standard, full-width, dll)
|
# Pre-compiled regex patterns (optimization)
|
||||||
COLON_PATTERN = r'[:\:]'
|
COLON_PATTERN = re.compile(r'[::]')
|
||||||
|
NIK_PATTERN = re.compile(r'\b(\d{16})\b')
|
||||||
|
DATE_PATTERN = re.compile(r'(\d{2}[-/]\d{2}[-/]\d{4})')
|
||||||
|
RT_RW_PATTERN = re.compile(r'(\d{3})\s*/\s*(\d{3})')
|
||||||
|
GOL_DARAH_PATTERN = re.compile(r'([ABO]{1,2}[+\-]?)', re.IGNORECASE)
|
||||||
|
PROVINSI_SPLIT_PATTERN = re.compile(r'(?i)provinsi\s*')
|
||||||
|
KABUPATEN_SPLIT_PATTERN = re.compile(r'(?i)\s*(kabupaten|kota)\s*')
|
||||||
|
TTL_PATTERN = re.compile(r'(?i)tempat[/\s]*tgl[/\s]*lahir|tempat[/\s]*lahir|lahir')
|
||||||
|
|
||||||
|
# Pattern colon string (for backward compatibility)
|
||||||
|
COLON_PATTERN_STR = r'[::]'
|
||||||
|
|
||||||
|
# Daftar Provinsi Indonesia (38 Provinsi)
|
||||||
|
PROVINSI_LIST = [
|
||||||
|
"ACEH", "SUMATERA UTARA", "SUMATERA BARAT", "RIAU", "JAMBI", "SUMATERA SELATAN", "BENGKULU", "LAMPUNG",
|
||||||
|
"KEPULAUAN BANGKA BELITUNG", "KEPULAUAN RIAU", "DKI JAKARTA", "JAWA BARAT", "JAWA TENGAH", "DI YOGYAKARTA",
|
||||||
|
"JAWA TIMUR", "BANTEN", "BALI", "NUSA TENGGARA BARAT", "NUSA TENGGARA TIMUR", "KALIMANTAN BARAT",
|
||||||
|
"KALIMANTAN TENGAH", "KALIMANTAN SELATAN", "KALIMANTAN TIMUR", "KALIMANTAN UTARA", "SULAWESI UTARA",
|
||||||
|
"SULAWESI TENGAH", "SULAWESI SELATAN", "SULAWESI TENGGARA", "GORONTALO", "SULAWESI BARAT", "MALUKU",
|
||||||
|
"MALUKU UTARA", "PAPUA BARAT", "PAPUA", "PAPUA SELATAN", "PAPUA TENGAH", "PAPUA PEGUNUNGAN", "PAPUA BARAT DAYA"
|
||||||
|
]
|
||||||
|
|
||||||
# Keywords untuk jenis kelamin
|
# Keywords untuk jenis kelamin
|
||||||
MALE_KEYWORDS = ['laki', 'pria', 'male']
|
MALE_KEYWORDS = ['laki', 'pria', 'male']
|
||||||
@@ -26,6 +51,99 @@ class KTPExtractor:
|
|||||||
'buruh', 'petani', 'nelayan', 'karyawan', 'ibu rumah tangga',
|
'buruh', 'petani', 'nelayan', 'karyawan', 'ibu rumah tangga',
|
||||||
'tidak bekerja', 'lainnya', 'mengurus rumah tangga']
|
'tidak bekerja', 'lainnya', 'mengurus rumah tangga']
|
||||||
|
|
||||||
|
# Status Perkawinan yang valid
|
||||||
|
STATUS_PERKAWINAN_LIST = ['BELUM KAWIN', 'KAWIN', 'CERAI HIDUP', 'CERAI MATI']
|
||||||
|
|
||||||
|
# Field Labels untuk fuzzy matching (mengatasi typo OCR seperti "Aamat" -> "ALAMAT")
|
||||||
|
FIELD_LABELS = {
|
||||||
|
'nama': ['NAMA'],
|
||||||
|
'alamat': ['ALAMAT'],
|
||||||
|
'agama': ['AGAMA'],
|
||||||
|
'pekerjaan': ['PEKERJAAN'],
|
||||||
|
'kewarganegaraan': ['KEWARGANEGARAAN', 'WARGANEGARA'],
|
||||||
|
'tempat_lahir': ['TEMPAT', 'LAHIR', 'TEMPAT/TGL LAHIR'],
|
||||||
|
'jenis_kelamin': ['JENIS KELAMIN', 'JENIS', 'KELAMIN'],
|
||||||
|
'gol_darah': ['GOL. DARAH', 'GOL DARAH', 'GOLONGAN DARAH'],
|
||||||
|
'kel_desa': ['KEL/DESA', 'KELURAHAN', 'DESA'],
|
||||||
|
'kecamatan': ['KECAMATAN', 'KEC'],
|
||||||
|
'status_perkawinan': ['STATUS PERKAWINAN', 'PERKAWINAN'],
|
||||||
|
'berlaku_hingga': ['BERLAKU HINGGA', 'BERLAKU'],
|
||||||
|
'rt_rw': ['RT/RW', 'RT', 'RW'],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Sistem Penamaan Hindu Bali
|
||||||
|
# ============================================
|
||||||
|
# Struktur: [Prefix Gender] + [Gelar Kasta] + [Penanda Gender] + [Urutan Lahir] + [Nama Pribadi]
|
||||||
|
|
||||||
|
# Prefix penanda gender (harus di awal nama)
|
||||||
|
BALI_GENDER_PREFIX = {
|
||||||
|
'NI': 'PEREMPUAN', # Prefix untuk perempuan
|
||||||
|
'I': 'LAKI-LAKI', # Prefix untuk laki-laki
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gelar Kasta (setelah prefix gender)
|
||||||
|
BALI_KASTA = {
|
||||||
|
'IDA': 'BRAHMANA',
|
||||||
|
'GUSTI': 'KSATRIA',
|
||||||
|
'ANAK AGUNG': 'KSATRIA',
|
||||||
|
'COKORDA': 'KSATRIA',
|
||||||
|
'DEWA': 'KSATRIA',
|
||||||
|
'DESAK': 'KSATRIA',
|
||||||
|
'AGUNG': 'KSATRIA',
|
||||||
|
'NGAKAN': 'WAISYA',
|
||||||
|
'SANG': 'WAISYA',
|
||||||
|
'SI': 'WAISYA',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Penanda gender tambahan (setelah kasta)
|
||||||
|
BALI_GENDER_MARKER = {
|
||||||
|
'AYU': 'PEREMPUAN',
|
||||||
|
'ISTRI': 'PEREMPUAN',
|
||||||
|
'LUH': 'PEREMPUAN',
|
||||||
|
'BAGUS': 'LAKI-LAKI',
|
||||||
|
'GEDE': 'LAKI-LAKI',
|
||||||
|
'AGUS': 'LAKI-LAKI',
|
||||||
|
'ALIT': 'LAKI-LAKI', # Kecil/muda (untuk laki-laki)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Urutan kelahiran (bersiklus setiap 4 anak)
|
||||||
|
BALI_BIRTH_ORDER = {
|
||||||
|
'PUTU': 1, 'WAYAN': 1, 'GEDE': 1, 'ILUH': 1,
|
||||||
|
'MADE': 2, 'KADEK': 2, 'NENGAH': 2,
|
||||||
|
'NYOMAN': 3, 'KOMANG': 3,
|
||||||
|
'KETUT': 4,
|
||||||
|
'BALIK': 5, # Untuk anak ke-5+ (siklus ulang)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Soroh/Klan Bali (identifikasi garis keturunan)
|
||||||
|
BALI_SOROH = {
|
||||||
|
'PASEK': 'SOROH', # Klan mayoritas (~60% Hindu Bali)
|
||||||
|
'PANDE': 'SOROH', # Klan pandai besi/metalurgi
|
||||||
|
'ARYA': 'SOROH', # Klan Arya
|
||||||
|
'BENDESA': 'SOROH', # Pemimpin adat
|
||||||
|
'TANGKAS': 'SOROH', # Klan Tangkas
|
||||||
|
'CELAGI': 'SOROH', # Klan Celagi
|
||||||
|
'SENGGUHU': 'SOROH', # Klan Sengguhu
|
||||||
|
'KUBAYAN': 'SOROH', # Klan Kubayan
|
||||||
|
'BANDESA': 'SOROH', # Varian Bendesa
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gabungkan semua komponen untuk deteksi (urut dari panjang ke pendek)
|
||||||
|
BALI_NAME_COMPONENTS = [
|
||||||
|
# Prefix gender
|
||||||
|
'NI', 'I',
|
||||||
|
# Kasta (prioritas: yang lebih panjang dulu)
|
||||||
|
'ANAK AGUNG', 'COKORDA', 'NGAKAN',
|
||||||
|
'IDA', 'GUSTI', 'DEWA', 'DESAK', 'AGUNG', 'SANG', 'SI',
|
||||||
|
# Soroh/Klan
|
||||||
|
'PASEK', 'PANDE', 'ARYA', 'BENDESA', 'BANDESA', 'TANGKAS', 'CELAGI', 'SENGGUHU', 'KUBAYAN',
|
||||||
|
# Gender marker
|
||||||
|
'AYU', 'ISTRI', 'LUH', 'BAGUS', 'GEDE', 'AGUS', 'ALIT',
|
||||||
|
# Urutan lahir
|
||||||
|
'WAYAN', 'PUTU', 'ILUH', 'MADE', 'KADEK', 'NENGAH', 'NYOMAN', 'KOMANG', 'KETUT', 'BALIK',
|
||||||
|
]
|
||||||
|
|
||||||
# KTP Zone Template (normalized coordinates: x_min, y_min, x_max, y_max)
|
# KTP Zone Template (normalized coordinates: x_min, y_min, x_max, y_max)
|
||||||
# Based on standard KTP layout
|
# Based on standard KTP layout
|
||||||
ZONES = {
|
ZONES = {
|
||||||
@@ -75,6 +193,211 @@ class KTPExtractor:
|
|||||||
return parts[1].strip()
|
return parts[1].strip()
|
||||||
return text.strip()
|
return text.strip()
|
||||||
|
|
||||||
|
def _find_best_match(self, text: str, candidates: List[str], cutoff: float = 0.6) -> Optional[str]:
|
||||||
|
"""Find best fuzzy match from candidates"""
|
||||||
|
matches = difflib.get_close_matches(text, candidates, n=1, cutoff=cutoff)
|
||||||
|
return matches[0] if matches else None
|
||||||
|
|
||||||
|
def _is_label_match(self, text: str, field_name: str, cutoff: float = 0.7) -> bool:
|
||||||
|
"""
|
||||||
|
Fuzzy match untuk label field - mengatasi typo OCR seperti "Aamat" -> "ALAMAT"
|
||||||
|
Returns True jika text cocok dengan salah satu label untuk field tersebut
|
||||||
|
"""
|
||||||
|
if not text or not text.strip():
|
||||||
|
return False
|
||||||
|
|
||||||
|
if field_name not in self.FIELD_LABELS:
|
||||||
|
return field_name.lower() in text.lower()
|
||||||
|
|
||||||
|
text_upper = text.upper().strip()
|
||||||
|
|
||||||
|
# Coba exact match dulu (lebih cepat)
|
||||||
|
for label in self.FIELD_LABELS[field_name]:
|
||||||
|
if label in text_upper:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Fuzzy match jika tidak ada exact match
|
||||||
|
# Ekstrak kata pertama dari text (biasanya label ada di awal)
|
||||||
|
parts = text_upper.split(':')[0].split()
|
||||||
|
if not parts:
|
||||||
|
return False
|
||||||
|
first_word = parts[0]
|
||||||
|
|
||||||
|
for label in self.FIELD_LABELS[field_name]:
|
||||||
|
label_parts = label.split()
|
||||||
|
if not label_parts:
|
||||||
|
continue
|
||||||
|
# Bandingkan dengan kata pertama
|
||||||
|
ratio = difflib.SequenceMatcher(None, first_word, label_parts[0]).ratio()
|
||||||
|
if ratio >= cutoff:
|
||||||
|
print(f" [FUZZY LABEL] '{first_word}' matched '{label}' (ratio={ratio:.2f})")
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _parse_balinese_name(self, name: str) -> str:
|
||||||
|
"""
|
||||||
|
Parse nama Bali yang digabung OCR dan tambahkan spasi yang tepat.
|
||||||
|
Contoh: "NIGUSTIAYUNYOMANSUWETRI" -> "NI GUSTI AYU NYOMAN SUWETRI"
|
||||||
|
|
||||||
|
Struktur nama Bali:
|
||||||
|
[Prefix Gender] + [Gelar Kasta] + [Penanda Gender] + [Urutan Lahir] + [Nama Pribadi]
|
||||||
|
|
||||||
|
PENTING: Hanya proses jika nama benar-benar mengandung komponen Bali!
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return name
|
||||||
|
|
||||||
|
name_upper = name.upper().strip()
|
||||||
|
|
||||||
|
# Jika sudah ada spasi dengan jumlah wajar, kembalikan apa adanya
|
||||||
|
if name_upper.count(' ') >= 2:
|
||||||
|
return name_upper
|
||||||
|
|
||||||
|
# Cek apakah nama mengandung komponen Bali
|
||||||
|
# Nama harus dimulai dengan NI, I GUSTI, IDA, atau komponen urutan lahir Bali
|
||||||
|
name_clean = name_upper.replace(' ', '')
|
||||||
|
|
||||||
|
is_balinese_name = False
|
||||||
|
# Cek prefix khas Bali
|
||||||
|
if name_clean.startswith('NI') and len(name_clean) > 3:
|
||||||
|
# NI harus diikuti komponen Bali lain (GUSTI, LUH, WAYAN, dll)
|
||||||
|
after_ni = name_clean[2:]
|
||||||
|
for comp in ['GUSTI', 'LUH', 'WAYAN', 'MADE', 'NYOMAN', 'KETUT', 'PUTU', 'KADEK', 'KOMANG', 'PASEK', 'PANDE']:
|
||||||
|
if after_ni.startswith(comp):
|
||||||
|
is_balinese_name = True
|
||||||
|
break
|
||||||
|
elif name_clean.startswith('IGUSTI') or name_clean.startswith('IDABAGUS') or name_clean.startswith('IDAAYU'):
|
||||||
|
is_balinese_name = True
|
||||||
|
elif any(name_clean.startswith(p) for p in ['GUSTI', 'WAYAN', 'PUTU', 'MADE', 'KADEK', 'NYOMAN', 'KOMANG', 'KETUT']):
|
||||||
|
is_balinese_name = True
|
||||||
|
|
||||||
|
if not is_balinese_name:
|
||||||
|
# Bukan nama Bali, kembalikan dengan pemisahan spasi standar
|
||||||
|
# Jika ada 1 spasi, kembalikan apa adanya
|
||||||
|
if ' ' in name_upper:
|
||||||
|
return name_upper
|
||||||
|
# Jika tidak ada spasi sama sekali, kembalikan apa adanya (mungkin memang 1 kata)
|
||||||
|
return name_upper
|
||||||
|
|
||||||
|
# Urutan komponen yang akan dicari (dari yang terpanjang ke terpendek untuk akurasi)
|
||||||
|
components_ordered = sorted(self.BALI_NAME_COMPONENTS, key=len, reverse=True)
|
||||||
|
|
||||||
|
result_parts = []
|
||||||
|
remaining = name_clean
|
||||||
|
|
||||||
|
# Parse prefix gender (NI atau I di awal)
|
||||||
|
if remaining.startswith('NI'):
|
||||||
|
result_parts.append('NI')
|
||||||
|
remaining = remaining[2:]
|
||||||
|
elif remaining.startswith('I') and len(remaining) > 1:
|
||||||
|
# Pastikan bukan bagian dari kata lain
|
||||||
|
next_char = remaining[1] if len(remaining) > 1 else ''
|
||||||
|
# Cek apakah karakter setelah I adalah konsonan (bukan vokal)
|
||||||
|
if next_char not in 'AIUEO':
|
||||||
|
result_parts.append('I')
|
||||||
|
remaining = remaining[1:]
|
||||||
|
|
||||||
|
# Parse komponen-komponen lainnya
|
||||||
|
found = True
|
||||||
|
max_iterations = 10 # Prevent infinite loop
|
||||||
|
iteration = 0
|
||||||
|
|
||||||
|
while remaining and found and iteration < max_iterations:
|
||||||
|
found = False
|
||||||
|
iteration += 1
|
||||||
|
|
||||||
|
for component in components_ordered:
|
||||||
|
if remaining.startswith(component):
|
||||||
|
# Skip jika komponen sudah ada di result (kecuali nama pribadi)
|
||||||
|
if component not in result_parts or component not in self.BALI_NAME_COMPONENTS:
|
||||||
|
result_parts.append(component)
|
||||||
|
remaining = remaining[len(component):]
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Sisa adalah nama pribadi
|
||||||
|
if remaining:
|
||||||
|
result_parts.append(remaining)
|
||||||
|
|
||||||
|
parsed_name = ' '.join(result_parts)
|
||||||
|
|
||||||
|
# Log jika ada perubahan
|
||||||
|
if parsed_name != name_upper:
|
||||||
|
print(f" [BALI NAME] '{name_upper}' -> '{parsed_name}'")
|
||||||
|
|
||||||
|
return parsed_name
|
||||||
|
|
||||||
|
def _search_best_match_in_text(self, text: str, candidates: List[str], prefix: str = "") -> tuple:
|
||||||
|
"""
|
||||||
|
Search if any candidate is present in text using multiple strategies:
|
||||||
|
1. Exact substring
|
||||||
|
2. Prefix + Candidate (Fuzzy) - e.g. "PROVINSI BALI"
|
||||||
|
3. Candidate Only (Fuzzy) - e.g. "BALI" (if prefix is missing/damaged)
|
||||||
|
Returns (best_candidate, confidence_score)
|
||||||
|
"""
|
||||||
|
text_upper = text.upper()
|
||||||
|
best_match = None
|
||||||
|
best_ratio = 0.0
|
||||||
|
|
||||||
|
# Strategy 1: Exact substring match (fastest & most reliable)
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate in text_upper:
|
||||||
|
if len(candidate) > len(best_match or ""):
|
||||||
|
best_match = candidate
|
||||||
|
best_ratio = 1.0
|
||||||
|
|
||||||
|
if best_ratio == 1.0:
|
||||||
|
return best_match, best_ratio
|
||||||
|
|
||||||
|
# Strategy 2: Prefix Construction & Fuzzy Match
|
||||||
|
prefix_upper = prefix.upper() if prefix else ""
|
||||||
|
|
||||||
|
# DEBUG: Print checking (controlled by DEBUG_MODE)
|
||||||
|
if DEBUG_MODE:
|
||||||
|
print(f"DEBUG Check Text: '{text_upper}' with Prefix: '{prefix_upper}'")
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
# 2a. Compare with Prefix + Space (e.g. "PROVINSI BALI")
|
||||||
|
if prefix:
|
||||||
|
target_spaced = f"{prefix_upper} {candidate}"
|
||||||
|
s_spaced = difflib.SequenceMatcher(None, target_spaced, text_upper)
|
||||||
|
ratio_spaced = s_spaced.ratio()
|
||||||
|
|
||||||
|
# print(f" -> Compare '{target_spaced}' vs '{text_upper}' = {ratio_spaced:.2f}")
|
||||||
|
|
||||||
|
if ratio_spaced > best_ratio and ratio_spaced > 0.5:
|
||||||
|
best_ratio = ratio_spaced
|
||||||
|
best_match = candidate
|
||||||
|
|
||||||
|
# 2b. Compare with Prefix NO SPACE (e.g. "PROVINSIBALI")
|
||||||
|
# This handles "PROVNSIBALI" perfectly
|
||||||
|
target_merged = f"{prefix_upper}{candidate}"
|
||||||
|
s_merged = difflib.SequenceMatcher(None, target_merged, text_upper)
|
||||||
|
ratio_merged = s_merged.ratio()
|
||||||
|
|
||||||
|
if DEBUG_MODE:
|
||||||
|
print(f" -> Compare Merged '{target_merged}' vs '{text_upper}' = {ratio_merged:.2f}")
|
||||||
|
|
||||||
|
if ratio_merged > best_ratio and ratio_merged > 0.5:
|
||||||
|
best_ratio = ratio_merged
|
||||||
|
best_match = candidate
|
||||||
|
|
||||||
|
# 2c. Compare Candidate ONLY (e.g. "BALI")
|
||||||
|
if len(candidate) > 3:
|
||||||
|
s_raw = difflib.SequenceMatcher(None, candidate, text_upper)
|
||||||
|
ratio_raw = s_raw.ratio()
|
||||||
|
|
||||||
|
# print(f" -> Compare Raw '{candidate}' vs '{text_upper}' = {ratio_raw:.2f}")
|
||||||
|
|
||||||
|
if ratio_raw > best_ratio and ratio_raw > 0.6:
|
||||||
|
best_ratio = ratio_raw
|
||||||
|
best_match = candidate
|
||||||
|
|
||||||
|
if DEBUG_MODE:
|
||||||
|
print(f"DEBUG Best Match: {best_match} ({best_ratio:.2f})")
|
||||||
|
return best_match, best_ratio
|
||||||
|
|
||||||
def _detect_image_size(self, ocr_results: List[Dict]) -> tuple:
|
def _detect_image_size(self, ocr_results: List[Dict]) -> tuple:
|
||||||
"""Detect image dimensions from bounding boxes"""
|
"""Detect image dimensions from bounding boxes"""
|
||||||
max_x, max_y = 0, 0
|
max_x, max_y = 0, 0
|
||||||
@@ -93,10 +416,62 @@ class KTPExtractor:
|
|||||||
|
|
||||||
# PROVINSI from header
|
# PROVINSI from header
|
||||||
if 'header_provinsi' in zone_texts:
|
if 'header_provinsi' in zone_texts:
|
||||||
|
print(f"DEBUG Zone Provinsi Content: {zone_texts['header_provinsi']}")
|
||||||
for text in zone_texts['header_provinsi']:
|
for text in zone_texts['header_provinsi']:
|
||||||
if 'provinsi' in text.lower():
|
text_clean = text.strip()
|
||||||
val = re.sub(r'(?i)provinsi\s*', '', text).strip()
|
# Use prefix strategy: "PROVINSI " + result vs text
|
||||||
if val:
|
match, score = self._search_best_match_in_text(text_clean, self.PROVINSI_LIST, prefix="PROVINSI")
|
||||||
|
|
||||||
|
# LOWER THRESHOLD to 0.5 because "PROVINSI BALI" vs "PROVNSIBALI" is roughly 0.5-0.6 range
|
||||||
|
if match and score > 0.5:
|
||||||
|
result['provinsi'] = match
|
||||||
|
|
||||||
|
# Remove the found province (and label) from text to see what's left
|
||||||
|
# If we matched "PROVINSI JAWA TIMUR", the text might be "PROVNSIJAWATMRKABUPATENSUMENEP"
|
||||||
|
# It's hard to cleanly remove "PROVISI JAWA TIMUR" if it was fuzzy matched.
|
||||||
|
|
||||||
|
# BUT, we can try to find "KABUPATEN" or "KOTA" in the original text
|
||||||
|
# independent of the province match
|
||||||
|
if 'kabupaten' in text_clean.lower() or 'kota' in text_clean.lower():
|
||||||
|
parts = re.split(r'(?i)\s*(kabupaten|kota)', text_clean)
|
||||||
|
if len(parts) > 1:
|
||||||
|
kab_part = "".join(parts[1:]).strip()
|
||||||
|
kab_val = re.sub(r'^(?i)(kabupaten|kota)\s*', '', kab_part).strip()
|
||||||
|
if kab_val and result['kabupaten_kota'] is None:
|
||||||
|
prefix = "KABUPATEN" if "kabupaten" in text_clean.lower() else "KOTA"
|
||||||
|
result['kabupaten_kota'] = f"{prefix} {kab_val.upper()}"
|
||||||
|
break
|
||||||
|
|
||||||
|
# Fallback to keyword splitting (Legacy/Blurry fallback)
|
||||||
|
text_lower = text.lower()
|
||||||
|
val = text
|
||||||
|
|
||||||
|
# If keyword exists, strip it
|
||||||
|
if 'provinsi' in text_lower:
|
||||||
|
split_prov = re.split(r'(?i)provinsi\s*', text, 1)
|
||||||
|
if len(split_prov) > 1:
|
||||||
|
val = split_prov[1].strip()
|
||||||
|
else:
|
||||||
|
val = ""
|
||||||
|
|
||||||
|
# Check for merged text
|
||||||
|
if 'kabupaten' in text_lower or 'kota' in text_lower:
|
||||||
|
parts = re.split(r'(?i)\s*(kabupaten|kota)', val)
|
||||||
|
val = parts[0].strip()
|
||||||
|
|
||||||
|
if len(parts) > 1:
|
||||||
|
kab_part = "".join(parts[1:]).strip()
|
||||||
|
kab_val = re.sub(r'^(?i)(kabupaten|kota)\s*', '', kab_part).strip()
|
||||||
|
if kab_val and result['kabupaten_kota'] is None:
|
||||||
|
prefix = "KABUPATEN" if "kabupaten" in text_lower else "KOTA"
|
||||||
|
result['kabupaten_kota'] = f"{prefix} {kab_val.upper()}"
|
||||||
|
|
||||||
|
if val and len(val) > 2:
|
||||||
|
# Try fuzzy match again on the cleaned value
|
||||||
|
best_match = self._find_best_match(val.upper(), self.PROVINSI_LIST, cutoff=0.6)
|
||||||
|
if best_match:
|
||||||
|
result['provinsi'] = best_match
|
||||||
|
else:
|
||||||
result['provinsi'] = val.upper()
|
result['provinsi'] = val.upper()
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -104,12 +479,31 @@ class KTPExtractor:
|
|||||||
if 'header_kabupaten' in zone_texts:
|
if 'header_kabupaten' in zone_texts:
|
||||||
for text in zone_texts['header_kabupaten']:
|
for text in zone_texts['header_kabupaten']:
|
||||||
text_lower = text.lower()
|
text_lower = text.lower()
|
||||||
|
val = text
|
||||||
|
|
||||||
|
# Check keyword
|
||||||
if 'kabupaten' in text_lower or 'kota' in text_lower:
|
if 'kabupaten' in text_lower or 'kota' in text_lower:
|
||||||
val = re.sub(r'(?i)(kabupaten|kota)\s*', '', text).strip()
|
split_kab = re.split(r'(?i)\s*(kabupaten|kota)\s*', text, 1)
|
||||||
if val:
|
if len(split_kab) > 1:
|
||||||
result['kabupaten_kota'] = val.upper()
|
val = split_kab[-1].strip()
|
||||||
else:
|
else:
|
||||||
result['kabupaten_kota'] = text.upper()
|
val = ""
|
||||||
|
|
||||||
|
# If no keyword, but it's in the kabupaten zone, assume it's data
|
||||||
|
if val:
|
||||||
|
# Re-add prefix standard if we separated it or if it was missing
|
||||||
|
# Heuristic: if validation suggests it's a known regency, we are good.
|
||||||
|
# For now, standardize format.
|
||||||
|
if result['kabupaten_kota'] is None:
|
||||||
|
prefix = "KABUPATEN" if "kabupaten" in text_lower else "KOTA"
|
||||||
|
# If no keyword found, default to KABUPATEN? Or better check Wilayah?
|
||||||
|
# Let's default to detected keyword or KABUPATEN
|
||||||
|
if "kota" in text_lower:
|
||||||
|
prefix = "KOTA"
|
||||||
|
else:
|
||||||
|
prefix = "KABUPATEN"
|
||||||
|
|
||||||
|
result['kabupaten_kota'] = f"{prefix} {val.upper()}"
|
||||||
break
|
break
|
||||||
|
|
||||||
# NAMA from nama zone (skip label line)
|
# NAMA from nama zone (skip label line)
|
||||||
@@ -161,6 +555,89 @@ class KTPExtractor:
|
|||||||
result['alamat'] = val.upper()
|
result['alamat'] = val.upper()
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# RT/RW
|
||||||
|
if 'rt_rw' in zone_texts:
|
||||||
|
for text in zone_texts['rt_rw']:
|
||||||
|
rt_rw_match = re.search(r'(\d{3})\s*/\s*(\d{3})', text)
|
||||||
|
if rt_rw_match:
|
||||||
|
result['rt_rw'] = f"{rt_rw_match.group(1)}/{rt_rw_match.group(2)}"
|
||||||
|
break
|
||||||
|
|
||||||
|
# KEL/DESA
|
||||||
|
if 'kel_desa' in zone_texts:
|
||||||
|
for text in zone_texts['kel_desa']:
|
||||||
|
if 'kel' in text.lower() or 'desa' in text.lower():
|
||||||
|
val = self._extract_value_from_text(text)
|
||||||
|
if val and 'kel' not in val.lower():
|
||||||
|
result['kel_desa'] = val.upper()
|
||||||
|
break
|
||||||
|
elif result['kel_desa'] is None:
|
||||||
|
# Fallback context: simple text
|
||||||
|
result['kel_desa'] = text.upper()
|
||||||
|
|
||||||
|
# KECAMATAN
|
||||||
|
if 'kecamatan' in zone_texts:
|
||||||
|
for text in zone_texts['kecamatan']:
|
||||||
|
if 'kec' in text.lower():
|
||||||
|
val = self._extract_value_from_text(text)
|
||||||
|
if val and 'kec' not in val.lower():
|
||||||
|
result['kecamatan'] = val.upper()
|
||||||
|
break
|
||||||
|
elif result['kecamatan'] is None:
|
||||||
|
result['kecamatan'] = text.upper()
|
||||||
|
|
||||||
|
# AGAMA
|
||||||
|
if 'agama' in zone_texts:
|
||||||
|
for text in zone_texts['agama']:
|
||||||
|
val = text.upper()
|
||||||
|
if 'agama' in text.lower():
|
||||||
|
val = self._extract_value_from_text(text).upper()
|
||||||
|
|
||||||
|
# Verify against valid list
|
||||||
|
for agama in self.AGAMA_LIST:
|
||||||
|
if agama.upper() in val:
|
||||||
|
result['agama'] = agama.upper()
|
||||||
|
break
|
||||||
|
if result['agama']: break
|
||||||
|
|
||||||
|
# STATUS PERKAWINAN
|
||||||
|
if 'status' in zone_texts:
|
||||||
|
for text in zone_texts['status']:
|
||||||
|
val = text.upper()
|
||||||
|
# Normalize common OCR errors (e.g. BELUMKAWIN)
|
||||||
|
val = val.replace("BELUMKAWIN", "BELUM KAWIN")
|
||||||
|
|
||||||
|
# Check against official list
|
||||||
|
found_status = False
|
||||||
|
for status in self.STATUS_PERKAWINAN_LIST:
|
||||||
|
if status in val:
|
||||||
|
result['status_perkawinan'] = status
|
||||||
|
found_status = True
|
||||||
|
break
|
||||||
|
if found_status: break
|
||||||
|
|
||||||
|
# PEKERJAAN
|
||||||
|
if 'pekerjaan' in zone_texts:
|
||||||
|
for text in zone_texts['pekerjaan']:
|
||||||
|
val = text.upper()
|
||||||
|
if 'pekerjaan' in text.lower():
|
||||||
|
val = self._extract_value_from_text(text).upper()
|
||||||
|
|
||||||
|
# Check against list or take value
|
||||||
|
if len(val) > 3 and 'pekerjaan' not in val.lower():
|
||||||
|
result['pekerjaan'] = val
|
||||||
|
break
|
||||||
|
|
||||||
|
# WNI
|
||||||
|
if 'wni' in zone_texts:
|
||||||
|
for text in zone_texts['wni']:
|
||||||
|
if 'wni' in text.lower():
|
||||||
|
result['kewarganegaraan'] = 'WNI'
|
||||||
|
break
|
||||||
|
elif 'wna' in text.lower():
|
||||||
|
result['kewarganegaraan'] = 'WNA'
|
||||||
|
break
|
||||||
|
|
||||||
# PENERBITAN area (tempat & tanggal dalam satu zona)
|
# PENERBITAN area (tempat & tanggal dalam satu zona)
|
||||||
if 'penerbitan' in zone_texts:
|
if 'penerbitan' in zone_texts:
|
||||||
for text in zone_texts['penerbitan']:
|
for text in zone_texts['penerbitan']:
|
||||||
@@ -194,7 +671,7 @@ class KTPExtractor:
|
|||||||
'status_perkawinan': None,
|
'status_perkawinan': None,
|
||||||
'pekerjaan': None,
|
'pekerjaan': None,
|
||||||
'kewarganegaraan': None,
|
'kewarganegaraan': None,
|
||||||
'berlaku_hingga': None,
|
'berlaku_hingga': 'SEUMUR HIDUP', # Default sesuai peraturan pemerintah e-KTP
|
||||||
'provinsi': None,
|
'provinsi': None,
|
||||||
'kabupaten_kota': None,
|
'kabupaten_kota': None,
|
||||||
'tanggal_penerbitan': None,
|
'tanggal_penerbitan': None,
|
||||||
@@ -234,6 +711,14 @@ class KTPExtractor:
|
|||||||
|
|
||||||
# Fallback: Parse line by line for fields not found by zone
|
# Fallback: Parse line by line for fields not found by zone
|
||||||
for i, text in enumerate(texts):
|
for i, text in enumerate(texts):
|
||||||
|
# Skip baris yang hanya berisi punctuation atau kosong
|
||||||
|
text_stripped = text.strip()
|
||||||
|
if not text_stripped or text_stripped in [':', ':', '.', '-', '/', '|']:
|
||||||
|
continue
|
||||||
|
# Skip baris yang terlalu pendek (hanya 1-2 karakter non-alfanumerik)
|
||||||
|
if len(text_stripped) <= 2 and not any(c.isalnum() for c in text_stripped):
|
||||||
|
continue
|
||||||
|
|
||||||
text_lower = text.lower()
|
text_lower = text.lower()
|
||||||
|
|
||||||
# Normalize colons
|
# Normalize colons
|
||||||
@@ -242,19 +727,49 @@ class KTPExtractor:
|
|||||||
|
|
||||||
# ===== PROVINSI =====
|
# ===== PROVINSI =====
|
||||||
if 'provinsi' in text_lower and result['provinsi'] is None:
|
if 'provinsi' in text_lower and result['provinsi'] is None:
|
||||||
val = self._extract_after_label(text_normalized, 'provinsi')
|
# Split by PROVINSI and take remainder
|
||||||
|
split_prov = re.split(r'(?i)provinsi\s*', text, 1)
|
||||||
|
if len(split_prov) > 1:
|
||||||
|
val = split_prov[1].strip()
|
||||||
|
# Check if it contains kabupaten/kota (merged line case)
|
||||||
|
if 'kabupaten' in val.lower() or 'kota' in val.lower():
|
||||||
|
parts = re.split(r'(?i)\s*(kabupaten|kota)', val)
|
||||||
|
val = parts[0].strip()
|
||||||
|
|
||||||
if val:
|
if val:
|
||||||
|
# Fuzzy match against valid provinces
|
||||||
|
best_match = self._find_best_match(val.upper(), self.PROVINSI_LIST, cutoff=0.6)
|
||||||
|
if best_match:
|
||||||
|
result['provinsi'] = best_match
|
||||||
|
else:
|
||||||
result['provinsi'] = val.upper()
|
result['provinsi'] = val.upper()
|
||||||
elif i + 1 < len(texts) and 'provinsi' not in texts[i+1].lower():
|
|
||||||
# Mungkin value di line berikutnya
|
# Check for next line if current line only had 'PROVINSI'
|
||||||
result['provinsi'] = texts[i+1].strip().upper()
|
if result['provinsi'] is None and i + 1 < len(texts):
|
||||||
|
next_text = texts[i+1].strip()
|
||||||
|
next_lower = next_text.lower()
|
||||||
|
# Only take next line if it doesn't look like another field
|
||||||
|
if not any(kw in next_lower for kw in ['provinsi', 'kabupaten', 'kota', 'nik']):
|
||||||
|
# Fuzzy match next line
|
||||||
|
val = next_text.upper()
|
||||||
|
best_match = self._find_best_match(val, self.PROVINSI_LIST, cutoff=0.6)
|
||||||
|
if best_match:
|
||||||
|
result['provinsi'] = best_match
|
||||||
|
else:
|
||||||
|
result['provinsi'] = val
|
||||||
|
|
||||||
# ===== KABUPATEN/KOTA =====
|
# ===== KABUPATEN/KOTA =====
|
||||||
if ('kabupaten' in text_lower or 'kota' in text_lower or 'jakarta' in text_lower) and result['kabupaten_kota'] is None:
|
if ('kabupaten' in text_lower or 'kota' in text_lower or 'jakarta' in text_lower) and result['kabupaten_kota'] is None:
|
||||||
if 'provinsi' not in text_lower: # Bukan bagian dari provinsi
|
if 'provinsi' not in text_lower: # Bukan bagian dari provinsi
|
||||||
val = self._extract_after_label(text_normalized, 'kabupaten|kota')
|
# Split by KABUPATEN or KOTA and take remainder
|
||||||
|
split_kab = re.split(r'(?i)\s*(kabupaten|kota)\s*', text, 1)
|
||||||
|
if len(split_kab) > 1:
|
||||||
|
prefix = "KABUPATEN" if "kabupaten" in text_lower else "KOTA"
|
||||||
|
val = split_kab[-1].strip()
|
||||||
if val:
|
if val:
|
||||||
result['kabupaten_kota'] = val.upper()
|
result['kabupaten_kota'] = f"{prefix} {val.upper()}"
|
||||||
|
else:
|
||||||
|
result['kabupaten_kota'] = text.strip().upper()
|
||||||
else:
|
else:
|
||||||
result['kabupaten_kota'] = text.strip().upper()
|
result['kabupaten_kota'] = text.strip().upper()
|
||||||
|
|
||||||
@@ -312,13 +827,17 @@ class KTPExtractor:
|
|||||||
if re.match(r'^[ABO]{1,2}[+\-]?$', text.strip(), re.IGNORECASE) and len(text.strip()) <= 3:
|
if re.match(r'^[ABO]{1,2}[+\-]?$', text.strip(), re.IGNORECASE) and len(text.strip()) <= 3:
|
||||||
result['gol_darah'] = text.strip().upper()
|
result['gol_darah'] = text.strip().upper()
|
||||||
|
|
||||||
# ===== ALAMAT =====
|
# ===== ALAMAT ===== (dengan fuzzy label matching)
|
||||||
if 'alamat' in text_lower and result['alamat'] is None:
|
if result['alamat'] is None and self._is_label_match(text, 'alamat'):
|
||||||
val = self._extract_after_label(text_normalized, 'alamat')
|
val = self._extract_after_label(text_normalized, r'a{1,2}l{0,2}a?m{0,2}a?t')
|
||||||
if val:
|
if val:
|
||||||
result['alamat'] = val.upper()
|
result['alamat'] = val.upper()
|
||||||
elif i + 1 < len(texts):
|
elif i + 1 < len(texts):
|
||||||
result['alamat'] = texts[i+1].strip().upper()
|
# Ambil nilai dari baris berikutnya
|
||||||
|
next_text = texts[i+1].strip()
|
||||||
|
# Pastikan bukan label field lain
|
||||||
|
if len(next_text) > 2 and not self._is_label_match(next_text, 'rt_rw'):
|
||||||
|
result['alamat'] = next_text.upper()
|
||||||
|
|
||||||
# ===== RT/RW =====
|
# ===== RT/RW =====
|
||||||
rt_rw_match = re.search(r'(\d{3})\s*/\s*(\d{3})', text)
|
rt_rw_match = re.search(r'(\d{3})\s*/\s*(\d{3})', text)
|
||||||
@@ -346,9 +865,9 @@ class KTPExtractor:
|
|||||||
if len(next_text) > 2 and not any(kw in next_text.lower() for kw in ['agama', 'status', 'pekerjaan']):
|
if len(next_text) > 2 and not any(kw in next_text.lower() for kw in ['agama', 'status', 'pekerjaan']):
|
||||||
result['kecamatan'] = next_text.upper()
|
result['kecamatan'] = next_text.upper()
|
||||||
|
|
||||||
# ===== AGAMA =====
|
# ===== AGAMA ===== (dengan fuzzy label matching)
|
||||||
if 'agama' in text_lower:
|
if self._is_label_match(text, 'agama'):
|
||||||
val = self._extract_after_label(text_normalized, 'agama')
|
val = self._extract_after_label(text_normalized, r'a?g{0,2}a?m{0,2}a')
|
||||||
if val and result['agama'] is None:
|
if val and result['agama'] is None:
|
||||||
result['agama'] = val.upper()
|
result['agama'] = val.upper()
|
||||||
elif result['agama'] is None and i + 1 < len(texts):
|
elif result['agama'] is None and i + 1 < len(texts):
|
||||||
@@ -366,18 +885,19 @@ class KTPExtractor:
|
|||||||
|
|
||||||
# ===== STATUS PERKAWINAN =====
|
# ===== STATUS PERKAWINAN =====
|
||||||
if 'kawin' in text_lower:
|
if 'kawin' in text_lower:
|
||||||
|
if result['status_perkawinan'] is None:
|
||||||
|
# Check against official list first
|
||||||
|
text_upper = text.upper().replace("BELUMKAWIN", "BELUM KAWIN")
|
||||||
|
for status in self.STATUS_PERKAWINAN_LIST:
|
||||||
|
if status in text_upper:
|
||||||
|
result['status_perkawinan'] = status
|
||||||
|
break
|
||||||
|
|
||||||
|
# Fallback to extraction if not found in list
|
||||||
if result['status_perkawinan'] is None:
|
if result['status_perkawinan'] is None:
|
||||||
val = self._extract_after_label(text_normalized, 'status.*kawin|perkawinan')
|
val = self._extract_after_label(text_normalized, 'status.*kawin|perkawinan')
|
||||||
if val:
|
if val:
|
||||||
result['status_perkawinan'] = val.upper()
|
result['status_perkawinan'] = val.upper()
|
||||||
elif 'belum' in text_lower:
|
|
||||||
result['status_perkawinan'] = 'BELUM KAWIN'
|
|
||||||
elif 'kawin' in text_lower and 'cerai' not in text_lower:
|
|
||||||
result['status_perkawinan'] = 'KAWIN'
|
|
||||||
elif 'cerai hidup' in text_lower:
|
|
||||||
result['status_perkawinan'] = 'CERAI HIDUP'
|
|
||||||
elif 'cerai mati' in text_lower:
|
|
||||||
result['status_perkawinan'] = 'CERAI MATI'
|
|
||||||
|
|
||||||
# ===== PEKERJAAN =====
|
# ===== PEKERJAAN =====
|
||||||
if 'pekerjaan' in text_lower:
|
if 'pekerjaan' in text_lower:
|
||||||
@@ -430,6 +950,88 @@ class KTPExtractor:
|
|||||||
if result['berlaku_hingga'] or i > len(texts) * 0.7:
|
if result['berlaku_hingga'] or i > len(texts) * 0.7:
|
||||||
result['tanggal_penerbitan'] = found_date
|
result['tanggal_penerbitan'] = found_date
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# AGGRESSIVE SCAN: Cari agama dari semua teks OCR
|
||||||
|
# ============================================
|
||||||
|
# Indonesia hanya punya 6 agama resmi, mudah dideteksi
|
||||||
|
if result['agama'] is None:
|
||||||
|
# Daftar agama dengan variasi penulisan
|
||||||
|
agama_patterns = {
|
||||||
|
'ISLAM': ['ISLAM', 'ISLM', 'ISIAM', 'ISLAMI'],
|
||||||
|
'KRISTEN': ['KRISTEN', 'KRISTEN PROTESTAN', 'PROTESTAN', 'KRISTN'],
|
||||||
|
'KATOLIK': ['KATOLIK', 'KATHOLIK', 'KATHOLK', 'KATOLIK ROMA', 'KATOLIK.'],
|
||||||
|
'HINDU': ['HINDU', 'HNDU', 'HINDU DHARMA', 'HINDHU'],
|
||||||
|
'BUDDHA': ['BUDDHA', 'BUDHA', 'BUDDA', 'BUDDHIS'],
|
||||||
|
'KONGHUCU': ['KONGHUCU', 'KHONGHUCU', 'KONGHUCHU', 'CONFUCIUS'],
|
||||||
|
}
|
||||||
|
|
||||||
|
for text in texts:
|
||||||
|
text_upper = text.upper().strip()
|
||||||
|
# Skip jika teks terlalu pendek atau terlalu panjang
|
||||||
|
if len(text_upper) < 4 or len(text_upper) > 30:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for agama_std, variants in agama_patterns.items():
|
||||||
|
for variant in variants:
|
||||||
|
if variant in text_upper:
|
||||||
|
result['agama'] = agama_std
|
||||||
|
print(f" [AGAMA SCAN] Found '{variant}' in '{text_upper}' -> {agama_std}")
|
||||||
|
break
|
||||||
|
if result['agama']:
|
||||||
|
break
|
||||||
|
if result['agama']:
|
||||||
|
break
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# AGGRESSIVE SCAN: Cari golongan darah dari semua teks OCR
|
||||||
|
# ============================================
|
||||||
|
# Golongan darah hanya 4: A, B, AB, O (dengan/tanpa rhesus +/-)
|
||||||
|
if result['gol_darah'] is None:
|
||||||
|
gol_darah_patterns = ['AB+', 'AB-', 'A+', 'A-', 'B+', 'B-', 'O+', 'O-', 'AB', 'A', 'B', 'O']
|
||||||
|
|
||||||
|
for text in texts:
|
||||||
|
text_upper = text.upper().strip()
|
||||||
|
# Hapus punctuation umum
|
||||||
|
text_clean = re.sub(r'[:\.\,\s]+', '', text_upper)
|
||||||
|
# Konversi 0 (nol) menjadi O (huruf) - OCR sering salah baca
|
||||||
|
text_clean = text_clean.replace('0', 'O')
|
||||||
|
|
||||||
|
# Skip jika teks terlalu panjang (bukan gol darah)
|
||||||
|
if len(text_clean) > 10:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Cari match untuk gol darah (dari panjang ke pendek untuk prioritas AB sebelum A/B)
|
||||||
|
for gol in gol_darah_patterns:
|
||||||
|
# Exact match setelah dibersihkan
|
||||||
|
if text_clean == gol:
|
||||||
|
result['gol_darah'] = gol
|
||||||
|
print(f" [GOL DARAH SCAN] Found '{text_upper}' -> {gol}")
|
||||||
|
break
|
||||||
|
# Match dengan prefix GOL
|
||||||
|
if text_clean == f"GOL{gol}" or text_clean == f"GOLDARAH{gol}":
|
||||||
|
result['gol_darah'] = gol
|
||||||
|
print(f" [GOL DARAH SCAN] Found '{text_upper}' -> {gol}")
|
||||||
|
break
|
||||||
|
# Match sebagai single character di akhir teks pendek
|
||||||
|
if len(text_clean) <= 3 and text_clean.endswith(gol):
|
||||||
|
result['gol_darah'] = gol
|
||||||
|
print(f" [GOL DARAH SCAN] Found '{text_upper}' -> {gol}")
|
||||||
|
break
|
||||||
|
|
||||||
|
if result['gol_darah']:
|
||||||
|
break
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# AGGRESSIVE SCAN: Cari berlaku hingga dari semua teks OCR
|
||||||
|
# ============================================
|
||||||
|
if result['berlaku_hingga'] is None:
|
||||||
|
for text in texts:
|
||||||
|
text_upper = text.upper().strip()
|
||||||
|
if 'SEUMUR' in text_upper or 'HIDUP' in text_upper:
|
||||||
|
result['berlaku_hingga'] = 'SEUMUR HIDUP'
|
||||||
|
print(f" [BERLAKU SCAN] Found '{text_upper}' -> SEUMUR HIDUP")
|
||||||
|
break
|
||||||
|
|
||||||
# Post-processing
|
# Post-processing
|
||||||
result = self._post_process(result)
|
result = self._post_process(result)
|
||||||
|
|
||||||
@@ -505,6 +1107,21 @@ class KTPExtractor:
|
|||||||
else:
|
else:
|
||||||
result['nik'] = None
|
result['nik'] = None
|
||||||
|
|
||||||
|
# Fix format tanggal lahir yang salah
|
||||||
|
# Pattern: DDMM-YYYY (contoh: 1608-1976) -> DD-MM-YYYY (16-08-1976)
|
||||||
|
if result['tanggal_lahir']:
|
||||||
|
tl = result['tanggal_lahir']
|
||||||
|
# Match DDMM-YYYY format (salah)
|
||||||
|
wrong_format = re.match(r'^(\d{2})(\d{2})-(\d{4})$', tl)
|
||||||
|
if wrong_format:
|
||||||
|
result['tanggal_lahir'] = f"{wrong_format.group(1)}-{wrong_format.group(2)}-{wrong_format.group(3)}"
|
||||||
|
print(f" [DATE FIX] '{tl}' -> '{result['tanggal_lahir']}'")
|
||||||
|
# Match DDMMYYYY format (tanpa separator)
|
||||||
|
no_sep_format = re.match(r'^(\d{2})(\d{2})(\d{4})$', tl)
|
||||||
|
if no_sep_format:
|
||||||
|
result['tanggal_lahir'] = f"{no_sep_format.group(1)}-{no_sep_format.group(2)}-{no_sep_format.group(3)}"
|
||||||
|
print(f" [DATE FIX] '{tl}' -> '{result['tanggal_lahir']}'")
|
||||||
|
|
||||||
# Clean all string values - remove leading colons and extra whitespace
|
# Clean all string values - remove leading colons and extra whitespace
|
||||||
for field in result:
|
for field in result:
|
||||||
if result[field] and isinstance(result[field], str):
|
if result[field] and isinstance(result[field], str):
|
||||||
@@ -540,6 +1157,54 @@ class KTPExtractor:
|
|||||||
result['berlaku_hingga'] = 'SEUMUR HIDUP'
|
result['berlaku_hingga'] = 'SEUMUR HIDUP'
|
||||||
else:
|
else:
|
||||||
result['berlaku_hingga'] = bh
|
result['berlaku_hingga'] = bh
|
||||||
|
else:
|
||||||
|
# Fallback: Sesuai peraturan pemerintah, e-KTP berlaku seumur hidup
|
||||||
|
# Berlaku untuk e-KTP yang diterbitkan sejak 2011
|
||||||
|
result['berlaku_hingga'] = 'SEUMUR HIDUP'
|
||||||
|
print(" [FALLBACK] berlaku_hingga = SEUMUR HIDUP (peraturan pemerintah)")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Parse nama Bali jika terdeteksi
|
||||||
|
# ============================================
|
||||||
|
# Deteksi apakah ini KTP Bali berdasarkan:
|
||||||
|
# 1. Provinsi = BALI
|
||||||
|
# 2. NIK dimulai dengan 51 (kode Bali)
|
||||||
|
# 3. Nama mengandung komponen khas Bali (NI, I GUSTI, dll)
|
||||||
|
is_bali = False
|
||||||
|
if result.get('provinsi') and 'BALI' in result['provinsi'].upper():
|
||||||
|
is_bali = True
|
||||||
|
elif result.get('nik') and result['nik'].startswith('51'):
|
||||||
|
is_bali = True
|
||||||
|
elif result.get('nama'):
|
||||||
|
nama_upper = result['nama'].upper()
|
||||||
|
# Cek apakah nama dimulai dengan prefix Bali
|
||||||
|
if nama_upper.startswith('NI') or nama_upper.startswith('IGUSTI') or \
|
||||||
|
nama_upper.startswith('IDABAGUS') or nama_upper.startswith('IDAAYU') or \
|
||||||
|
any(nama_upper.startswith(p) for p in ['GUSTI', 'WAYAN', 'MADE', 'NYOMAN', 'KETUT', 'PUTU', 'KADEK', 'KOMANG']):
|
||||||
|
is_bali = True
|
||||||
|
|
||||||
|
if is_bali and result.get('nama'):
|
||||||
|
result['nama'] = self._parse_balinese_name(result['nama'])
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Validasi dan koreksi Agama
|
||||||
|
# ============================================
|
||||||
|
if result.get('agama'):
|
||||||
|
agama = result['agama'].upper().strip()
|
||||||
|
# Fuzzy match terhadap daftar agama valid
|
||||||
|
agama_match = None
|
||||||
|
best_ratio = 0
|
||||||
|
for valid_agama in self.AGAMA_LIST:
|
||||||
|
ratio = difflib.SequenceMatcher(None, agama, valid_agama.upper()).ratio()
|
||||||
|
if ratio > best_ratio and ratio > 0.6:
|
||||||
|
best_ratio = ratio
|
||||||
|
agama_match = valid_agama.upper()
|
||||||
|
|
||||||
|
if agama_match:
|
||||||
|
if agama_match != agama:
|
||||||
|
print(f" [AGAMA VALIDATE] '{agama}' -> '{agama_match}' (ratio={best_ratio:.2f})")
|
||||||
|
result['agama'] = agama_match
|
||||||
|
# Tidak ada fallback otomatis untuk agama - harus dari OCR
|
||||||
|
|
||||||
# Fix merged kabupaten/kota names (e.g., JAKARTASELATAN -> JAKARTA SELATAN)
|
# Fix merged kabupaten/kota names (e.g., JAKARTASELATAN -> JAKARTA SELATAN)
|
||||||
if result['kabupaten_kota']:
|
if result['kabupaten_kota']:
|
||||||
@@ -572,6 +1237,29 @@ class KTPExtractor:
|
|||||||
alamat = re.sub(r'\b(NO|BLOK)(\d+|[A-Z])\b', r'\1 \2', alamat, flags=re.IGNORECASE)
|
alamat = re.sub(r'\b(NO|BLOK)(\d+|[A-Z])\b', r'\1 \2', alamat, flags=re.IGNORECASE)
|
||||||
result['alamat'] = alamat.upper()
|
result['alamat'] = alamat.upper()
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Cross-validation: Tempat Lahir vs Kel/Desa
|
||||||
|
# ============================================
|
||||||
|
# Pada KTP, tempat lahir sering sama dengan desa/kelurahan
|
||||||
|
# Jika tempat_lahir mirip dengan kel_desa, gunakan yang tervalidasi
|
||||||
|
if result.get('tempat_lahir') and result.get('kel_desa'):
|
||||||
|
tl = result['tempat_lahir'].upper()
|
||||||
|
kd = result['kel_desa'].upper()
|
||||||
|
|
||||||
|
# Hitung similarity
|
||||||
|
ratio = difflib.SequenceMatcher(None, tl, kd).ratio()
|
||||||
|
|
||||||
|
if ratio > 0.7:
|
||||||
|
# Tempat lahir mirip dengan kel/desa, gunakan kel/desa yang sudah divalidasi
|
||||||
|
print(f" [CROSS-VALIDATE] Tempat Lahir '{tl}' mirip dengan Kel/Desa '{kd}' (ratio={ratio:.2f})")
|
||||||
|
result['tempat_lahir'] = kd
|
||||||
|
elif ratio > 0.5:
|
||||||
|
# Cukup mirip, log untuk debugging
|
||||||
|
print(f" [CROSS-VALIDATE] Tempat Lahir '{tl}' mungkin sama dengan Kel/Desa '{kd}' (ratio={ratio:.2f})")
|
||||||
|
|
||||||
|
# Jika tempat_lahir kosong tapi kel_desa ada, mungkin sama
|
||||||
|
# (tidak otomatis mengisi karena bisa beda)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
48
migrate_db.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import os
|
||||||
|
import pymysql
|
||||||
|
from database import DB_CONFIG
|
||||||
|
|
||||||
|
def migrate_db():
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host=DB_CONFIG['host'],
|
||||||
|
port=int(DB_CONFIG['port']),
|
||||||
|
user=DB_CONFIG['user'],
|
||||||
|
password=DB_CONFIG['password'],
|
||||||
|
database=DB_CONFIG['database']
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
# Check if column exists
|
||||||
|
print("Checking schema...")
|
||||||
|
cursor.execute("SHOW COLUMNS FROM ktp_records LIKE 'image_path'")
|
||||||
|
result = cursor.fetchone()
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
print("Adding image_path column to ktp_records...")
|
||||||
|
cursor.execute("ALTER TABLE ktp_records ADD COLUMN image_path VARCHAR(255) NULL AFTER berlaku_hingga")
|
||||||
|
conn.commit()
|
||||||
|
print("Migration successful: Added image_path column.")
|
||||||
|
else:
|
||||||
|
print("Column image_path already exists in KTP. No migration needed.")
|
||||||
|
|
||||||
|
# Check KK
|
||||||
|
print("Checking KK schema...")
|
||||||
|
cursor.execute("SHOW COLUMNS FROM kk_records LIKE 'image_path'")
|
||||||
|
result_kk = cursor.fetchone()
|
||||||
|
|
||||||
|
if not result_kk:
|
||||||
|
print("Adding image_path column to kk_records...")
|
||||||
|
cursor.execute("ALTER TABLE kk_records ADD COLUMN image_path VARCHAR(255) NULL AFTER kode_pos")
|
||||||
|
conn.commit()
|
||||||
|
print("Migration successful: Added image_path column to KK.")
|
||||||
|
else:
|
||||||
|
print("Column image_path already exists in KK.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Migration error: {e}")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate_db()
|
||||||
138
models.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""
|
||||||
|
Database Models for OCR Application
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from database import db
|
||||||
|
|
||||||
|
|
||||||
|
class KTPRecord(db.Model):
|
||||||
|
"""Model untuk menyimpan data KTP hasil OCR"""
|
||||||
|
__tablename__ = 'ktp_records'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||||
|
nik = db.Column(db.String(16), unique=True, nullable=True, index=True)
|
||||||
|
nama = db.Column(db.String(100), nullable=True)
|
||||||
|
tempat_lahir = db.Column(db.String(50), nullable=True)
|
||||||
|
tanggal_lahir = db.Column(db.String(20), nullable=True)
|
||||||
|
jenis_kelamin = db.Column(db.String(20), nullable=True)
|
||||||
|
gol_darah = db.Column(db.String(5), nullable=True)
|
||||||
|
alamat = db.Column(db.Text, nullable=True)
|
||||||
|
rt_rw = db.Column(db.String(10), nullable=True)
|
||||||
|
kel_desa = db.Column(db.String(50), nullable=True)
|
||||||
|
kecamatan = db.Column(db.String(50), nullable=True)
|
||||||
|
kabupaten_kota = db.Column(db.String(50), nullable=True)
|
||||||
|
provinsi = db.Column(db.String(50), nullable=True)
|
||||||
|
agama = db.Column(db.String(20), nullable=True)
|
||||||
|
status_perkawinan = db.Column(db.String(30), nullable=True)
|
||||||
|
pekerjaan = db.Column(db.String(50), nullable=True)
|
||||||
|
kewarganegaraan = db.Column(db.String(10), nullable=True)
|
||||||
|
berlaku_hingga = db.Column(db.String(20), nullable=True)
|
||||||
|
image_path = db.Column(db.String(255), nullable=True) # Path to saved KTP image
|
||||||
|
raw_text = db.Column(db.Text, nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert model to dictionary"""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'nik': self.nik,
|
||||||
|
'nama': self.nama,
|
||||||
|
'tempat_lahir': self.tempat_lahir,
|
||||||
|
'tanggal_lahir': self.tanggal_lahir,
|
||||||
|
'jenis_kelamin': self.jenis_kelamin,
|
||||||
|
'gol_darah': self.gol_darah,
|
||||||
|
'alamat': self.alamat,
|
||||||
|
'rt_rw': self.rt_rw,
|
||||||
|
'kel_desa': self.kel_desa,
|
||||||
|
'kecamatan': self.kecamatan,
|
||||||
|
'kabupaten_kota': self.kabupaten_kota,
|
||||||
|
'provinsi': self.provinsi,
|
||||||
|
'agama': self.agama,
|
||||||
|
'status_perkawinan': self.status_perkawinan,
|
||||||
|
'pekerjaan': self.pekerjaan,
|
||||||
|
'kewarganegaraan': self.kewarganegaraan,
|
||||||
|
'berlaku_hingga': self.berlaku_hingga,
|
||||||
|
'image_path': self.image_path,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_ocr_data(cls, ocr_data, raw_text=None):
|
||||||
|
"""Create KTPRecord from OCR extracted data"""
|
||||||
|
return cls(
|
||||||
|
nik=ocr_data.get('nik'),
|
||||||
|
nama=ocr_data.get('nama'),
|
||||||
|
tempat_lahir=ocr_data.get('tempat_lahir'),
|
||||||
|
tanggal_lahir=ocr_data.get('tanggal_lahir'),
|
||||||
|
jenis_kelamin=ocr_data.get('jenis_kelamin'),
|
||||||
|
gol_darah=ocr_data.get('gol_darah'),
|
||||||
|
alamat=ocr_data.get('alamat'),
|
||||||
|
rt_rw=ocr_data.get('rt_rw'),
|
||||||
|
kel_desa=ocr_data.get('kel_desa'),
|
||||||
|
kecamatan=ocr_data.get('kecamatan'),
|
||||||
|
kabupaten_kota=ocr_data.get('kabupaten_kota'),
|
||||||
|
provinsi=ocr_data.get('provinsi'),
|
||||||
|
agama=ocr_data.get('agama'),
|
||||||
|
status_perkawinan=ocr_data.get('status_perkawinan'),
|
||||||
|
pekerjaan=ocr_data.get('pekerjaan'),
|
||||||
|
kewarganegaraan=ocr_data.get('kewarganegaraan'),
|
||||||
|
berlaku_hingga=ocr_data.get('berlaku_hingga'),
|
||||||
|
raw_text=raw_text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class KKRecord(db.Model):
|
||||||
|
"""Model untuk menyimpan data Kartu Keluarga hasil OCR"""
|
||||||
|
__tablename__ = 'kk_records'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||||
|
no_kk = db.Column(db.String(16), unique=True, nullable=True, index=True)
|
||||||
|
kepala_keluarga = db.Column(db.String(100), nullable=True)
|
||||||
|
alamat = db.Column(db.Text, nullable=True)
|
||||||
|
rt_rw = db.Column(db.String(10), nullable=True)
|
||||||
|
kel_desa = db.Column(db.String(50), nullable=True)
|
||||||
|
kecamatan = db.Column(db.String(50), nullable=True)
|
||||||
|
kabupaten_kota = db.Column(db.String(50), nullable=True)
|
||||||
|
provinsi = db.Column(db.String(50), nullable=True)
|
||||||
|
kode_pos = db.Column(db.String(10), nullable=True)
|
||||||
|
image_path = db.Column(db.String(255), nullable=True) # Path to saved KK image
|
||||||
|
raw_text = db.Column(db.Text, nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert model to dictionary"""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'no_kk': self.no_kk,
|
||||||
|
'kepala_keluarga': self.kepala_keluarga,
|
||||||
|
'alamat': self.alamat,
|
||||||
|
'rt_rw': self.rt_rw,
|
||||||
|
'kel_desa': self.kel_desa,
|
||||||
|
'kecamatan': self.kecamatan,
|
||||||
|
'kabupaten_kota': self.kabupaten_kota,
|
||||||
|
'provinsi': self.provinsi,
|
||||||
|
'kode_pos': self.kode_pos,
|
||||||
|
'image_path': self.image_path,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_ocr_data(cls, ocr_data, raw_text=None):
|
||||||
|
"""Create KKRecord from OCR extracted data"""
|
||||||
|
return cls(
|
||||||
|
no_kk=ocr_data.get('no_kk'),
|
||||||
|
kepala_keluarga=ocr_data.get('kepala_keluarga'),
|
||||||
|
alamat=ocr_data.get('alamat'),
|
||||||
|
rt_rw=ocr_data.get('rt_rw'),
|
||||||
|
kel_desa=ocr_data.get('kel_desa'),
|
||||||
|
kecamatan=ocr_data.get('kecamatan'),
|
||||||
|
kabupaten_kota=ocr_data.get('kabupaten_kota'),
|
||||||
|
provinsi=ocr_data.get('provinsi'),
|
||||||
|
kode_pos=ocr_data.get('kode_pos'),
|
||||||
|
raw_text=raw_text
|
||||||
|
)
|
||||||
@@ -20,16 +20,19 @@ class OCREngine:
|
|||||||
|
|
||||||
def preprocess_image(self, image_path: str) -> np.ndarray:
|
def preprocess_image(self, image_path: str) -> np.ndarray:
|
||||||
"""
|
"""
|
||||||
Preprocessing gambar untuk hasil OCR lebih baik
|
Enhanced preprocessing untuk hasil OCR lebih baik
|
||||||
|
Based on Context7 OpenCV documentation:
|
||||||
- Resize jika terlalu besar
|
- Resize jika terlalu besar
|
||||||
- Enhance contrast
|
- Denoising untuk mengurangi noise
|
||||||
|
- CLAHE untuk adaptive histogram equalization
|
||||||
|
- Sharpening untuk teks lebih jelas
|
||||||
"""
|
"""
|
||||||
img = cv2.imread(image_path)
|
img = cv2.imread(image_path)
|
||||||
if img is None:
|
if img is None:
|
||||||
raise ValueError(f"Tidak dapat membaca gambar: {image_path}")
|
raise ValueError(f"Tidak dapat membaca gambar: {image_path}")
|
||||||
|
|
||||||
# Resize jika terlalu besar (max 2000px)
|
# Resize jika terlalu besar (max 1500px - optimized for speed)
|
||||||
max_dim = 2000
|
max_dim = 1500
|
||||||
height, width = img.shape[:2]
|
height, width = img.shape[:2]
|
||||||
if max(height, width) > max_dim:
|
if max(height, width) > max_dim:
|
||||||
scale = max_dim / max(height, width)
|
scale = max_dim / max(height, width)
|
||||||
@@ -38,12 +41,20 @@ class OCREngine:
|
|||||||
# Convert ke grayscale untuk preprocessing
|
# Convert ke grayscale untuk preprocessing
|
||||||
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||||
|
|
||||||
# Enhance contrast menggunakan CLAHE
|
# Denoise (from Context7) - mengurangi noise tanpa blur teks
|
||||||
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
denoised = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21)
|
||||||
enhanced = clahe.apply(gray)
|
|
||||||
|
# Enhanced CLAHE untuk dokumen (from Context7)
|
||||||
|
# clipLimit lebih tinggi untuk kontras lebih baik
|
||||||
|
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
|
||||||
|
enhanced = clahe.apply(denoised)
|
||||||
|
|
||||||
|
# Sharpen using kernel (from Context7)
|
||||||
|
kernel = np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]], dtype=np.float32)
|
||||||
|
sharpened = cv2.filter2D(enhanced, -1, kernel)
|
||||||
|
|
||||||
# Convert kembali ke BGR untuk PaddleOCR
|
# Convert kembali ke BGR untuk PaddleOCR
|
||||||
enhanced_bgr = cv2.cvtColor(enhanced, cv2.COLOR_GRAY2BGR)
|
enhanced_bgr = cv2.cvtColor(sharpened, cv2.COLOR_GRAY2BGR)
|
||||||
|
|
||||||
return enhanced_bgr
|
return enhanced_bgr
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,6 @@ paddleocr
|
|||||||
flask
|
flask
|
||||||
pillow
|
pillow
|
||||||
opencv-python
|
opencv-python
|
||||||
|
pymysql
|
||||||
|
flask-sqlalchemy
|
||||||
|
requests
|
||||||
|
|||||||
403
static/style.css
@@ -175,6 +175,121 @@ header h1 {
|
|||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Crop Container */
|
||||||
|
.crop-container {
|
||||||
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-area {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
/* Let clicks pass through, handles catch them */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Perspective Crop Handles */
|
||||||
|
.crop-handle {
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--accent-primary);
|
||||||
|
border: 2px solid #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
cursor: move;
|
||||||
|
pointer-events: auto;
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-handle:hover,
|
||||||
|
.crop-handle.active {
|
||||||
|
background: #fff;
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
transform: translate(-50%, -50%) scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Crop Actions & Controls */
|
||||||
|
.crop-actions-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rotation-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rotation-control label {
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rotation-control input[type="range"] {
|
||||||
|
flex: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-action-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-action-btn.primary {
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-action-btn.primary:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-action-btn.primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-action-btn.secondary {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-action-btn.secondary:hover {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Process Button */
|
/* Process Button */
|
||||||
@@ -533,6 +648,290 @@ footer a:hover {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
/* Archive Header Button */
|
||||||
background: var(--text-muted);
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-header-btn {
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--accent-secondary);
|
||||||
|
color: var(--accent-secondary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-header-btn:hover {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
overflow: auto;
|
||||||
|
animation: fadeIn 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
margin: 5% auto;
|
||||||
|
padding: 2rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 1000px;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||||
|
position: relative;
|
||||||
|
animation: slideDown 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
transform: translateY(-50px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Archive List Grid */
|
||||||
|
.archive-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-card {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-card-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
background: #000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-card-img img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-card:hover .archive-card-img img {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-card-content {
|
||||||
|
padding: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-card-content h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-card-meta {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-card-actions {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn:hover {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-loading,
|
||||||
|
.archive-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print Styles */
|
||||||
|
@media print {
|
||||||
|
|
||||||
|
/* Reset Page */
|
||||||
|
@page {
|
||||||
|
margin: 0;
|
||||||
|
size: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: white !important;
|
||||||
|
/* Ensure no scroll or extra pages from hidden content */
|
||||||
|
height: 100vh !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide EVERYTHING initially with high specificity */
|
||||||
|
body * {
|
||||||
|
visibility: hidden !important;
|
||||||
|
display: none !important;
|
||||||
|
/* Force display none to remove layout space */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show ONLY Print Area and its children */
|
||||||
|
#printArea,
|
||||||
|
#printArea * {
|
||||||
|
visibility: visible !important;
|
||||||
|
display: flex !important;
|
||||||
|
/* Restore display for parent */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset display for children of printArea specifically */
|
||||||
|
#printArea * {
|
||||||
|
display: block !important;
|
||||||
|
/* Default to block or whatever needed */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specific fix for image inside */
|
||||||
|
#printArea img {
|
||||||
|
display: inline-block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#printArea {
|
||||||
|
position: fixed !important;
|
||||||
|
/* Fixed helps detach from flow */
|
||||||
|
left: 0 !important;
|
||||||
|
top: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
z-index: 99999 !important;
|
||||||
|
background: white !important;
|
||||||
|
|
||||||
|
display: flex !important;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: 5cm;
|
||||||
|
/* Adjust padding as needed */
|
||||||
|
}
|
||||||
|
|
||||||
|
.ktp-print-size {
|
||||||
|
/* Standar ISO/IEC 7810 ID-1: 85.60 × 53.98 mm */
|
||||||
|
width: 85.60mm !important;
|
||||||
|
height: 53.98mm !important;
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
border: 1px dashed #ccc;
|
||||||
|
box-shadow: none !important;
|
||||||
|
/* Remove any shadow */
|
||||||
|
}
|
||||||
|
|
||||||
|
.a4-print-size {
|
||||||
|
/* A4 Landscape: 297mm x 210mm */
|
||||||
|
/* Use slightly less to account for margins if necessary, but standard is distinct */
|
||||||
|
width: 297mm !important;
|
||||||
|
height: 210mm !important;
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,12 @@
|
|||||||
<header>
|
<header>
|
||||||
<h1>📄 OCR KTP/KK</h1>
|
<h1>📄 OCR KTP/KK</h1>
|
||||||
<p class="subtitle">Pembaca Dokumen Indonesia Offline</p>
|
<p class="subtitle">Pembaca Dokumen Indonesia Offline</p>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button id="reloadBtn" class="archive-header-btn secondary" title="Reload halaman">🔄 Reset /
|
||||||
|
Baru</button>
|
||||||
|
<button id="archiveBtn" class="archive-header-btn">📂 Arsip KTP</button>
|
||||||
|
<button id="archiveKKBtn" class="archive-header-btn">📂 Arsip KK</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
@@ -40,7 +46,42 @@
|
|||||||
</label>
|
</label>
|
||||||
<p class="file-types">PNG, JPG, JPEG, BMP, WEBP (max 16MB)</p>
|
<p class="file-types">PNG, JPG, JPEG, BMP, WEBP (max 16MB)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Crop Container -->
|
||||||
|
<div id="cropContainer" class="crop-container" style="display: none;">
|
||||||
|
<!-- Canvas for editing (rotation & crop) -->
|
||||||
|
<canvas id="cropCanvas" class="preview-image"></canvas>
|
||||||
|
<!-- Keep img for simple viewing if needed, or just use canvas. Let's rely on canvas for editor -->
|
||||||
<img id="preview" class="preview-image" style="display: none;">
|
<img id="preview" class="preview-image" style="display: none;">
|
||||||
|
|
||||||
|
<div id="cropArea" class="crop-area">
|
||||||
|
<svg width="100%" height="100%"
|
||||||
|
style="position: absolute; top:0; left:0; overflow:visible;">
|
||||||
|
<polygon id="cropPolygon" points=""
|
||||||
|
style="fill: rgba(255, 255, 255, 0.1); stroke: var(--accent-primary); stroke-width: 2; vector-effect: non-scaling-stroke;">
|
||||||
|
</polygon>
|
||||||
|
</svg>
|
||||||
|
<!-- Handles TL, TR, BR, BL -->
|
||||||
|
<div class="crop-handle" data-index="0"></div>
|
||||||
|
<div class="crop-handle" data-index="1"></div>
|
||||||
|
<div class="crop-handle" data-index="2"></div>
|
||||||
|
<div class="crop-handle" data-index="3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Crop Actions -->
|
||||||
|
<div id="cropActions" class="crop-actions-container" style="display: none;">
|
||||||
|
<div class="rotation-control">
|
||||||
|
<label for="rotationSlider">Rotasi: <span id="rotationValue">0°</span></label>
|
||||||
|
<input type="range" id="rotationSlider" min="-45" max="45" value="0" step="1">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="crop-buttons">
|
||||||
|
<button type="button" id="resetCropBtn" class="crop-action-btn secondary">🔄 Reset</button>
|
||||||
|
<button type="button" id="applyCropBtn" class="crop-action-btn primary">✂️ Terapkan
|
||||||
|
Crop</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="processBtn" class="process-btn" disabled>
|
<button id="processBtn" class="process-btn" disabled>
|
||||||
@@ -54,8 +95,11 @@
|
|||||||
<div class="results-header">
|
<div class="results-header">
|
||||||
<h2>📋 Hasil Ekstraksi</h2>
|
<h2>📋 Hasil Ekstraksi</h2>
|
||||||
<div class="results-actions">
|
<div class="results-actions">
|
||||||
<button class="action-btn" id="copyBtn" title="Copy JSON">📋 Copy</button>
|
<button class="action-btn secondary" id="printBtn">🖨️ Cetak</button>
|
||||||
<button class="action-btn" id="exportBtn" title="Export JSON">💾 Export</button>
|
<button class="action-btn secondary" id="downloadBtn">⬇️ Unduh</button>
|
||||||
|
<button class="action-btn primary" id="saveBtn" title="Simpan KTP">💾 Simpan</button>
|
||||||
|
<button class="action-btn" id="copyBtn" title="Copy Text (Word)">📋 Copy</button>
|
||||||
|
<button class="action-btn" id="exportBtn" title="Download Excel (.xlsx)">📤 Excel</button>
|
||||||
<button class="action-btn secondary" id="toggleRaw">📝 Raw Text</button>
|
<button class="action-btn secondary" id="toggleRaw">📝 Raw Text</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,6 +132,52 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Login Modal -->
|
||||||
|
<div id="loginModal" class="modal" style="display: none;">
|
||||||
|
<div class="modal-content" style="max-width: 400px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>🔐 Login Arsip</h2>
|
||||||
|
<span class="close-btn" id="closeLoginBtn">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p style="margin-bottom:1rem; color:var(--text-secondary);">Masukkan password untuk mengakses arsip
|
||||||
|
(Default: admin / 123).</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Username</label>
|
||||||
|
<input type="text" id="loginUser" class="form-control" value="admin">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" id="loginPass" class="form-control" placeholder="Password">
|
||||||
|
</div>
|
||||||
|
<div id="loginError"
|
||||||
|
style="color:var(--text-error); display:none; margin-bottom:1rem; font-size:0.9rem;"></div>
|
||||||
|
<button id="submitLoginBtn" class="action-btn primary" style="width:100%;">Masuk</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Archive Modal -->
|
||||||
|
<div id="archiveModal" class="modal" style="display: none;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>📂 Arsip KTP</h2>
|
||||||
|
<button id="closeModalBtn" class="close-btn">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="archiveList" class="archive-list">
|
||||||
|
<!-- Cards will be loaded here -->
|
||||||
|
</div>
|
||||||
|
<div id="archiveLoading" class="archive-loading" style="display: none;">
|
||||||
|
⏳ Memuat...
|
||||||
|
</div>
|
||||||
|
<div id="archiveEmpty" class="archive-empty" style="display: none;">
|
||||||
|
Belum ada KTP yang disimpan
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<p>OCR menggunakan <a href="https://github.com/PaddlePaddle/PaddleOCR" target="_blank">PaddleOCR</a> • Data
|
<p>OCR menggunakan <a href="https://github.com/PaddlePaddle/PaddleOCR" target="_blank">PaddleOCR</a> • Data
|
||||||
diproses secara lokal</p>
|
diproses secara lokal</p>
|
||||||
@@ -97,8 +187,10 @@
|
|||||||
<script>
|
<script>
|
||||||
// State
|
// State
|
||||||
let selectedFile = null;
|
let selectedFile = null;
|
||||||
let docType = 'ktp';
|
let originalImageObject = null; // For cropping
|
||||||
let extractedData = null;
|
let extractedData = null;
|
||||||
|
let currentDocType = 'ktp'; // Default
|
||||||
|
let currentArchiveType = 'ktp'; // Default for archive view
|
||||||
|
|
||||||
// Elements
|
// Elements
|
||||||
const dropzone = document.getElementById('dropzone');
|
const dropzone = document.getElementById('dropzone');
|
||||||
@@ -146,7 +238,7 @@
|
|||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
docBtns.forEach(b => b.classList.remove('active'));
|
docBtns.forEach(b => b.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
docType = btn.dataset.type;
|
currentDocType = btn.dataset.type;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,12 +269,20 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Click on dropzone
|
// Click on dropzone
|
||||||
|
// Click on dropzone - DISABLED (User request: Only 'Pilih File' button should work)
|
||||||
dropzone.addEventListener('click', (e) => {
|
dropzone.addEventListener('click', (e) => {
|
||||||
if (e.target === dropzone || e.target.closest('.dropzone-content')) {
|
// Do nothing. Label 'file-btn' handles clicks on itself automatically.
|
||||||
fileInput.click();
|
// preventing accidental uploads when clicking background/crop area.
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Canvas & Rotation Variables
|
||||||
|
const cropCanvas = document.getElementById('cropCanvas');
|
||||||
|
const rotationSlider = document.getElementById('rotationSlider');
|
||||||
|
const rotationValue = document.getElementById('rotationValue');
|
||||||
|
|
||||||
|
let currentRotation = 0;
|
||||||
|
// let originalImageObject = null; // Store Image object for redraws - moved to global state
|
||||||
|
|
||||||
function handleFile(file) {
|
function handleFile(file) {
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
showError('File harus berupa gambar');
|
showError('File harus berupa gambar');
|
||||||
@@ -194,14 +294,36 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
originalFile = file;
|
||||||
selectedFile = file;
|
selectedFile = file;
|
||||||
|
currentRotation = 0;
|
||||||
|
updateRotationUI();
|
||||||
|
|
||||||
// Show preview
|
// Load image
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
preview.src = e.target.result;
|
originalImageData = e.target.result;
|
||||||
preview.style.display = 'block';
|
|
||||||
|
// Create Image object
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
originalImageObject = img;
|
||||||
|
preview.src = e.target.result; // Keep this for backup/debugging
|
||||||
|
|
||||||
|
// Render to canvas
|
||||||
|
renderEditor();
|
||||||
|
|
||||||
|
cropCanvas.style.display = 'block';
|
||||||
|
preview.style.display = 'none';
|
||||||
|
cropContainer.style.display = 'block';
|
||||||
dropzone.querySelector('.dropzone-content').style.display = 'none';
|
dropzone.querySelector('.dropzone-content').style.display = 'none';
|
||||||
|
cropActions.style.display = 'flex';
|
||||||
|
|
||||||
|
// Init crop area after first render
|
||||||
|
// Small timeout to ensure layout is done
|
||||||
|
setTimeout(initCropArea, 50);
|
||||||
|
};
|
||||||
|
img.src = e.target.result;
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
@@ -210,6 +332,272 @@
|
|||||||
resultsSection.style.display = 'none';
|
resultsSection.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle Rotation
|
||||||
|
rotationSlider.addEventListener('input', (e) => {
|
||||||
|
currentRotation = parseInt(e.target.value);
|
||||||
|
updateRotationUI();
|
||||||
|
renderEditor();
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateRotationUI() {
|
||||||
|
rotationSlider.value = currentRotation;
|
||||||
|
rotationValue.textContent = currentRotation + '°';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditor() {
|
||||||
|
if (!originalImageObject) return;
|
||||||
|
|
||||||
|
const angleRad = currentRotation * Math.PI / 180;
|
||||||
|
const sin = Math.abs(Math.sin(angleRad));
|
||||||
|
const cos = Math.abs(Math.cos(angleRad));
|
||||||
|
|
||||||
|
// Calculate new bounding box size
|
||||||
|
const width = originalImageObject.naturalWidth;
|
||||||
|
const height = originalImageObject.naturalHeight;
|
||||||
|
const newWidth = width * cos + height * sin;
|
||||||
|
const newHeight = width * sin + height * cos;
|
||||||
|
|
||||||
|
// Set canvas internal size
|
||||||
|
cropCanvas.width = newWidth;
|
||||||
|
cropCanvas.height = newHeight;
|
||||||
|
|
||||||
|
const ctx = cropCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// Clear & Draw
|
||||||
|
ctx.clearRect(0, 0, newWidth, newHeight);
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(newWidth / 2, newHeight / 2);
|
||||||
|
ctx.rotate(angleRad);
|
||||||
|
ctx.drawImage(originalImageObject, -width / 2, -height / 2);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crop functionality (Perspective / 4-Point)
|
||||||
|
const cropContainer = document.getElementById('cropContainer');
|
||||||
|
|
||||||
|
// Prevent clicks in crop area from triggering file upload
|
||||||
|
cropContainer.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
cropContainer.addEventListener('mousedown', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
const cropArea = document.getElementById('cropArea');
|
||||||
|
const cropPolygon = document.getElementById('cropPolygon');
|
||||||
|
const cropActions = document.getElementById('cropActions');
|
||||||
|
const resetCropBtn = document.getElementById('resetCropBtn');
|
||||||
|
const applyCropBtn = document.getElementById('applyCropBtn');
|
||||||
|
|
||||||
|
let originalFile = null;
|
||||||
|
let originalImageData = null;
|
||||||
|
let cropPoints = []; // [{x,y}, {x,y}, {x,y}, {x,y}]
|
||||||
|
let isDragging = false;
|
||||||
|
let activeHandleIndex = null;
|
||||||
|
|
||||||
|
// KTP aspect ratio: 85.6mm x 53.98mm = ~1.586
|
||||||
|
const KTP_ASPECT_RATIO = 85.6 / 53.98;
|
||||||
|
// KK aspect ratio (A4 Landscape): 297mm x 210mm = ~1.414
|
||||||
|
const KK_ASPECT_RATIO = 297 / 210;
|
||||||
|
|
||||||
|
function initCropArea() {
|
||||||
|
// Match cropArea size/pos to canvas size/pos
|
||||||
|
cropArea.style.left = cropCanvas.offsetLeft + 'px';
|
||||||
|
cropArea.style.top = cropCanvas.offsetTop + 'px';
|
||||||
|
cropArea.style.width = cropCanvas.offsetWidth + 'px';
|
||||||
|
cropArea.style.height = cropCanvas.offsetHeight + 'px';
|
||||||
|
|
||||||
|
const w = cropCanvas.offsetWidth;
|
||||||
|
const h = cropCanvas.offsetHeight;
|
||||||
|
|
||||||
|
// Initialize default box (Centered Rectangle with appropriate ratio)
|
||||||
|
const targetRatio = currentDocType === 'kk' ? KK_ASPECT_RATIO : KTP_ASPECT_RATIO;
|
||||||
|
|
||||||
|
let boxW, boxH;
|
||||||
|
if (w / h > targetRatio) {
|
||||||
|
boxH = h * 0.7;
|
||||||
|
boxW = boxH * targetRatio;
|
||||||
|
} else {
|
||||||
|
boxW = w * 0.7;
|
||||||
|
boxH = boxW / targetRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cx = w / 2;
|
||||||
|
const cy = h / 2;
|
||||||
|
|
||||||
|
// 0: TL, 1: TR, 2: BR, 3: BL
|
||||||
|
cropPoints = [
|
||||||
|
{ x: cx - boxW / 2, y: cy - boxH / 2 },
|
||||||
|
{ x: cx + boxW / 2, y: cy - boxH / 2 },
|
||||||
|
{ x: cx + boxW / 2, y: cy + boxH / 2 },
|
||||||
|
{ x: cx - boxW / 2, y: cy + boxH / 2 }
|
||||||
|
];
|
||||||
|
|
||||||
|
updateCropVisuals();
|
||||||
|
cropArea.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCropVisuals() {
|
||||||
|
// Update Handles
|
||||||
|
const handles = cropArea.querySelectorAll('.crop-handle');
|
||||||
|
cropPoints.forEach((p, i) => {
|
||||||
|
if (handles[i]) {
|
||||||
|
handles[i].style.left = p.x + 'px';
|
||||||
|
handles[i].style.top = p.y + 'px';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Polygon
|
||||||
|
// SVG points format: x1,y1 x2,y2 ...
|
||||||
|
const pointsStr = cropPoints.map(p => `${p.x},${p.y}`).join(' ');
|
||||||
|
cropPolygon.setAttribute('points', pointsStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Dragging
|
||||||
|
const handles = cropArea.querySelectorAll('.crop-handle');
|
||||||
|
handles.forEach(handle => {
|
||||||
|
handle.addEventListener('mousedown', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
activeHandleIndex = parseInt(handle.dataset.index);
|
||||||
|
isDragging = true;
|
||||||
|
});
|
||||||
|
handle.addEventListener('touchstart', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
activeHandleIndex = parseInt(handle.dataset.index);
|
||||||
|
isDragging = true;
|
||||||
|
}, { passive: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleDragMove);
|
||||||
|
document.addEventListener('touchmove', handleDragMove, { passive: false });
|
||||||
|
document.addEventListener('mouseup', handleDragEnd);
|
||||||
|
document.addEventListener('touchend', handleDragEnd);
|
||||||
|
|
||||||
|
// Also allow updating cropArea size on window resize (since canvas might resize)
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (cropCanvas.offsetParent) {
|
||||||
|
cropArea.style.left = cropCanvas.offsetLeft + 'px';
|
||||||
|
cropArea.style.top = cropCanvas.offsetTop + 'px';
|
||||||
|
cropArea.style.width = cropCanvas.offsetWidth + 'px';
|
||||||
|
cropArea.style.height = cropCanvas.offsetHeight + 'px';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function getEventPos(e) {
|
||||||
|
const rect = cropArea.getBoundingClientRect();
|
||||||
|
let clientX, clientY;
|
||||||
|
|
||||||
|
if (e.touches && e.touches.length > 0) {
|
||||||
|
clientX = e.touches[0].clientX;
|
||||||
|
clientY = e.touches[0].clientY;
|
||||||
|
} else {
|
||||||
|
clientX = e.clientX;
|
||||||
|
clientY = e.clientY;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: clientX - rect.left,
|
||||||
|
y: clientY - rect.top
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragMove(e) {
|
||||||
|
if (!isDragging || activeHandleIndex === null) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const pos = getEventPos(e);
|
||||||
|
|
||||||
|
// Constrain to bounds
|
||||||
|
// Allow slightly outside? No, keep inside for UI sanity
|
||||||
|
const x = Math.max(0, Math.min(pos.x, cropArea.offsetWidth));
|
||||||
|
const y = Math.max(0, Math.min(pos.y, cropArea.offsetHeight));
|
||||||
|
|
||||||
|
cropPoints[activeHandleIndex] = { x, y };
|
||||||
|
updateCropVisuals();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd() {
|
||||||
|
isDragging = false;
|
||||||
|
activeHandleIndex = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook rotation to reset crop
|
||||||
|
rotationSlider.addEventListener('input', () => {
|
||||||
|
// We need to re-init crop area because canvas content changed
|
||||||
|
initCropArea();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset crop button
|
||||||
|
resetCropBtn.addEventListener('click', () => {
|
||||||
|
currentRotation = 0;
|
||||||
|
updateRotationUI();
|
||||||
|
|
||||||
|
if (originalImageObject) {
|
||||||
|
renderEditor();
|
||||||
|
cropCanvas.style.display = 'block';
|
||||||
|
preview.style.display = 'none';
|
||||||
|
setTimeout(initCropArea, 50);
|
||||||
|
selectedFile = originalFile;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply crop button (Perspective Transform API)
|
||||||
|
applyCropBtn.addEventListener('click', async () => {
|
||||||
|
if (!originalImageObject) return;
|
||||||
|
|
||||||
|
applyCropBtn.disabled = true;
|
||||||
|
applyCropBtn.textContent = '⏳ Memproses...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Get current Canvas blob (Rotated image)
|
||||||
|
const canvasBlob = await new Promise(resolve => cropCanvas.toBlob(resolve, 'image/jpeg', 0.95));
|
||||||
|
|
||||||
|
// 2. Calculate points relative to internal canvas resolution
|
||||||
|
const scaleX = cropCanvas.width / cropCanvas.offsetWidth;
|
||||||
|
const scaleY = cropCanvas.height / cropCanvas.offsetHeight;
|
||||||
|
|
||||||
|
// Map points to actual image coordinates
|
||||||
|
// Note: The backend expects [TL, TR, BR, BL] which is how cropPoints is ordered [0,1,2,3]
|
||||||
|
const realPoints = cropPoints.map(p => [p.x * scaleX, p.y * scaleY]);
|
||||||
|
|
||||||
|
// 3. Send to server
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', canvasBlob, 'rotated_temp.jpg');
|
||||||
|
formData.append('image', canvasBlob, 'rotated_temp.jpg');
|
||||||
|
formData.append('points', JSON.stringify(realPoints));
|
||||||
|
formData.append('doc_type', currentDocType);
|
||||||
|
|
||||||
|
const response = await fetch('/api/transform-perspective', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Update preview with transformed image
|
||||||
|
preview.src = result.image_url;
|
||||||
|
preview.style.display = 'block';
|
||||||
|
cropCanvas.style.display = 'none';
|
||||||
|
cropArea.style.display = 'none';
|
||||||
|
|
||||||
|
// Fetch blob to update selectedFile
|
||||||
|
const resBlob = await fetch(result.image_url).then(r => r.blob());
|
||||||
|
selectedFile = new File([resBlob], result.filename || 'perspective_cropped.jpg', { type: 'image/jpeg' });
|
||||||
|
|
||||||
|
} else {
|
||||||
|
showError('Gagal transformasi: ' + result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Crop error:', error);
|
||||||
|
showError('Gagal memproses crop: ' + error.message);
|
||||||
|
} finally {
|
||||||
|
applyCropBtn.disabled = false;
|
||||||
|
applyCropBtn.textContent = '✂️ Terapkan Crop';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Process button
|
// Process button
|
||||||
processBtn.addEventListener('click', async () => {
|
processBtn.addEventListener('click', async () => {
|
||||||
if (!selectedFile) return;
|
if (!selectedFile) return;
|
||||||
@@ -224,7 +612,7 @@
|
|||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', selectedFile);
|
formData.append('file', selectedFile);
|
||||||
formData.append('doc_type', docType);
|
formData.append('doc_type', currentDocType);
|
||||||
|
|
||||||
const response = await fetch('/upload', {
|
const response = await fetch('/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -235,6 +623,10 @@
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
extractedData = result.data;
|
extractedData = result.data;
|
||||||
|
currentDocType = result.doc_type || 'ktp';
|
||||||
|
if (result.validation) {
|
||||||
|
validationResult = result.validation;
|
||||||
|
}
|
||||||
displayResults(result);
|
displayResults(result);
|
||||||
hideError();
|
hideError();
|
||||||
} else {
|
} else {
|
||||||
@@ -526,24 +918,61 @@
|
|||||||
rawTextSection.style.display = isVisible ? 'none' : 'block';
|
rawTextSection.style.display = isVisible ? 'none' : 'block';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Copy to clipboard
|
// Copy to clipboard (Formatted Text for Word)
|
||||||
document.getElementById('copyBtn').addEventListener('click', () => {
|
document.getElementById('copyBtn').addEventListener('click', () => {
|
||||||
if (extractedData) {
|
if (extractedData) {
|
||||||
navigator.clipboard.writeText(JSON.stringify(extractedData, null, 2))
|
// Format as Key: Value text
|
||||||
.then(() => alert('Data berhasil disalin!'));
|
const excludeKeys = ['raw_text', 'image_path', 'id', 'created_at', 'updated_at'];
|
||||||
|
const text = Object.entries(extractedData)
|
||||||
|
.filter(([k, v]) => !excludeKeys.includes(k) && v)
|
||||||
|
.map(([k, v]) => {
|
||||||
|
const label = k.replace(/_/g, ' ').toUpperCase();
|
||||||
|
return `${label}: ${v}`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
.then(() => alert('Data berhasil disalin (Format Teks)! Bisa dipaste di Word/Notepad.'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export JSON
|
// Export Excel (Real .xlsx via Backend)
|
||||||
document.getElementById('exportBtn').addEventListener('click', () => {
|
document.getElementById('exportBtn').addEventListener('click', async () => {
|
||||||
if (extractedData) {
|
if (!extractedData) return;
|
||||||
const blob = new Blob([JSON.stringify(extractedData, null, 2)], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
const btn = document.getElementById('exportBtn');
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = '⏳...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/export-excel', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(extractedData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `${docType}_data.json`;
|
const filename = extractedData.nik ? `Data_KTP_${extractedData.nik}.xlsx` : 'Data_KTP.xlsx';
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert('Gagal export excel: ' + (err.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('Error export excel.');
|
||||||
|
} finally {
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -556,14 +985,371 @@
|
|||||||
errorSection.style.display = 'none';
|
errorSection.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset on new file selection
|
// Save KTP Button
|
||||||
preview.addEventListener('click', () => {
|
const saveBtn = document.getElementById('saveBtn');
|
||||||
preview.style.display = 'none';
|
saveBtn.addEventListener('click', async () => {
|
||||||
dropzone.querySelector('.dropzone-content').style.display = 'flex';
|
if (!extractedData || !selectedFile) return;
|
||||||
selectedFile = null;
|
|
||||||
processBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
fileInput.value = '';
|
saveBtn.innerHTML = '⏳ Menyimpan...';
|
||||||
|
|
||||||
|
// Determine endpoint based on currentDocType
|
||||||
|
const endpoint = currentDocType === 'kk' ? '/api/save-kk' : '/api/save-ktp';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use selectedFile directly (it is already cropped/rotated by Apply Crop)
|
||||||
|
const formData = new FormData();
|
||||||
|
// Rename file based on type just for neatness
|
||||||
|
const filename = currentDocType === 'kk' ? 'kk_saved.jpg' : 'ktp_saved.jpg';
|
||||||
|
formData.append('image', selectedFile, filename);
|
||||||
|
formData.append('data', JSON.stringify(extractedData));
|
||||||
|
|
||||||
|
// Send to server
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const docName = currentDocType === 'kk' ? 'Kartu Keluarga' : 'KTP';
|
||||||
|
alert(`Data ${docName} berhasil disimpan!`);
|
||||||
|
saveBtn.innerHTML = '✅ Tersimpan';
|
||||||
|
} else {
|
||||||
|
alert('Gagal menyimpan: ' + result.error);
|
||||||
|
saveBtn.innerHTML = '💾 Simpan';
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Terjadi kesalahan: ' + error.message);
|
||||||
|
saveBtn.innerHTML = '💾 Simpan';
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Print functionality
|
||||||
|
const printBtn = document.getElementById('printBtn');
|
||||||
|
|
||||||
|
printBtn.addEventListener('click', () => {
|
||||||
|
const printArea = document.getElementById('printArea');
|
||||||
|
console.log('Print button clicked');
|
||||||
|
|
||||||
|
// Determine source: preview image or crop canvas?
|
||||||
|
const isPreviewVisible = preview.style.display !== 'none' && preview.getAttribute('src') !== '#' && preview.src;
|
||||||
|
const isCanvasVisible = cropCanvas.style.display !== 'none';
|
||||||
|
|
||||||
|
if (!isPreviewVisible) {
|
||||||
|
if (isCanvasVisible) {
|
||||||
|
if (!confirm('Gambar belum diterapkan (Apply). Cetak tampilan canvas saat ini?')) return;
|
||||||
|
// Use canvas data
|
||||||
|
const img = new Image();
|
||||||
|
img.src = cropCanvas.toDataURL('image/jpeg', 0.95);
|
||||||
|
img.className = currentDocType === 'kk' ? 'a4-print-size' : 'ktp-print-size';
|
||||||
|
printArea.innerHTML = '';
|
||||||
|
printArea.appendChild(img);
|
||||||
|
// Canvas data is instant, no onload needed usually, but to be safe:
|
||||||
|
setTimeout(() => window.print(), 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert('Tidak ada gambar KTP untuk dicetak! Silakan upload atau pilih dari arsip.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
printArea.innerHTML = '';
|
||||||
|
const img = new Image();
|
||||||
|
// Use current preview src
|
||||||
|
img.src = preview.src;
|
||||||
|
img.className = currentDocType === 'kk' ? 'a4-print-size' : 'ktp-print-size';
|
||||||
|
printArea.appendChild(img);
|
||||||
|
|
||||||
|
// Robust print trigger
|
||||||
|
img.onload = () => {
|
||||||
|
// Short delay to ensure rendering
|
||||||
|
setTimeout(() => window.print(), 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fallback if image cached or instant
|
||||||
|
if (img.complete) {
|
||||||
|
img.onload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
img.onerror = () => {
|
||||||
|
alert('Gagal memuat gambar untuk dicetak.');
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download functionality
|
||||||
|
const downloadBtn = document.getElementById('downloadBtn');
|
||||||
|
downloadBtn.addEventListener('click', () => {
|
||||||
|
// Check if preview is valid
|
||||||
|
const isPreviewVisible = preview.style.display !== 'none' && preview.getAttribute('src') !== '#' && preview.src;
|
||||||
|
|
||||||
|
if (!isPreviewVisible) {
|
||||||
|
if (cropCanvas.style.display !== 'none') {
|
||||||
|
// Allow download canvas
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = 'ktp_scan_raw.jpg';
|
||||||
|
link.href = cropCanvas.toDataURL('image/jpeg', 0.95);
|
||||||
|
link.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert('Tidak ada gambar untuk diunduh');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = preview.src;
|
||||||
|
|
||||||
|
// Construct filename from Extracted Data if available
|
||||||
|
let filename = 'ktp_scan.jpg';
|
||||||
|
const nikInput = document.getElementById('field-nik'); // ID format might be different, let's check render logic
|
||||||
|
// renderEditableFields generates identifiers? No, extractedData global var is safest.
|
||||||
|
if (typeof extractedData !== 'undefined' && extractedData.nik) {
|
||||||
|
filename = `KTP_${extractedData.nik}.jpg`;
|
||||||
|
} else {
|
||||||
|
// Try getting from DOM inputs if extractedData not set
|
||||||
|
const domNik = document.querySelector('input[data-key="nik"]');
|
||||||
|
if (domNik && domNik.value) filename = `KTP_${domNik.value}.jpg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Archive Modal Logic
|
||||||
|
const archiveBtn = document.getElementById('archiveBtn');
|
||||||
|
const archiveModal = document.getElementById('archiveModal');
|
||||||
|
const closeModalBtn = document.getElementById('closeModalBtn');
|
||||||
|
const archiveList = document.getElementById('archiveList');
|
||||||
|
const archiveLoading = document.getElementById('archiveLoading');
|
||||||
|
const archiveEmpty = document.getElementById('archiveEmpty');
|
||||||
|
|
||||||
|
// Login Logic Vars
|
||||||
|
const loginModal = document.getElementById('loginModal');
|
||||||
|
const loginUser = document.getElementById('loginUser');
|
||||||
|
const loginPass = document.getElementById('loginPass');
|
||||||
|
const submitLoginBtn = document.getElementById('submitLoginBtn');
|
||||||
|
const loginError = document.getElementById('loginError');
|
||||||
|
const closeLoginBtn = document.getElementById('closeLoginBtn');
|
||||||
|
|
||||||
|
// Check Auth Helper
|
||||||
|
async function checkAuth() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/check-auth');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
return data.authenticated;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (e) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive Button Logic
|
||||||
|
// const archiveBtn = document.getElementById('archiveBtn'); // Already declared
|
||||||
|
const archiveKKBtn = document.getElementById('archiveKKBtn');
|
||||||
|
|
||||||
|
async function openArchive(type) {
|
||||||
|
currentArchiveType = type;
|
||||||
|
const title = type === 'kk' ? 'Arsip Kartu Keluarga' : 'Arsip KTP';
|
||||||
|
document.querySelector('#archiveModal h2').textContent = '📂 ' + title;
|
||||||
|
|
||||||
|
if (await checkAuth()) {
|
||||||
|
archiveModal.style.display = 'block';
|
||||||
|
loadArchive();
|
||||||
|
} else {
|
||||||
|
loginModal.style.display = 'block';
|
||||||
|
// Reset login form
|
||||||
|
loginPass.value = '';
|
||||||
|
loginError.style.display = 'none';
|
||||||
|
loginPass.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
archiveBtn.addEventListener('click', () => openArchive('ktp'));
|
||||||
|
if (archiveKKBtn) {
|
||||||
|
archiveKKBtn.addEventListener('click', () => openArchive('kk'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit Login Logic
|
||||||
|
submitLoginBtn.addEventListener('click', async () => {
|
||||||
|
const user = loginUser.value;
|
||||||
|
const pass = loginPass.value;
|
||||||
|
|
||||||
|
if (!user || !pass) {
|
||||||
|
loginError.textContent = 'Username dan Password harus diisi';
|
||||||
|
loginError.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loginError.style.display = 'none';
|
||||||
|
submitLoginBtn.disabled = true;
|
||||||
|
submitLoginBtn.innerHTML = 'Memeriksa...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: user, password: pass })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
loginModal.style.display = 'none';
|
||||||
|
archiveModal.style.display = 'block';
|
||||||
|
loadArchive();
|
||||||
|
} else {
|
||||||
|
loginError.textContent = data.error || 'Login gagal';
|
||||||
|
loginError.style.display = 'block';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loginError.textContent = 'Gagal terhubung ke server';
|
||||||
|
loginError.style.display = 'block';
|
||||||
|
} finally {
|
||||||
|
submitLoginBtn.disabled = false;
|
||||||
|
submitLoginBtn.innerHTML = 'Masuk';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle Enter Key
|
||||||
|
loginPass.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') submitLoginBtn.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close Login Modal
|
||||||
|
closeLoginBtn.addEventListener('click', () => { loginModal.style.display = 'none'; });
|
||||||
|
// Close Archive Modal (Restored)
|
||||||
|
closeModalBtn.addEventListener('click', () => { archiveModal.style.display = 'none'; });
|
||||||
|
|
||||||
|
// Window click handler for both modals
|
||||||
|
window.addEventListener('click', (e) => {
|
||||||
|
if (e.target === archiveModal) archiveModal.style.display = 'none';
|
||||||
|
if (e.target === loginModal) loginModal.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadArchive() {
|
||||||
|
archiveList.innerHTML = '';
|
||||||
|
archiveLoading.style.display = 'block';
|
||||||
|
archiveEmpty.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endpoint = `/api/${currentArchiveType}-archive?per_page=50`;
|
||||||
|
const response = await fetch(endpoint);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
archiveLoading.style.display = 'none';
|
||||||
|
|
||||||
|
if (result.success && result.data.length > 0) {
|
||||||
|
renderArchiveList(result.data);
|
||||||
|
} else {
|
||||||
|
archiveEmpty.style.display = 'block';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Archive load error:', error);
|
||||||
|
archiveLoading.innerHTML = '❌ Gagal memuat data';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderArchiveList(records) {
|
||||||
|
const imgPrefix = currentArchiveType === 'kk' ? '/kk-images/' : '/ktp-images/';
|
||||||
|
|
||||||
|
records.forEach(record => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'archive-card';
|
||||||
|
|
||||||
|
const date = new Date(record.created_at).toLocaleDateString('id-ID', {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||||
|
});
|
||||||
|
|
||||||
|
const title = currentArchiveType === 'kk' ? (record.kepala_keluarga || 'Tanpa Nama') : (record.nama || 'Tanpa Nama');
|
||||||
|
const idVal = currentArchiveType === 'kk' ? (record.no_kk || '-') : (record.nik || '-');
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="archive-card-img">
|
||||||
|
<img src="${imgPrefix}${record.image_path}" alt="${title}" loading="lazy">
|
||||||
|
</div>
|
||||||
|
<div class="archive-card-content">
|
||||||
|
<h3>${title}</h3>
|
||||||
|
<div class="archive-card-meta">
|
||||||
|
<span class="nik">${idVal}</span>
|
||||||
|
<span class="date">${date}</span>
|
||||||
|
</div>
|
||||||
|
<div class="archive-card-actions" style="display:flex; gap:0.5rem;">
|
||||||
|
<button class="view-btn" onclick='viewArchiveDetail(${JSON.stringify(record).replace(/'/g, "'")})' style="flex:1;">👁️ Lihat</button>
|
||||||
|
<a href="${imgPrefix}${record.image_path}" download="${currentArchiveType.toUpperCase()}_${idVal}.jpg" class="view-btn" style="flex:1; text-align:center; text-decoration:none; display:flex; align-items:center; justify-content:center;">⬇️</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
archiveList.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global function to view detail from archive
|
||||||
|
window.viewArchiveDetail = (record) => {
|
||||||
|
extractedData = record;
|
||||||
|
currentDocType = currentArchiveType; // Sync type so Save works correctly
|
||||||
|
|
||||||
|
// 1. Display results
|
||||||
|
displayResults({ data: record, raw_text: record.raw_text || '' });
|
||||||
|
|
||||||
|
// 2. Load image into preview
|
||||||
|
if (record.image_path) {
|
||||||
|
const imgPrefix = currentArchiveType === 'kk' ? '/kk-images/' : '/ktp-images/';
|
||||||
|
const imgUrl = `${imgPrefix}${record.image_path}`;
|
||||||
|
preview.src = imgUrl;
|
||||||
|
|
||||||
|
// Set originalImageData to allow re-cropping or re-saving if needed
|
||||||
|
fetch(imgUrl)
|
||||||
|
.then(res => res.blob())
|
||||||
|
.then(blob => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
originalImageData = reader.result;
|
||||||
|
selectedFile = new File([blob], record.image_path, { type: blob.type });
|
||||||
|
|
||||||
|
// Initialize Image Object for rotation editor
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
originalImageObject = img;
|
||||||
|
// We don't necessarily need to renderEditor() immediately if we are in "View" mode
|
||||||
|
// But having it ready is good for "Reset"
|
||||||
|
};
|
||||||
|
img.src = reader.result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show preview area (static image result), hide dropzone/canvas
|
||||||
|
preview.style.display = 'block';
|
||||||
|
cropCanvas.style.display = 'none';
|
||||||
|
dropzone.querySelector('.dropzone-content').style.display = 'none';
|
||||||
|
|
||||||
|
// Setup crop ui visibility
|
||||||
|
cropContainer.style.display = 'block';
|
||||||
|
cropArea.style.display = 'none';
|
||||||
|
cropActions.style.display = 'flex';
|
||||||
|
|
||||||
|
// Reset rotation slider for viewing (since we are viewing already cropped/straightened result)
|
||||||
|
currentRotation = 0;
|
||||||
|
updateRotationUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
archiveModal.style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reload Button
|
||||||
|
document.getElementById('reloadBtn').addEventListener('click', () => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<!-- Print Area: Use visibility hidden/height 0 to ensure images load but are invisible on screen -->
|
||||||
|
<div id="printArea" style="visibility: hidden; height: 0; overflow: hidden; position: absolute; z-index: -1;"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ... (this comment is just marker, main script is above)
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
BIN
uploads/521f24cf6aa54257b7d69a95d23e8682.jpg
Normal file
|
After Width: | Height: | Size: 204 KiB |
BIN
uploads/temp_transformed_rotated_temp.jpg
Normal file
|
After Width: | Height: | Size: 204 KiB |