{ "cells": [ { "cell_type": "markdown", "id": "899c35b3", "metadata": {}, "source": [ "# 🎓 Evaluasi Kinerja Sistem Pengenalan Wajah Berbasis LBP dan SVM\n", "\n", "**Laporan Eksperimen Pengujian Biometrik Lanjut**\n", "\n", "Notebook ini merupakan panduan saintifik yang lengkap untuk demonstrasi uji coba *Computer Vision Traditional* LBP-SVM. Mencakup Analisa Komponen Vektor hingga Kurva Evaluasi Metrik Biometrik Standar Skripsi/Tesis.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "6c21ecf7", "metadata": {}, "outputs": [], "source": [ "import os\n", "import sys\n", "import json\n", "import logging\n", "import numpy as np\n", "import pandas as pd\n", "import joblib\n", "import cv2\n", "from PIL import Image\n", "\n", "import seaborn as sns\n", "import matplotlib.pyplot as plt\n", "import matplotlib.patches as patches\n", "\n", "from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay, roc_curve, auc\n", "from sklearn.decomposition import PCA\n", "import warnings\n", "from sklearn.exceptions import InconsistentVersionWarning\n", "warnings.filterwarnings(\"ignore\", category=InconsistentVersionWarning)\n", "\n", "try:\n", " BASE_DIR = os.path.dirname(os.path.abspath(__file__)) \n", "except NameError:\n", " BASE_DIR = os.path.abspath('')\n", "\n", "if not os.path.exists(os.path.join(BASE_DIR, 'python_scripts')):\n", " BASE_DIR = r\"C:\\xampp\\htdocs\\mpg-hris\"\n", "\n", "DATASET_DIR = os.path.join(BASE_DIR, 'storage', 'app', 'private', 'face_datasets')\n", "MODEL_DIR = os.path.join(BASE_DIR, 'storage', 'app', 'private', 'face_models')\n", "DEBUG_DIR = os.path.join(BASE_DIR, 'storage', 'app', 'private', 'debug_verify')\n", "\n", "DF_APPROVED = 1.5\n", "DF_PENDING = 0.5\n", "\n", "scripts_path = os.path.join(BASE_DIR, 'python_scripts')\n", "if scripts_path not in sys.path:\n", " sys.path.insert(0, scripts_path)\n", " \n", "from lbp_features import extract_lbp_features, preprocess_face, FACE_SIZE, _lbp_uniform\n", "\n", "face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')\n", "profile_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_profileface.xml')\n", "print(\"✅ Lingkungan eksekusi Akademik Advanced berhasil dimuat.\")\n" ] }, { "cell_type": "markdown", "id": "961e72b4", "metadata": {}, "source": [ "## 1. Arsitektur Klasifikasi: Load Model Multi-Class SVM\n", "* **StandardScaler**: Mengubah mean pada dataset fitur LBP (1792 dimensi) ke nilai 0 berdeviasi 1.\n", "* **SVM Decision Function**: Kami menggunakan kernel probabilistik margin L2 dengan evaluasi absolut jarak Hyperplane.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "d7a07d5f", "metadata": {}, "outputs": [], "source": [ "def load_system_model():\n", " svm_path = os.path.join(MODEL_DIR, 'face_model.pkl')\n", " scaler_path = os.path.join(MODEL_DIR, 'face_scaler.pkl')\n", " labels_path = os.path.join(MODEL_DIR, 'face_labels.json')\n", " svm_m = joblib.load(svm_path)\n", " scaler_m = joblib.load(scaler_path)\n", " with open(labels_path, 'r') as f:\n", " lbl = json.load(f)\n", " return svm_m, scaler_m, lbl\n", "\n", "svm, scaler, labels = load_system_model()\n", "kelas_terdaftar = [str(c) for c in svm.classes_ if str(c) != 'unknown']\n", "\n", "print(f\"✅ Model klasifikasi berhasil diload ke memory. Identitas Aktif: {kelas_terdaftar}\")\n" ] }, { "cell_type": "markdown", "id": "56d2305c", "metadata": {}, "source": [ "## 2. API Pipeline Wajah dan Crop Geometrik" ] }, { "cell_type": "code", "execution_count": null, "id": "8213583b", "metadata": {}, "outputs": [], "source": [ "def detect_and_crop_face(gray):\n", " h, w = gray.shape\n", " if max(h, w) > 640:\n", " scale = 640.0 / max(h, w)\n", " gray = cv2.resize(gray, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA)\n", " h, w = gray.shape\n", "\n", " min_size = int(min(h, w) * 0.1)\n", " faces = face_cascade.detectMultiScale(gray, 1.1, 5, minSize=(min_size, min_size))\n", " if len(faces) == 0:\n", " faces = face_cascade.detectMultiScale(gray, 1.05, 3, minSize=(min_size, min_size))\n", " if len(faces) == 0:\n", " faces = profile_cascade.detectMultiScale(gray, 1.1, 3, minSize=(min_size, min_size))\n", "\n", " if len(faces) == 0: return None\n", " faces = sorted(faces, key=lambda f: f[2] * f[3], reverse=True)\n", " (x, y, fw, fh) = faces[0]\n", " padding = int(max(fw, fh) * 0.1)\n", " x1, y1 = max(0, x - padding), max(0, y - padding)\n", " x2, y2 = min(w, x + fw + padding), min(h, y + fh + padding)\n", " crop = gray[y1:y2, x1:x2]\n", " return cv2.resize(crop, FACE_SIZE, interpolation=cv2.INTER_AREA)\n", "\n", "def process_image_file(img_path):\n", " try:\n", " pil_img = Image.open(img_path)\n", " exif = pil_img.getexif()\n", " orient = exif.get(274, 1)\n", " if orient == 3: pil_img = pil_img.rotate(180, expand=True)\n", " elif orient == 6: pil_img = pil_img.rotate(270, expand=True)\n", " elif orient == 8: pil_img = pil_img.rotate(90, expand=True)\n", " img = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)\n", " except Exception:\n", " img = cv2.imread(img_path)\n", " if img is None: return None, None, \"Fail\"\n", " gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if len(img.shape) == 3 else img\n", " face = detect_and_crop_face(gray)\n", " if face is None: return img, None, \"No Face\"\n", " proc = preprocess_face(face)\n", " feats = extract_lbp_features(proc)\n", " return face, feats, \"OK\"\n", "\n", "def classify_secure(feats, expected_user_id):\n", " classes = [str(c) for c in svm.classes_]\n", " feats_s = scaler.transform([feats])\n", " df_values = svm.decision_function(feats_s)[0]\n", " if expected_user_id in classes:\n", " idx = classes.index(expected_user_id)\n", " user_df = float(df_values[idx] if hasattr(df_values, '__len__') else df_values)\n", " else: user_df = -999.0\n", "\n", " df_sorted = sorted(enumerate(df_values), key=lambda x: -x[1])\n", " predicted = str(classes[df_sorted[0][0]])\n", " predicted_is_user = (predicted == expected_user_id)\n", " if predicted_is_user and user_df >= DF_APPROVED: status = \"APPROVED\"\n", " elif predicted_is_user and user_df >= DF_PENDING: status = \"PENDING\"\n", " else: status = \"REJECTED\"\n", " return {'predicted': predicted, 'user_df': user_df, 'status': status, 'match': status in (\"APPROVED\", \"PENDING\")}\n" ] }, { "cell_type": "markdown", "id": "3ef9c6fc", "metadata": {}, "source": [ "## 3. Visualisasi Anatomi Feature Extraction LBP Penuh" ] }, { "cell_type": "code", "execution_count": null, "id": "b3c69098", "metadata": {}, "outputs": [], "source": [ "def detailed_visual_demo():\n", " sample_file = None\n", " target_user = None\n", " for d in os.listdir(DATASET_DIR):\n", " dir_path = os.path.join(DATASET_DIR, d)\n", " if os.path.isdir(dir_path):\n", " files = [f for f in os.listdir(dir_path) if f.startswith('raw_frame_') and f.endswith('.jpg')]\n", " if files:\n", " sample_file = os.path.join(dir_path, files[0])\n", " target_user = d\n", " break\n", " if not sample_file: return\n", "\n", " raw_img = cv2.imread(sample_file)\n", " raw_rgb = cv2.cvtColor(raw_img, cv2.COLOR_BGR2RGB)\n", " gray = cv2.cvtColor(raw_img, cv2.COLOR_BGR2GRAY)\n", " faces = face_cascade.detectMultiScale(gray, 1.1, 5, minSize=(30, 30))\n", " x, y, w, h = (0,0,0,0)\n", " if len(faces) > 0:\n", " faces = sorted(faces, key=lambda f: f[2] * f[3], reverse=True)\n", " x, y, w, h = faces[0]\n", "\n", " face_raw, feats, msg = process_image_file(sample_file)\n", " if face_raw is None: return\n", " processed = preprocess_face(face_raw)\n", " lbp_map = _lbp_uniform(processed, n_points=8, radius=1)\n", " res = classify_secure(feats, expected_user_id=target_user)\n", " \n", " fig = plt.figure(figsize=(19, 11))\n", " fig.suptitle(f\"Bedah Algoritma Vektor Wajah | Sampel Asli: User {target_user}\", fontsize=20, fontweight='bold', y=0.98)\n", " \n", " ax1 = plt.subplot(2, 3, 1)\n", " ax1.imshow(raw_rgb)\n", " if w > 0:\n", " pad = int(max(w, h) * 0.1)\n", " ax1.add_patch(patches.Rectangle((max(0, x-pad), max(0, y-pad)), w+(pad*2), h+(pad*2), linewidth=3, edgecolor='yellow', facecolor='none'))\n", " ax1.set_title(\"1. Deteksi Wajah Geometris\", fontsize=13)\n", " ax1.axis('off')\n", " \n", " ax2 = plt.subplot(2, 3, 2)\n", " ax2.imshow(face_raw, cmap='gray')\n", " ax2.set_title(\"2. Potongan Skala Abu 128px\", fontsize=13)\n", " ax2.axis('off')\n", " \n", " ax3 = plt.subplot(2, 3, 3)\n", " ax3.imshow(processed, cmap='gray')\n", " ax3.set_title(\"3. Preprocessing Histogram CLAHE\", fontsize=13)\n", " ax3.axis('off')\n", " \n", " ax4 = plt.subplot(2, 3, 4)\n", " ax4.imshow(lbp_map, cmap='viridis')\n", " ax4.set_title(\"4. Local Binary Map (LBP Texture)\", fontsize=13)\n", " ax4.axis('off')\n", " \n", " ax5 = plt.subplot(2, 3, 5)\n", " ax5.imshow(processed, cmap='gray')\n", " step_x = 128 // 8\n", " step_y = 128 // 8\n", " for i in range(1, 8):\n", " ax5.axhline(i*step_y, color='lime', linestyle='dashed', alpha=0.8)\n", " ax5.axvline(i*step_x, color='lime', linestyle='dashed', alpha=0.8)\n", " ax5.set_title(\"5. Peta 8x8 Lokalisasi Vektor\", fontsize=13)\n", " ax5.axis('off')\n", " \n", " ax6 = plt.subplot(2, 3, 6)\n", " ax6.bar(range(180), feats[:180], color='dodgerblue')\n", " ax6.set_title(\"6. Output Vektor Array LBP 1D (SVM Feed)\", fontsize=13)\n", " \n", " plt.tight_layout()\n", " plt.show()\n", "\n", "detailed_visual_demo()\n" ] }, { "cell_type": "markdown", "id": "e077bbaf", "metadata": {}, "source": [ "## 4. Evaluasi Akurasi Dasar (Confusion Matrix)" ] }, { "cell_type": "code", "execution_count": null, "id": "4391500d", "metadata": {}, "outputs": [], "source": [ "y_true = []\n", "y_pred = []\n", "\n", "for uid in kelas_terdaftar:\n", " folder = os.path.join(DATASET_DIR, uid)\n", " files = sorted([f for f in os.listdir(folder) if f.startswith('raw_frame_') and f.endswith('.jpg')])\n", " for fname in files:\n", " face, feats, info = process_image_file(os.path.join(folder, fname))\n", " if feats is not None:\n", " result = classify_secure(feats, expected_user_id=uid)\n", " y_true.append(uid)\n", " y_pred.append(result['predicted'])\n", "\n", "labels_in_data = sorted(list(set(y_true + y_pred)))\n", "cm = confusion_matrix(y_true, y_pred, labels=labels_in_data)\n", "\n", "fig, ax = plt.subplots(figsize=(8,6))\n", "disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=labels_in_data)\n", "disp.plot(cmap='Blues', ax=ax, values_format='d')\n", "plt.title(\"Confusion Matrix Otentikasi SVM\", fontweight='bold', pad=20)\n", "plt.show()\n" ] }, { "cell_type": "markdown", "id": "7f922126", "metadata": {}, "source": [ "## 5. Visualisasi Multivariabel 1792 Dimensi Jarak SVM (PCA 2D Cluster)\n", "Jika anda bertanya *'Bagaimana cara SVM membedakan manusia satu dengan lainnya?'*, grafik ini jawabannya.\n", "Sistem ini menggunakan algoritma dimensi **Principal Component Analysis (PCA)** untuk mereduksi `1792 Dimensi` ke 2 dimensi X dan Y agar bisa terlihat oleh kacamata penguji. \n", "Apabila setiap klasifikasi identitas mengerucut dan mengumpul secara berkelompok (tidak berpencar atau meledak acak), maka model Machine Learning kita valid secara Geometrik." ] }, { "cell_type": "code", "execution_count": null, "id": "5e9f4ae7", "metadata": {}, "outputs": [], "source": [ "X_data = [] # Array Matrix Fitur\n", "y_labels = [] # Array Identitas Sah\n", "genuine_scores = [] # Track skor keyakinan untuk label sebenarnya\n", "impostor_scores = [] # Track invasi hacker ketika coba masuk account kita\n", "\n", "for uid in kelas_terdaftar:\n", " folder = os.path.join(DATASET_DIR, uid)\n", " files = sorted([f for f in os.listdir(folder) if f.startswith('raw_frame_') and f.endswith('.jpg')])\n", " for fname in files:\n", " face, feats, msg = process_image_file(os.path.join(folder, fname))\n", " if feats is not None:\n", " X_data.append(feats)\n", " y_labels.append(uid)\n", " \n", " # --- Perhitungan Skor Jarak untuk Grafik Histogram Lonceng dan EER Nantinya ---\n", " feats_s = scaler.transform([feats])\n", " df_values = svm.decision_function(feats_s)[0]\n", " for class_idx, predicted_uid in enumerate(svm.classes_):\n", " predicted_uid = str(predicted_uid)\n", " if predicted_uid == 'unknown': continue\n", " \n", " score = df_values[class_idx]\n", " if predicted_uid == uid:\n", " genuine_scores.append(score)\n", " else:\n", " impostor_scores.append(score)\n", "\n", "# --- MULAI PLOT PCA ---\n", "print(\"Melakukan faktorisasi kompresi dimensi PCA...\")\n", "pca = PCA(n_components=2)\n", "components = pca.fit_transform(X_data)\n", "\n", "df_pca = pd.DataFrame(data = components, columns = ['Komponen LBP Skala Utama 1', 'Komponen LBP Skala Utama 2'])\n", "df_pca['Identitas Ground Truth'] = y_labels\n", "\n", "plt.figure(figsize=(10, 7))\n", "sns.scatterplot(\n", " x='Komponen LBP Skala Utama 1', y='Komponen LBP Skala Utama 2',\n", " hue='Identitas Ground Truth', data=df_pca, \n", " palette=sns.color_palette(\"Set1\", len(kelas_terdaftar)),\n", " s=120, alpha=0.9, edgecolor='white', linewidth=0.5\n", ")\n", "plt.title('Pemetaan Relasi Fitur Wajah Manusia SVM (Clustering Visual)', fontsize=15, fontweight='bold', pad=15)\n", "plt.grid(True, linestyle='--', alpha=0.6)\n", "plt.legend(title='Database Pegawai', bbox_to_anchor=(1.05, 1), loc='upper left')\n", "plt.tight_layout()\n", "plt.show()\n" ] }, { "cell_type": "markdown", "id": "bc72d777", "metadata": {}, "source": [ "## 6. Histogram Lonceng Biometrik (*Genuine Score vs Impostor Score*)\n", "Grafik penting otentikasi biometrik yang berfungsi melihat \"Jarak Jembatan\" (*Gap Wall*) ketebalan perlindungan Margin antara Wajah Pengguna Asli (*Genuine*) dan Penyusup Acak (*Impostor*).\n", "Apabila Grafik Merah (*Impostor*) menembus dan bersinggungan erat menutupi bukit warna Hijau (*Authentic*), sistem tersebut dinilai rentan dan berbahaya!" ] }, { "cell_type": "code", "execution_count": null, "id": "ce954f49", "metadata": {}, "outputs": [], "source": [ "plt.figure(figsize=(12, 6))\n", "sns.histplot(genuine_scores, color='seagreen', label='Genuine Access (Orang Dalam Sah)', kde=True, stat=\"density\", bins=20, alpha=0.5, edgecolor=\"none\")\n", "sns.histplot(impostor_scores, color='crimson', label='Impostor Access (Orang Lain Tersesat)', kde=True, stat=\"density\", bins=20, alpha=0.5, edgecolor=\"none\")\n", "\n", "plt.axvline(x=DF_APPROVED, color='gold', linestyle='--', linewidth=3, label=f'Margin Titik Kunci Server: DF({DF_APPROVED})')\n", "\n", "plt.title('Sebaran Probabilitas Decision Margin SVM Wajah', fontsize=15, fontweight='bold')\n", "plt.xlabel('SVM Decision Margin Score / Angka Kepastian')\n", "plt.ylabel('Kepadatan Distribusi Data (%)')\n", "plt.legend(loc='upper right')\n", "plt.grid(axis='y', linestyle='--', alpha=0.5)\n", "plt.tight_layout()\n", "plt.show()\n" ] }, { "cell_type": "markdown", "id": "20287582", "metadata": {}, "source": [ "## 7. Receiver Operating Characteristic (ROC) & Titik Evaluasi Otentikasi EER Ekstrim\n", "- **Kurva Biru ROC (Gambar Kiri)**: Menggambarkan luasan area keakuratan (*Area Under Curve / AUC*), dinilai superlatif bilamana mendekati angka 1.00.\n", "- **Titik Silang EER (Gambar Kanan)**: Adalah titik krusial persinggungan antara Resiko Alarm Palsu **(FAR)** dengan Resiko Salah Tolak Orang Nyata **(FRR)**. Secara akademis _Golden Standard Error Rate_ harus bernilai 0% ~ 1%. " ] }, { "cell_type": "code", "execution_count": null, "id": "9b319996", "metadata": {}, "outputs": [], "source": [ "# Label array: Asli (1) dan Palsu / Impostor (0)\n", "y_binary_roc = [1] * len(genuine_scores) + [0] * len(impostor_scores)\n", "y_scores_roc = genuine_scores + impostor_scores\n", "\n", "fpr_roc, tpr_roc, thresholds_roc = roc_curve(y_binary_roc, y_scores_roc)\n", "roc_auc = auc(fpr_roc, tpr_roc)\n", "\n", "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 6))\n", "\n", "# ---- 1. ROC CURVE ----\n", "ax1.plot(fpr_roc, tpr_roc, color='dodgerblue', lw=3, label=f'Support Vector ROC (AUC = {roc_auc:.4f})')\n", "ax1.plot([0, 1], [0, 1], color='darkgrey', lw=2, linestyle='--', label='Garis Lemparan Koin Acak (Keburukan)')\n", "ax1.set_xlim([-0.05, 1.0])\n", "ax1.set_ylim([0.0, 1.05])\n", "ax1.set_xlabel('False Positive Rate (Rasio Kebocoran Penipu)', fontsize=12)\n", "ax1.set_ylabel('True Positive Rate (Rasio Menyetujui Akun Sendiri)', fontsize=12)\n", "ax1.set_title('Kesempurnaan Deteksi / ROC Curve', fontweight='bold', fontsize=14)\n", "ax1.legend(loc='lower right')\n", "ax1.grid(True, linestyle=':', alpha=0.7)\n", "\n", "# ---- 2. EER (EQUAL ERROR RATE) CURVE ----\n", "# Formula Dasar Biometrik FRR adalah (100% - Tingkat Akseptasi Kebenaran TPR / False Rejection)\n", "frr = 1 - tpr_roc\n", "far = fpr_roc\n", "\n", "# Deteksi titik perpapasan/silang untuk mendapatkan Absolute Equal Error Rate (EER) Tengah\n", "eer_index = np.argmin(np.abs(far - frr))\n", "eer_threshold_val = thresholds_roc[eer_index]\n", "eer_percent = far[eer_index]\n", "\n", "ax2.plot(thresholds_roc, far, color='crimson', lw=2.5, label='FAR (Orang Asing Diterima / Bahaya)')\n", "ax2.plot(thresholds_roc, frr, color='navy', lw=2.5, label='FRR (Orang Asli Ditolak Mesin)')\n", "ax2.axvline(x=eer_threshold_val, color='gold', linestyle='--', linewidth=2, label=f'EER={eer_percent*100:.2f}% (Toleransi Error Mutlak)\\nMargin Kritis di: {eer_threshold_val:.2f}')\n", "\n", "ax2.set_xlim([-1.5, 3.5]) # Memfokuskan grafik ke pergeseran persimpangan Decision Values\n", "ax2.set_ylim([-0.05, 1.05])\n", "ax2.set_xlabel('SVM Operational Decision Boundary Margin Limit', fontsize=12)\n", "ax2.set_ylabel('Level Bahaya Error Tolerance', fontsize=12)\n", "ax2.set_title('Kesetimbangan Resiko Operasional: FAR vs FRR', fontweight='bold', fontsize=14)\n", "ax2.legend(loc='upper right')\n", "ax2.grid(True, linestyle=':', alpha=0.7)\n", "\n", "plt.tight_layout()\n", "plt.show()\n", "\n", "print(\"=\" * 60)\n", "print(f\" >>> AREA UNDER CURVE KUALITAS SVM MURNI: {roc_auc:.4f} <<< \")\n", "print(\" Titik AUC 1.000 memformulasikan Algoritma telah mengenali Identitas tanpa misklasifikasi sedetik pun di atas kertas.\")\n", "print(f\" ✅ Equal Error Rate (EER) Absolut ditekan ke angka ekstrim: {eer_percent*100:.2f} % !!!\")\n", "print(\"=\" * 60)\n" ] }, { "cell_type": "markdown", "id": "75690eb9", "metadata": {}, "source": [ "## 8. Verifikasi Kasus Lintas Domain (Kondisi Lapangan Aplikasi Mobile)\n", "Penyelesaian *Domain Gap* untuk mendiagnosa jepretan kamera Low Quality langsung dari Flutter Front-End Apps." ] }, { "cell_type": "code", "execution_count": null, "id": "d4594f26", "metadata": {}, "outputs": [], "source": [ "if not os.path.exists(DEBUG_DIR): print(\"Folder debug_verify log tidak ditemukan.\")\n", "else:\n", " debug_files = sorted([f for f in os.listdir(DEBUG_DIR) if f.endswith('.jpg') and f.startswith('user')])\n", " \n", " if len(debug_files) == 0: print(\"Berdasarkan pengumpulan lapangan belum ada data log dari Presensi App.\")\n", " else:\n", " print(f\"Ditemukan {len(debug_files)} Gambar Log Absen Harian Teruji Secara Domain Silang:\\n\")\n", " print(f\"{'Nama File Uji':<32} {'Target':<7} {'Status':<10} {'Margin SVM':>10} {'Akurasi Jauh'}\")\n", " print(\"-\" * 80)\n", " \n", " real_total = 0\n", " real_correct = 0\n", " for fname in debug_files:\n", " expected_uid = fname.split('_')[0].replace('user', '')\n", " fpath = os.path.join(DEBUG_DIR, fname)\n", " face, feats, msg = process_image_file(fpath)\n", " if feats is None: continue\n", " r = classify_secure(feats, expected_user_id=expected_uid)\n", " real_total += 1\n", " if r['match']: real_correct += 1\n", " \n", " mar = \"✔️ Lolos Security\" if r['match'] else \"❌ Gagal (Wajah Ditolak/Noise)\"\n", " print(f\"{fname:<32} User {expected_uid:<2} {r['status']:<10} {r['user_df']:>10.3f} {mar}\")\n", " \n", " print(\"-\" * 80)\n", " print(f\"Keandalan Validasi Perbedaan Domain & Resolusi Ekstrem Kamera Mobile: {real_correct}/{real_total} ({(real_correct/real_total)*100 if real_total else 0:.1f}%)\")\n" ] } ], "metadata": {}, "nbformat": 4, "nbformat_minor": 5 }