Spaces:
Runtime error
Runtime error
Create app.py
Browse files
app.py
ADDED
|
@@ -0,0 +1,914 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app.py
|
| 2 |
+
# Biometric Authentication Literature Survey + Interactive Demonstration
|
| 3 |
+
# Designed for Hugging Face Spaces free CPU tier.
|
| 4 |
+
#
|
| 5 |
+
# Educational scope:
|
| 6 |
+
# - Fingerprint, iris, and optional face feature extraction
|
| 7 |
+
# - Handcrafted features: minutiae-like, LBP, Gabor, SIFT-like
|
| 8 |
+
# - Deep-feature simulation: CNN-like and deep embedding
|
| 9 |
+
# - Enrollment vs verification matching
|
| 10 |
+
# - Template protection demonstrations
|
| 11 |
+
# - Attack/liveness simulation
|
| 12 |
+
# - Survey comparison tables for all 4 assigned students
|
| 13 |
+
#
|
| 14 |
+
# Important:
|
| 15 |
+
# This is NOT a production biometric authentication system.
|
| 16 |
+
# It stores no biometric database and performs session-only comparisons.
|
| 17 |
+
|
| 18 |
+
import base64
|
| 19 |
+
import hashlib
|
| 20 |
+
import io
|
| 21 |
+
import math
|
| 22 |
+
import warnings
|
| 23 |
+
from typing import Dict, List, Tuple
|
| 24 |
+
|
| 25 |
+
import gradio as gr
|
| 26 |
+
import matplotlib.pyplot as plt
|
| 27 |
+
import numpy as np
|
| 28 |
+
import pandas as pd
|
| 29 |
+
from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageOps
|
| 30 |
+
|
| 31 |
+
warnings.filterwarnings("ignore")
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
from cryptography.fernet import Fernet
|
| 35 |
+
|
| 36 |
+
HAS_CRYPTO = True
|
| 37 |
+
except Exception:
|
| 38 |
+
HAS_CRYPTO = False
|
| 39 |
+
|
| 40 |
+
try:
|
| 41 |
+
import cv2
|
| 42 |
+
|
| 43 |
+
HAS_CV2 = True
|
| 44 |
+
except Exception:
|
| 45 |
+
HAS_CV2 = False
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
APP_TITLE = "Biometric Authentication Literature Survey & Interactive Demo"
|
| 49 |
+
DEFAULT_SIZE = 128
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ---------------------------------------------------------------------
|
| 53 |
+
# Utility helpers
|
| 54 |
+
# ---------------------------------------------------------------------
|
| 55 |
+
|
| 56 |
+
def _safe_image(img):
|
| 57 |
+
if img is None:
|
| 58 |
+
return None
|
| 59 |
+
if isinstance(img, Image.Image):
|
| 60 |
+
return img.convert("RGB")
|
| 61 |
+
return Image.fromarray(np.array(img)).convert("RGB")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _array_to_pil(arr: np.ndarray) -> Image.Image:
|
| 65 |
+
arr = np.asarray(arr)
|
| 66 |
+
arr = np.nan_to_num(arr)
|
| 67 |
+
if arr.max() <= 1.0:
|
| 68 |
+
arr = arr * 255.0
|
| 69 |
+
arr = np.clip(arr, 0, 255).astype(np.uint8)
|
| 70 |
+
return Image.fromarray(arr)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _normalize01(arr: np.ndarray) -> np.ndarray:
|
| 74 |
+
arr = np.asarray(arr, dtype=np.float32)
|
| 75 |
+
mn, mx = float(arr.min()), float(arr.max())
|
| 76 |
+
if mx - mn < 1e-8:
|
| 77 |
+
return np.zeros_like(arr, dtype=np.float32)
|
| 78 |
+
return (arr - mn) / (mx - mn)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _seed_from_key(key: str) -> int:
|
| 82 |
+
key = key or "student-demo-key"
|
| 83 |
+
digest = hashlib.sha256(key.encode("utf-8")).digest()
|
| 84 |
+
return int.from_bytes(digest[:8], "little") % (2**32 - 1)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _resize_gray(img: Image.Image, size: int = DEFAULT_SIZE) -> np.ndarray:
|
| 88 |
+
img = _safe_image(img)
|
| 89 |
+
gray = ImageOps.grayscale(img)
|
| 90 |
+
gray = ImageOps.autocontrast(gray)
|
| 91 |
+
gray = gray.resize((size, size))
|
| 92 |
+
return np.asarray(gray, dtype=np.float32) / 255.0
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _pad_or_trim(vec: np.ndarray, length: int) -> np.ndarray:
|
| 96 |
+
vec = np.asarray(vec, dtype=np.float32).flatten()
|
| 97 |
+
if len(vec) == length:
|
| 98 |
+
return vec
|
| 99 |
+
if len(vec) > length:
|
| 100 |
+
return vec[:length]
|
| 101 |
+
out = np.zeros(length, dtype=np.float32)
|
| 102 |
+
out[:len(vec)] = vec
|
| 103 |
+
return out
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _unit_vector(vec: np.ndarray) -> np.ndarray:
|
| 107 |
+
vec = np.asarray(vec, dtype=np.float32).flatten()
|
| 108 |
+
vec = np.nan_to_num(vec)
|
| 109 |
+
norm = np.linalg.norm(vec)
|
| 110 |
+
if norm < 1e-8:
|
| 111 |
+
return vec
|
| 112 |
+
return vec / norm
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
|
| 116 |
+
a = np.asarray(a, dtype=np.float32).flatten()
|
| 117 |
+
b = np.asarray(b, dtype=np.float32).flatten()
|
| 118 |
+
n = min(len(a), len(b))
|
| 119 |
+
if n == 0:
|
| 120 |
+
return 0.0
|
| 121 |
+
a = _unit_vector(a[:n])
|
| 122 |
+
b = _unit_vector(b[:n])
|
| 123 |
+
score = float(np.dot(a, b))
|
| 124 |
+
return max(0.0, min(1.0, (score + 1.0) / 2.0))
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _hamming_similarity(a: np.ndarray, b: np.ndarray) -> float:
|
| 128 |
+
a = np.asarray(a).flatten() > 0.5
|
| 129 |
+
b = np.asarray(b).flatten() > 0.5
|
| 130 |
+
n = min(len(a), len(b))
|
| 131 |
+
if n == 0:
|
| 132 |
+
return 0.0
|
| 133 |
+
return float(1.0 - np.mean(a[:n] != b[:n]))
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _vector_preview(vec: np.ndarray, limit: int = 16) -> str:
|
| 137 |
+
vec = np.asarray(vec).flatten()
|
| 138 |
+
shown = vec[:limit]
|
| 139 |
+
return np.array2string(shown, precision=4, separator=", ")
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _make_feature_dataframe(vec: np.ndarray, limit: int = 32) -> pd.DataFrame:
|
| 143 |
+
vec = np.asarray(vec).flatten()
|
| 144 |
+
rows = []
|
| 145 |
+
for i, v in enumerate(vec[:limit]):
|
| 146 |
+
rows.append({"index": i, "value": float(v)})
|
| 147 |
+
return pd.DataFrame(rows)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _fig_feature_bar(vec: np.ndarray, title: str = "Feature vector preview"):
|
| 151 |
+
vec = np.asarray(vec).flatten()
|
| 152 |
+
fig = plt.figure(figsize=(7, 3))
|
| 153 |
+
n = min(64, len(vec))
|
| 154 |
+
plt.bar(np.arange(n), vec[:n])
|
| 155 |
+
plt.title(title)
|
| 156 |
+
plt.xlabel("Feature index")
|
| 157 |
+
plt.ylabel("Value")
|
| 158 |
+
plt.tight_layout()
|
| 159 |
+
return fig
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ---------------------------------------------------------------------
|
| 163 |
+
# Preprocessing
|
| 164 |
+
# ---------------------------------------------------------------------
|
| 165 |
+
|
| 166 |
+
def preprocess_modality(img: Image.Image, modality: str) -> Tuple[np.ndarray, Image.Image, Dict]:
|
| 167 |
+
img = _safe_image(img)
|
| 168 |
+
if img is None:
|
| 169 |
+
raise ValueError("Please upload an image.")
|
| 170 |
+
|
| 171 |
+
if modality == "Iris":
|
| 172 |
+
# Educational iris approximation:
|
| 173 |
+
# central crop + circular mask. This is not true iris segmentation.
|
| 174 |
+
w, h = img.size
|
| 175 |
+
side = min(w, h)
|
| 176 |
+
left = (w - side) // 2
|
| 177 |
+
top = (h - side) // 2
|
| 178 |
+
crop = img.crop((left, top, left + side, top + side))
|
| 179 |
+
gray = ImageOps.grayscale(crop)
|
| 180 |
+
gray = ImageOps.autocontrast(gray)
|
| 181 |
+
gray = gray.resize((DEFAULT_SIZE, DEFAULT_SIZE))
|
| 182 |
+
arr = np.asarray(gray, dtype=np.float32) / 255.0
|
| 183 |
+
|
| 184 |
+
yy, xx = np.ogrid[:DEFAULT_SIZE, :DEFAULT_SIZE]
|
| 185 |
+
center = (DEFAULT_SIZE - 1) / 2
|
| 186 |
+
radius_outer = DEFAULT_SIZE * 0.46
|
| 187 |
+
radius_inner = DEFAULT_SIZE * 0.12
|
| 188 |
+
dist = np.sqrt((xx - center) ** 2 + (yy - center) ** 2)
|
| 189 |
+
mask = (dist <= radius_outer) & (dist >= radius_inner)
|
| 190 |
+
masked = arr.copy()
|
| 191 |
+
masked[~mask] = 0.0
|
| 192 |
+
|
| 193 |
+
meta = {
|
| 194 |
+
"modality": modality,
|
| 195 |
+
"preprocessing": "central crop, grayscale, autocontrast, circular iris-style mask",
|
| 196 |
+
"note": "Educational approximation; not a clinical iris segmenter."
|
| 197 |
+
}
|
| 198 |
+
return masked, _array_to_pil(masked), meta
|
| 199 |
+
|
| 200 |
+
if modality == "Fingerprint":
|
| 201 |
+
gray = _resize_gray(img)
|
| 202 |
+
# Increase ridge visibility.
|
| 203 |
+
pil = _array_to_pil(gray)
|
| 204 |
+
pil = ImageEnhance.Contrast(pil).enhance(1.8)
|
| 205 |
+
pil = pil.filter(ImageFilter.SHARPEN)
|
| 206 |
+
arr = np.asarray(pil, dtype=np.float32) / 255.0
|
| 207 |
+
meta = {
|
| 208 |
+
"modality": modality,
|
| 209 |
+
"preprocessing": "grayscale, resize, autocontrast, contrast enhancement, sharpening"
|
| 210 |
+
}
|
| 211 |
+
return arr, pil, meta
|
| 212 |
+
|
| 213 |
+
# Face / generic biometric image.
|
| 214 |
+
gray = _resize_gray(img)
|
| 215 |
+
pil = _array_to_pil(gray)
|
| 216 |
+
pil = ImageEnhance.Contrast(pil).enhance(1.25)
|
| 217 |
+
arr = np.asarray(pil, dtype=np.float32) / 255.0
|
| 218 |
+
meta = {
|
| 219 |
+
"modality": modality,
|
| 220 |
+
"preprocessing": "grayscale, resize, autocontrast, light contrast enhancement"
|
| 221 |
+
}
|
| 222 |
+
return arr, pil, meta
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# ---------------------------------------------------------------------
|
| 226 |
+
# Feature extraction methods
|
| 227 |
+
# ---------------------------------------------------------------------
|
| 228 |
+
|
| 229 |
+
def _conv2d_same(img: np.ndarray, kernel: np.ndarray) -> np.ndarray:
|
| 230 |
+
img = np.asarray(img, dtype=np.float32)
|
| 231 |
+
kernel = np.asarray(kernel, dtype=np.float32)
|
| 232 |
+
kh, kw = kernel.shape
|
| 233 |
+
ph, pw = kh // 2, kw // 2
|
| 234 |
+
padded = np.pad(img, ((ph, ph), (pw, pw)), mode="reflect")
|
| 235 |
+
|
| 236 |
+
try:
|
| 237 |
+
windows = np.lib.stride_tricks.sliding_window_view(padded, (kh, kw))
|
| 238 |
+
return np.einsum("ijkl,kl->ij", windows, kernel)
|
| 239 |
+
except Exception:
|
| 240 |
+
out = np.zeros_like(img)
|
| 241 |
+
for y in range(img.shape[0]):
|
| 242 |
+
for x in range(img.shape[1]):
|
| 243 |
+
out[y, x] = np.sum(padded[y:y + kh, x:x + kw] * kernel)
|
| 244 |
+
return out
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def gabor_kernel(size=21, sigma=4.0, theta=0.0, frequency=0.12, gamma=0.5):
|
| 248 |
+
radius = size // 2
|
| 249 |
+
y, x = np.mgrid[-radius:radius + 1, -radius:radius + 1]
|
| 250 |
+
x_theta = x * np.cos(theta) + y * np.sin(theta)
|
| 251 |
+
y_theta = -x * np.sin(theta) + y * np.cos(theta)
|
| 252 |
+
|
| 253 |
+
gb = np.exp(-(x_theta ** 2 + gamma ** 2 * y_theta ** 2) / (2 * sigma ** 2))
|
| 254 |
+
gb *= np.cos(2 * np.pi * frequency * x_theta)
|
| 255 |
+
gb -= gb.mean()
|
| 256 |
+
return gb.astype(np.float32)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def extract_gabor(arr: np.ndarray) -> Tuple[np.ndarray, Image.Image, Dict]:
|
| 260 |
+
orientations = [0, np.pi / 4, np.pi / 2, 3 * np.pi / 4]
|
| 261 |
+
responses = []
|
| 262 |
+
features = []
|
| 263 |
+
|
| 264 |
+
for theta in orientations:
|
| 265 |
+
kernel = gabor_kernel(theta=theta)
|
| 266 |
+
response = _conv2d_same(arr, kernel)
|
| 267 |
+
responses.append(response)
|
| 268 |
+
abs_resp = np.abs(response)
|
| 269 |
+
features.extend([
|
| 270 |
+
float(abs_resp.mean()),
|
| 271 |
+
float(abs_resp.std()),
|
| 272 |
+
float(abs_resp.max()),
|
| 273 |
+
float(np.percentile(abs_resp, 75)),
|
| 274 |
+
])
|
| 275 |
+
|
| 276 |
+
stacked = np.stack([np.abs(r) for r in responses], axis=0)
|
| 277 |
+
visual = _normalize01(stacked.max(axis=0))
|
| 278 |
+
|
| 279 |
+
meta = {
|
| 280 |
+
"method": "Gabor filters",
|
| 281 |
+
"feature_type": "Handcrafted texture/ridge-frequency features",
|
| 282 |
+
"feature_length": len(features),
|
| 283 |
+
"advantages": "Good for ridge and iris texture enhancement; interpretable.",
|
| 284 |
+
"limitations": "Sensitive to segmentation quality, rotation, scale, and chosen filter parameters."
|
| 285 |
+
}
|
| 286 |
+
return np.array(features, dtype=np.float32), _array_to_pil(visual), meta
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def extract_lbp(arr: np.ndarray) -> Tuple[np.ndarray, Image.Image, Dict]:
|
| 290 |
+
center = arr
|
| 291 |
+
neighbors = [
|
| 292 |
+
np.roll(np.roll(arr, -1, axis=0), -1, axis=1),
|
| 293 |
+
np.roll(arr, -1, axis=0),
|
| 294 |
+
np.roll(np.roll(arr, -1, axis=0), 1, axis=1),
|
| 295 |
+
np.roll(arr, 1, axis=1),
|
| 296 |
+
np.roll(np.roll(arr, 1, axis=0), 1, axis=1),
|
| 297 |
+
np.roll(arr, 1, axis=0),
|
| 298 |
+
np.roll(np.roll(arr, 1, axis=0), -1, axis=1),
|
| 299 |
+
np.roll(arr, -1, axis=1),
|
| 300 |
+
]
|
| 301 |
+
|
| 302 |
+
code = np.zeros_like(arr, dtype=np.uint8)
|
| 303 |
+
for i, n in enumerate(neighbors):
|
| 304 |
+
code += ((n >= center).astype(np.uint8) << i)
|
| 305 |
+
|
| 306 |
+
hist, _ = np.histogram(code.flatten(), bins=256, range=(0, 256), density=True)
|
| 307 |
+
visual = code.astype(np.float32) / 255.0
|
| 308 |
+
|
| 309 |
+
meta = {
|
| 310 |
+
"method": "Local Binary Pattern",
|
| 311 |
+
"feature_type": "Handcrafted local texture histogram",
|
| 312 |
+
"feature_length": len(hist),
|
| 313 |
+
"advantages": "Fast, simple, strong texture descriptor.",
|
| 314 |
+
"limitations": "Can be sensitive to noise and does not model global structure well."
|
| 315 |
+
}
|
| 316 |
+
return hist.astype(np.float32), _array_to_pil(visual), meta
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def extract_sift_like(arr: np.ndarray) -> Tuple[np.ndarray, Image.Image, Dict]:
|
| 320 |
+
if HAS_CV2:
|
| 321 |
+
img8 = np.clip(arr * 255, 0, 255).astype(np.uint8)
|
| 322 |
+
sift = None
|
| 323 |
+
try:
|
| 324 |
+
sift = cv2.SIFT_create()
|
| 325 |
+
except Exception:
|
| 326 |
+
sift = None
|
| 327 |
+
|
| 328 |
+
if sift is not None:
|
| 329 |
+
keypoints, descriptors = sift.detectAndCompute(img8, None)
|
| 330 |
+
if descriptors is None or len(descriptors) == 0:
|
| 331 |
+
desc = np.zeros(128, dtype=np.float32)
|
| 332 |
+
else:
|
| 333 |
+
desc = descriptors.mean(axis=0).astype(np.float32)
|
| 334 |
+
desc = _unit_vector(desc)
|
| 335 |
+
|
| 336 |
+
color = cv2.cvtColor(img8, cv2.COLOR_GRAY2RGB)
|
| 337 |
+
drawn = cv2.drawKeypoints(color, keypoints[:80], None, flags=cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)
|
| 338 |
+
visual = Image.fromarray(drawn)
|
| 339 |
+
|
| 340 |
+
meta = {
|
| 341 |
+
"method": "SIFT",
|
| 342 |
+
"feature_type": "Keypoint descriptor",
|
| 343 |
+
"feature_length": len(desc),
|
| 344 |
+
"advantages": "Robust to scale/rotation changes when keypoints are stable.",
|
| 345 |
+
"limitations": "Can fail on low-texture or poor-quality biometric images."
|
| 346 |
+
}
|
| 347 |
+
return desc.astype(np.float32), visual, meta
|
| 348 |
+
|
| 349 |
+
# Fallback SIFT-like descriptor:
|
| 350 |
+
# 4x4 grid, 8-bin orientation histogram = 128 dims.
|
| 351 |
+
gy, gx = np.gradient(arr)
|
| 352 |
+
mag = np.sqrt(gx ** 2 + gy ** 2)
|
| 353 |
+
ori = (np.arctan2(gy, gx) + np.pi) / (2 * np.pi)
|
| 354 |
+
|
| 355 |
+
cells = 4
|
| 356 |
+
bins = 8
|
| 357 |
+
h, w = arr.shape
|
| 358 |
+
ch, cw = h // cells, w // cells
|
| 359 |
+
feats = []
|
| 360 |
+
|
| 361 |
+
for cy in range(cells):
|
| 362 |
+
for cx in range(cells):
|
| 363 |
+
y0, y1 = cy * ch, (cy + 1) * ch
|
| 364 |
+
x0, x1 = cx * cw, (cx + 1) * cw
|
| 365 |
+
cell_ori = ori[y0:y1, x0:x1].flatten()
|
| 366 |
+
cell_mag = mag[y0:y1, x0:x1].flatten()
|
| 367 |
+
hist, _ = np.histogram(cell_ori, bins=bins, range=(0, 1), weights=cell_mag)
|
| 368 |
+
feats.extend(hist.tolist())
|
| 369 |
+
|
| 370 |
+
feats = _unit_vector(np.array(feats, dtype=np.float32))
|
| 371 |
+
|
| 372 |
+
visual = Image.fromarray(np.uint8(np.stack([arr, arr, arr], axis=-1) * 255))
|
| 373 |
+
draw = ImageDraw.Draw(visual)
|
| 374 |
+
# Draw top gradient points as pseudo-keypoints.
|
| 375 |
+
flat_idx = np.argsort(mag.flatten())[-60:]
|
| 376 |
+
for idx in flat_idx:
|
| 377 |
+
y, x = divmod(int(idx), w)
|
| 378 |
+
draw.ellipse((x - 1, y - 1, x + 1, y + 1), fill=(255, 0, 0))
|
| 379 |
+
|
| 380 |
+
meta = {
|
| 381 |
+
"method": "SIFT-like fallback",
|
| 382 |
+
"feature_type": "Educational gradient keypoint/orientation descriptor",
|
| 383 |
+
"feature_length": len(feats),
|
| 384 |
+
"advantages": "Demonstrates SIFT/SURF idea without heavy dependencies.",
|
| 385 |
+
"limitations": "Not a full SIFT/SURF implementation unless OpenCV SIFT is available."
|
| 386 |
+
}
|
| 387 |
+
return feats.astype(np.float32), visual, meta
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def extract_minutiae_like(arr: np.ndarray) -> Tuple[np.ndarray, Image.Image, Dict]:
|
| 391 |
+
# Educational fingerprint minutiae approximation:
|
| 392 |
+
# threshold ridges + estimate endpoints/bifurcations through neighbor counts.
|
| 393 |
+
smooth = _conv2d_same(arr, np.ones((3, 3), dtype=np.float32) / 9.0)
|
| 394 |
+
binary = smooth < np.percentile(smooth, 45)
|
| 395 |
+
|
| 396 |
+
# Remove border.
|
| 397 |
+
binary[:2, :] = False
|
| 398 |
+
binary[-2:, :] = False
|
| 399 |
+
binary[:, :2] = False
|
| 400 |
+
binary[:, -2:] = False
|
| 401 |
+
|
| 402 |
+
neighbor_count = np.zeros_like(binary, dtype=np.int32)
|
| 403 |
+
for dy in [-1, 0, 1]:
|
| 404 |
+
for dx in [-1, 0, 1]:
|
| 405 |
+
if dy == 0 and dx == 0:
|
| 406 |
+
continue
|
| 407 |
+
neighbor_count += np.roll(np.roll(binary, dy, axis=0), dx, axis=1).astype(np.int32)
|
| 408 |
+
|
| 409 |
+
endpoints = binary & (neighbor_count == 1)
|
| 410 |
+
bifurcations = binary & (neighbor_count >= 3)
|
| 411 |
+
|
| 412 |
+
# Spatial histograms.
|
| 413 |
+
grid = 4
|
| 414 |
+
h, w = arr.shape
|
| 415 |
+
feats = [
|
| 416 |
+
float(endpoints.sum()) / 1000.0,
|
| 417 |
+
float(bifurcations.sum()) / 1000.0,
|
| 418 |
+
float(binary.mean()),
|
| 419 |
+
float(neighbor_count[binary].mean()) if binary.any() else 0.0,
|
| 420 |
+
]
|
| 421 |
+
|
| 422 |
+
for mask in [endpoints, bifurcations]:
|
| 423 |
+
for gy in range(grid):
|
| 424 |
+
for gx in range(grid):
|
| 425 |
+
y0, y1 = gy * h // grid, (gy + 1) * h // grid
|
| 426 |
+
x0, x1 = gx * w // grid, (gx + 1) * w // grid
|
| 427 |
+
feats.append(float(mask[y0:y1, x0:x1].sum()) / 100.0)
|
| 428 |
+
|
| 429 |
+
visual = Image.fromarray(np.uint8(np.stack([arr, arr, arr], axis=-1) * 255))
|
| 430 |
+
draw = ImageDraw.Draw(visual)
|
| 431 |
+
ey, ex = np.where(endpoints)
|
| 432 |
+
by, bx = np.where(bifurcations)
|
| 433 |
+
|
| 434 |
+
for y, x in list(zip(ey, ex))[:120]:
|
| 435 |
+
draw.ellipse((x - 2, y - 2, x + 2, y + 2), outline=(0, 255, 0), width=1)
|
| 436 |
+
for y, x in list(zip(by, bx))[:120]:
|
| 437 |
+
draw.rectangle((x - 2, y - 2, x + 2, y + 2), outline=(255, 0, 0), width=1)
|
| 438 |
+
|
| 439 |
+
meta = {
|
| 440 |
+
"method": "Minutiae-like extraction",
|
| 441 |
+
"feature_type": "Educational ridge endpoint/bifurcation approximation",
|
| 442 |
+
"feature_length": len(feats),
|
| 443 |
+
"advantages": "Explains classic fingerprint minutiae concepts visually.",
|
| 444 |
+
"limitations": "Not a true skeletonization-based forensic minutiae extractor."
|
| 445 |
+
}
|
| 446 |
+
return np.array(feats, dtype=np.float32), visual, meta
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
def extract_cnn_like(arr: np.ndarray) -> Tuple[np.ndarray, Image.Image, Dict]:
|
| 450 |
+
# Lightweight CNN-style embedding simulation:
|
| 451 |
+
# edge responses + pooled statistics across multiple grid sizes.
|
| 452 |
+
sobel_x = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float32)
|
| 453 |
+
sobel_y = sobel_x.T
|
| 454 |
+
gx = _conv2d_same(arr, sobel_x)
|
| 455 |
+
gy = _conv2d_same(arr, sobel_y)
|
| 456 |
+
edge = _normalize01(np.sqrt(gx ** 2 + gy ** 2))
|
| 457 |
+
|
| 458 |
+
feats = []
|
| 459 |
+
for grid in [2, 4, 8]:
|
| 460 |
+
h, w = arr.shape
|
| 461 |
+
for y in range(grid):
|
| 462 |
+
for x in range(grid):
|
| 463 |
+
y0, y1 = y * h // grid, (y + 1) * h // grid
|
| 464 |
+
x0, x1 = x * w // grid, (x + 1) * w // grid
|
| 465 |
+
patch = arr[y0:y1, x0:x1]
|
| 466 |
+
epatch = edge[y0:y1, x0:x1]
|
| 467 |
+
feats.extend([
|
| 468 |
+
float(patch.mean()),
|
| 469 |
+
float(patch.std()),
|
| 470 |
+
float(epatch.mean()),
|
| 471 |
+
float(epatch.std()),
|
| 472 |
+
])
|
| 473 |
+
|
| 474 |
+
# Add global moments.
|
| 475 |
+
feats.extend([
|
| 476 |
+
float(arr.mean()),
|
| 477 |
+
float(arr.std()),
|
| 478 |
+
float(edge.mean()),
|
| 479 |
+
float(edge.std()),
|
| 480 |
+
float(np.percentile(arr, 25)),
|
| 481 |
+
float(np.percentile(arr, 50)),
|
| 482 |
+
float(np.percentile(arr, 75)),
|
| 483 |
+
])
|
| 484 |
+
|
| 485 |
+
feats = _unit_vector(np.array(feats, dtype=np.float32))
|
| 486 |
+
|
| 487 |
+
meta = {
|
| 488 |
+
"method": "CNN-like embedding",
|
| 489 |
+
"feature_type": "Lightweight multiscale pooled edge/texture embedding",
|
| 490 |
+
"feature_length": len(feats),
|
| 491 |
+
"advantages": "Demonstrates deep-model-style hierarchical feature pooling on CPU.",
|
| 492 |
+
"limitations": "Not trained; does not replace a real CNN biometric model."
|
| 493 |
+
}
|
| 494 |
+
return feats, _array_to_pil(edge), meta
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
def extract_deep_embedding(arr: np.ndarray, modality: str) -> Tuple[np.ndarray, Image.Image, Dict]:
|
| 498 |
+
# Deterministic random projection of multiple handcrafted features.
|
| 499 |
+
# This mimics a compact deep embedding for demonstration.
|
| 500 |
+
gabor_vec, gabor_vis, _ = extract_gabor(arr)
|
| 501 |
+
lbp_vec, _, _ = extract_lbp(arr)
|
| 502 |
+
sift_vec, _, _ = extract_sift_like(arr)
|
| 503 |
+
cnn_vec, cnn_vis, _ = extract_cnn_like(arr)
|
| 504 |
+
|
| 505 |
+
base = np.concatenate([
|
| 506 |
+
_pad_or_trim(gabor_vec, 32),
|
| 507 |
+
_pad_or_trim(lbp_vec, 128),
|
| 508 |
+
_pad_or_trim(sift_vec, 128),
|
| 509 |
+
_pad_or_trim(cnn_vec, 128),
|
| 510 |
+
])
|
| 511 |
+
base = _unit_vector(base)
|
| 512 |
+
|
| 513 |
+
rng = np.random.default_rng(_seed_from_key("deep-" + modality))
|
| 514 |
+
projection = rng.normal(0, 1, size=(len(base), 128)).astype(np.float32)
|
| 515 |
+
emb = base @ projection
|
| 516 |
+
emb = _unit_vector(emb)
|
| 517 |
+
|
| 518 |
+
visual = cnn_vis
|
| 519 |
+
|
| 520 |
+
meta = {
|
| 521 |
+
"method": "Deep embedding simulation",
|
| 522 |
+
"feature_type": "Deterministic projected multimethod embedding",
|
| 523 |
+
"feature_length": len(emb),
|
| 524 |
+
"advantages": "Shows the idea of compact embeddings used by FaceNet/ArcFace/CNN systems.",
|
| 525 |
+
"limitations": "Educational simulation; not trained on biometric identity labels."
|
| 526 |
+
}
|
| 527 |
+
return emb.astype(np.float32), visual, meta
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
def extract_features(img: Image.Image, modality: str, method: str):
|
| 531 |
+
arr, preprocessed, pre_meta = preprocess_modality(img, modality)
|
| 532 |
+
|
| 533 |
+
if method == "Minutiae-like":
|
| 534 |
+
vec, vis, meta = extract_minutiae_like(arr)
|
| 535 |
+
elif method == "LBP":
|
| 536 |
+
vec, vis, meta = extract_lbp(arr)
|
| 537 |
+
elif method == "Gabor":
|
| 538 |
+
vec, vis, meta = extract_gabor(arr)
|
| 539 |
+
elif method == "SIFT/SURF-like":
|
| 540 |
+
vec, vis, meta = extract_sift_like(arr)
|
| 541 |
+
elif method == "CNN-like":
|
| 542 |
+
vec, vis, meta = extract_cnn_like(arr)
|
| 543 |
+
elif method == "Deep embedding":
|
| 544 |
+
vec, vis, meta = extract_deep_embedding(arr, modality)
|
| 545 |
+
else:
|
| 546 |
+
vec, vis, meta = extract_gabor(arr)
|
| 547 |
+
|
| 548 |
+
full_meta = {**pre_meta, **meta}
|
| 549 |
+
return vec.astype(np.float32), preprocessed, vis, full_meta
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
# ---------------------------------------------------------------------
|
| 553 |
+
# Template protection
|
| 554 |
+
# ---------------------------------------------------------------------
|
| 555 |
+
|
| 556 |
+
def _fernet_key(secret: str) -> bytes:
|
| 557 |
+
digest = hashlib.sha256((secret or "demo-secret").encode()).digest()
|
| 558 |
+
return base64.urlsafe_b64encode(digest)
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
def encrypted_preview(vec: np.ndarray, secret: str) -> str:
|
| 562 |
+
raw = np.asarray(vec[:64], dtype=np.float32).tobytes()
|
| 563 |
+
if HAS_CRYPTO:
|
| 564 |
+
f = Fernet(_fernet_key(secret))
|
| 565 |
+
token = f.encrypt(raw)
|
| 566 |
+
return token[:180].decode("utf-8") + "..."
|
| 567 |
+
fallback = hashlib.sha256(raw + secret.encode()).hexdigest()
|
| 568 |
+
return "cryptography package missing; SHA-256 preview only: " + fallback
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
def random_projection(vec: np.ndarray, secret: str, out_dim: int = 128) -> np.ndarray:
|
| 572 |
+
vec = _unit_vector(vec)
|
| 573 |
+
rng = np.random.default_rng(_seed_from_key(secret))
|
| 574 |
+
projection = rng.normal(0, 1, size=(len(vec), out_dim)).astype(np.float32)
|
| 575 |
+
out = vec @ projection
|
| 576 |
+
return _unit_vector(out)
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
def biohash(vec: np.ndarray, secret: str, out_dim: int = 128) -> np.ndarray:
|
| 580 |
+
projected = random_projection(vec, secret, out_dim)
|
| 581 |
+
return (projected > np.median(projected)).astype(np.float32)
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
def chaotic_permutation(vec: np.ndarray, secret: str) -> np.ndarray:
|
| 585 |
+
vec = np.asarray(vec, dtype=np.float32).flatten()
|
| 586 |
+
seed = _seed_from_key(secret)
|
| 587 |
+
x = ((seed % 100000) + 1) / 100001.0
|
| 588 |
+
r = 3.99
|
| 589 |
+
chaotic = []
|
| 590 |
+
for _ in range(len(vec)):
|
| 591 |
+
x = r * x * (1 - x)
|
| 592 |
+
chaotic.append(x)
|
| 593 |
+
perm = np.argsort(chaotic)
|
| 594 |
+
return _unit_vector(vec[perm])
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
def fuzzy_bits(vec: np.ndarray, secret: str, out_dim: int = 128) -> np.ndarray:
|
| 598 |
+
projected = random_projection(vec, secret, out_dim)
|
| 599 |
+
return (projected > 0).astype(np.float32)
|
| 600 |
+
|
| 601 |
+
|
| 602 |
+
def protect_for_matching(vec: np.ndarray, method: str, secret: str) -> Tuple[np.ndarray, str, str]:
|
| 603 |
+
vec = np.asarray(vec, dtype=np.float32).flatten()
|
| 604 |
+
|
| 605 |
+
if method == "Plain template":
|
| 606 |
+
return _unit_vector(vec), "cosine", "Raw normalized template used for comparison."
|
| 607 |
+
|
| 608 |
+
if method == "Encrypted storage":
|
| 609 |
+
# Real encrypted-template systems usually decrypt before matching
|
| 610 |
+
# unless using special cryptographic protocols.
|
| 611 |
+
return _unit_vector(vec), "cosine", (
|
| 612 |
+
"Template is encrypted at rest. For this demo, matching uses the decrypted vector. "
|
| 613 |
+
"Encryption protects storage but does not provide cancelability by itself."
|
| 614 |
+
)
|
| 615 |
+
|
| 616 |
+
if method == "Cancelable biometric":
|
| 617 |
+
return random_projection(vec, secret), "cosine", (
|
| 618 |
+
"Feature vector is transformed using a secret-key random projection. "
|
| 619 |
+
"Changing the key revokes and reissues a new template."
|
| 620 |
+
)
|
| 621 |
+
|
| 622 |
+
if method == "BioHashing":
|
| 623 |
+
return biohash(vec, secret), "hamming", (
|
| 624 |
+
"Projected features are binarized into a BioHash. "
|
| 625 |
+
"Comparison uses Hamming similarity."
|
| 626 |
+
)
|
| 627 |
+
|
| 628 |
+
if method == "Chaotic mapping":
|
| 629 |
+
return chaotic_permutation(vec, secret), "cosine", (
|
| 630 |
+
"A logistic-map sequence permutes the feature vector. "
|
| 631 |
+
"Changing the key changes the permutation."
|
| 632 |
+
)
|
| 633 |
+
|
| 634 |
+
if method == "Fuzzy extractor simulation":
|
| 635 |
+
return fuzzy_bits(vec, secret), "hamming", (
|
| 636 |
+
"Features are converted into stable binary helper-data-style bits. "
|
| 637 |
+
"This demonstrates the concept; it is not a full fuzzy extractor implementation."
|
| 638 |
+
)
|
| 639 |
+
|
| 640 |
+
if method == "Toy homomorphic encryption":
|
| 641 |
+
return _unit_vector(vec), "cosine", (
|
| 642 |
+
"Conceptual demo only. Real homomorphic matching would compute on encrypted values "
|
| 643 |
+
"with much higher cost."
|
| 644 |
+
)
|
| 645 |
+
|
| 646 |
+
return _unit_vector(vec), "cosine", "Default normalized template."
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
def template_preview(vec: np.ndarray, method: str, secret: str) -> Tuple[str, pd.DataFrame]:
|
| 650 |
+
protected, metric, explanation = protect_for_matching(vec, method, secret)
|
| 651 |
+
|
| 652 |
+
if method == "Encrypted storage":
|
| 653 |
+
preview = encrypted_preview(vec, secret)
|
| 654 |
+
df = pd.DataFrame({
|
| 655 |
+
"field": ["storage form", "matching metric", "revocation", "note"],
|
| 656 |
+
"value": [
|
| 657 |
+
"ciphertext preview",
|
| 658 |
+
metric,
|
| 659 |
+
"possible by changing encryption key, but biometric itself is unchanged",
|
| 660 |
+
explanation
|
| 661 |
+
]
|
| 662 |
+
})
|
| 663 |
+
return preview, df
|
| 664 |
+
|
| 665 |
+
if method == "Toy homomorphic encryption":
|
| 666 |
+
quantized = np.round(np.asarray(vec[:16]) * 1000).astype(int)
|
| 667 |
+
preview = "Encrypted-integer toy preview: " + np.array2string(quantized, separator=", ")
|
| 668 |
+
else:
|
| 669 |
+
preview = _vector_preview(protected, 24)
|
| 670 |
+
|
| 671 |
+
df = pd.DataFrame({
|
| 672 |
+
"field": ["protected length", "matching metric", "revocability", "explanation"],
|
| 673 |
+
"value": [
|
| 674 |
+
len(protected),
|
| 675 |
+
metric,
|
| 676 |
+
"Yes" if method in ["Cancelable biometric", "BioHashing", "Chaotic mapping", "Fuzzy extractor simulation"] else "Limited",
|
| 677 |
+
explanation
|
| 678 |
+
]
|
| 679 |
+
})
|
| 680 |
+
return preview, df
|
| 681 |
+
|
| 682 |
+
|
| 683 |
+
# ---------------------------------------------------------------------
|
| 684 |
+
# Liveness and attacks
|
| 685 |
+
# ---------------------------------------------------------------------
|
| 686 |
+
|
| 687 |
+
def liveness_metrics(img: Image.Image) -> Dict:
|
| 688 |
+
arr, _, _ = preprocess_modality(img, "Face")
|
| 689 |
+
|
| 690 |
+
lap_kernel = np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]], dtype=np.float32)
|
| 691 |
+
lap = _conv2d_same(arr, lap_kernel)
|
| 692 |
+
blur_var = float(lap.var())
|
| 693 |
+
|
| 694 |
+
# Frequency energy.
|
| 695 |
+
fft = np.fft.fftshift(np.fft.fft2(arr))
|
| 696 |
+
mag = np.abs(fft)
|
| 697 |
+
h, w = mag.shape
|
| 698 |
+
cy, cx = h // 2, w // 2
|
| 699 |
+
yy, xx = np.ogrid[:h, :w]
|
| 700 |
+
dist = np.sqrt((yy - cy) ** 2 + (xx - cx) ** 2)
|
| 701 |
+
high_mask = dist > (min(h, w) * 0.18)
|
| 702 |
+
high_freq_ratio = float(mag[high_mask].sum() / (mag.sum() + 1e-8))
|
| 703 |
+
|
| 704 |
+
lbp_vec, _, _ = extract_lbp(arr)
|
| 705 |
+
entropy = float(-np.sum(lbp_vec * np.log2(lbp_vec + 1e-8)))
|
| 706 |
+
entropy_score = min(1.0, entropy / 8.0)
|
| 707 |
+
|
| 708 |
+
contrast = float(arr.std())
|
| 709 |
+
brightness = float(arr.mean())
|
| 710 |
+
|
| 711 |
+
blur_score = min(1.0, blur_var * 120.0)
|
| 712 |
+
freq_score = min(1.0, high_freq_ratio * 4.0)
|
| 713 |
+
contrast_score = min(1.0, contrast * 4.0)
|
| 714 |
+
|
| 715 |
+
overall = 0.30 * blur_score + 0.30 * freq_score + 0.25 * entropy_score + 0.15 * contrast_score
|
| 716 |
+
|
| 717 |
+
suspicious_reasons = []
|
| 718 |
+
if blur_score < 0.18:
|
| 719 |
+
suspicious_reasons.append("low sharpness")
|
| 720 |
+
if freq_score < 0.18:
|
| 721 |
+
suspicious_reasons.append("low high-frequency detail")
|
| 722 |
+
if contrast < 0.05:
|
| 723 |
+
suspicious_reasons.append("very low contrast")
|
| 724 |
+
if brightness < 0.08 or brightness > 0.92:
|
| 725 |
+
suspicious_reasons.append("extreme brightness")
|
| 726 |
+
|
| 727 |
+
return {
|
| 728 |
+
"blur_score": round(blur_score, 4),
|
| 729 |
+
"frequency_score": round(freq_score, 4),
|
| 730 |
+
"texture_entropy_score": round(entropy_score, 4),
|
| 731 |
+
"contrast_score": round(contrast_score, 4),
|
| 732 |
+
"brightness": round(brightness, 4),
|
| 733 |
+
"overall_liveness_score": round(float(overall), 4),
|
| 734 |
+
"suspicious_reasons": ", ".join(suspicious_reasons) if suspicious_reasons else "none"
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
|
| 738 |
+
def simulate_attack(img: Image.Image, attack: str, intensity: float) -> Image.Image:
|
| 739 |
+
img = _safe_image(img)
|
| 740 |
+
if img is None:
|
| 741 |
+
raise ValueError("Please upload an image.")
|
| 742 |
+
intensity = float(intensity)
|
| 743 |
+
|
| 744 |
+
if attack == "None":
|
| 745 |
+
return img
|
| 746 |
+
|
| 747 |
+
if attack == "Blur / out-of-focus":
|
| 748 |
+
return img.filter(ImageFilter.GaussianBlur(radius=0.5 + intensity * 5))
|
| 749 |
+
|
| 750 |
+
if attack == "Gaussian noise":
|
| 751 |
+
arr = np.asarray(img).astype(np.float32)
|
| 752 |
+
rng = np.random.default_rng(123)
|
| 753 |
+
noise = rng.normal(0, 8 + intensity * 45, size=arr.shape)
|
| 754 |
+
out = np.clip(arr + noise, 0, 255).astype(np.uint8)
|
| 755 |
+
return Image.fromarray(out)
|
| 756 |
+
|
| 757 |
+
if attack == "Low-contrast print":
|
| 758 |
+
out = ImageOps.grayscale(img).convert("RGB")
|
| 759 |
+
out = ImageEnhance.Contrast(out).enhance(max(0.2, 1.0 - intensity * 0.8))
|
| 760 |
+
out = ImageEnhance.Brightness(out).enhance(0.85 + intensity * 0.15)
|
| 761 |
+
return out
|
| 762 |
+
|
| 763 |
+
if attack == "Replay-screen scanlines":
|
| 764 |
+
arr = np.asarray(img).astype(np.float32)
|
| 765 |
+
step = max(2, int(8 - intensity * 5))
|
| 766 |
+
arr[::step, :, :] *= 0.55
|
| 767 |
+
arr[:, ::max(3, step + 1), :] *= 0.85
|
| 768 |
+
return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8))
|
| 769 |
+
|
| 770 |
+
if attack == "Deepfake-like smoothing":
|
| 771 |
+
out = img.filter(ImageFilter.MedianFilter(size=3))
|
| 772 |
+
out = out.filter(ImageFilter.GaussianBlur(radius=0.5 + intensity * 2.5))
|
| 773 |
+
out = ImageEnhance.Sharpness(out).enhance(0.5)
|
| 774 |
+
return out
|
| 775 |
+
|
| 776 |
+
if attack == "Adversarial-style tiny noise":
|
| 777 |
+
arr = np.asarray(img).astype(np.float32)
|
| 778 |
+
rng = np.random.default_rng(999)
|
| 779 |
+
pattern = rng.choice([-1, 1], size=arr.shape) * (2 + intensity * 12)
|
| 780 |
+
out = np.clip(arr + pattern, 0, 255).astype(np.uint8)
|
| 781 |
+
return Image.fromarray(out)
|
| 782 |
+
|
| 783 |
+
return img
|
| 784 |
+
|
| 785 |
+
|
| 786 |
+
# ---------------------------------------------------------------------
|
| 787 |
+
# Gradio callback functions
|
| 788 |
+
# ---------------------------------------------------------------------
|
| 789 |
+
|
| 790 |
+
def run_feature_lab(img, modality, method):
|
| 791 |
+
if img is None:
|
| 792 |
+
return None, None, None, pd.DataFrame(), {}, "Upload an image first."
|
| 793 |
+
|
| 794 |
+
try:
|
| 795 |
+
vec, pre, vis, meta = extract_features(img, modality, method)
|
| 796 |
+
fig = _fig_feature_bar(vec, f"{method} feature preview")
|
| 797 |
+
df = _make_feature_dataframe(vec)
|
| 798 |
+
explanation = f"""
|
| 799 |
+
### Feature extraction result
|
| 800 |
+
|
| 801 |
+
**Modality:** {modality}
|
| 802 |
+
**Method:** {meta.get("method")}
|
| 803 |
+
**Feature type:** {meta.get("feature_type")}
|
| 804 |
+
**Feature length:** {meta.get("feature_length")}
|
| 805 |
+
|
| 806 |
+
**Advantages:** {meta.get("advantages")}
|
| 807 |
+
|
| 808 |
+
**Limitations:** {meta.get("limitations")}
|
| 809 |
+
|
| 810 |
+
**Note:** The app is educational. For a final report, use exact metrics from the papers you review.
|
| 811 |
+
"""
|
| 812 |
+
return pre, vis, fig, df, meta, explanation
|
| 813 |
+
except Exception as e:
|
| 814 |
+
return None, None, None, pd.DataFrame(), {}, f"Error: {e}"
|
| 815 |
+
|
| 816 |
+
|
| 817 |
+
def run_verification(enroll_img, verify_img, modality, method, protection_method, secret_key, threshold):
|
| 818 |
+
if enroll_img is None or verify_img is None:
|
| 819 |
+
return "Upload both enrollment and verification images.", pd.DataFrame(), None, None
|
| 820 |
+
|
| 821 |
+
try:
|
| 822 |
+
e_vec, e_pre, e_vis, e_meta = extract_features(enroll_img, modality, method)
|
| 823 |
+
v_vec, v_pre, v_vis, v_meta = extract_features(verify_img, modality, method)
|
| 824 |
+
|
| 825 |
+
e_prot, metric, prot_explanation = protect_for_matching(e_vec, protection_method, secret_key)
|
| 826 |
+
v_prot, _, _ = protect_for_matching(v_vec, protection_method, secret_key)
|
| 827 |
+
|
| 828 |
+
if metric == "hamming":
|
| 829 |
+
similarity = _hamming_similarity(e_prot, v_prot)
|
| 830 |
+
else:
|
| 831 |
+
similarity = _cosine_similarity(e_prot, v_prot)
|
| 832 |
+
|
| 833 |
+
live = liveness_metrics(verify_img)
|
| 834 |
+
liveness_score = live["overall_liveness_score"]
|
| 835 |
+
is_live = liveness_score >= 0.35
|
| 836 |
+
accepted = similarity >= threshold and is_live
|
| 837 |
+
|
| 838 |
+
decision = "ACCEPTED" if accepted else "REJECTED"
|
| 839 |
+
color = "green" if accepted else "red"
|
| 840 |
+
|
| 841 |
+
reason = []
|
| 842 |
+
if similarity < threshold:
|
| 843 |
+
reason.append("similarity below threshold")
|
| 844 |
+
if not is_live:
|
| 845 |
+
reason.append("liveness score suspicious")
|
| 846 |
+
if not reason:
|
| 847 |
+
reason.append("similarity and liveness passed")
|
| 848 |
+
|
| 849 |
+
result_md = f"""
|
| 850 |
+
## <span style='color:{color}'>{decision}</span>
|
| 851 |
+
|
| 852 |
+
| Check | Value |
|
| 853 |
+
|---|---:|
|
| 854 |
+
| Similarity score | **{similarity:.4f}** |
|
| 855 |
+
| Threshold | **{threshold:.4f}** |
|
| 856 |
+
| Matching metric | **{metric}** |
|
| 857 |
+
| Liveness score | **{liveness_score:.4f}** |
|
| 858 |
+
| Liveness verdict | **{"Live / acceptable" if is_live else "Suspicious"}** |
|
| 859 |
+
| Reason | **{", ".join(reason)}** |
|
| 860 |
+
|
| 861 |
+
**Template protection explanation:**
|
| 862 |
+
{prot_explanation}
|
| 863 |
+
|
| 864 |
+
**Important:** This demo fails closed. If the image cannot be processed, it does not return fake success.
|
| 865 |
+
"""
|
| 866 |
+
|
| 867 |
+
metrics_df = pd.DataFrame([
|
| 868 |
+
{"metric": "similarity", "value": round(similarity, 4)},
|
| 869 |
+
{"metric": "threshold", "value": round(float(threshold), 4)},
|
| 870 |
+
{"metric": "liveness_score", "value": liveness_score},
|
| 871 |
+
{"metric": "blur_score", "value": live["blur_score"]},
|
| 872 |
+
{"metric": "frequency_score", "value": live["frequency_score"]},
|
| 873 |
+
{"metric": "texture_entropy_score", "value": live["texture_entropy_score"]},
|
| 874 |
+
{"metric": "contrast_score", "value": live["contrast_score"]},
|
| 875 |
+
])
|
| 876 |
+
|
| 877 |
+
fig = plt.figure(figsize=(6, 3))
|
| 878 |
+
labels = ["similarity", "threshold", "liveness"]
|
| 879 |
+
values = [similarity, threshold, liveness_score]
|
| 880 |
+
plt.bar(labels, values)
|
| 881 |
+
plt.ylim(0, 1)
|
| 882 |
+
plt.title("Verification decision signals")
|
| 883 |
+
plt.tight_layout()
|
| 884 |
+
|
| 885 |
+
return result_md, metrics_df, e_vis, v_vis
|
| 886 |
+
|
| 887 |
+
except Exception as e:
|
| 888 |
+
return f"## REJECTED\n\nProcessing error: {e}", pd.DataFrame(), None, None
|
| 889 |
+
|
| 890 |
+
|
| 891 |
+
def run_template_lab(img, modality, feature_method, protection_method, secret_key):
|
| 892 |
+
if img is None:
|
| 893 |
+
return "Upload an image first.", pd.DataFrame(), pd.DataFrame(), None
|
| 894 |
+
|
| 895 |
+
try:
|
| 896 |
+
vec, pre, vis, meta = extract_features(img, modality, feature_method)
|
| 897 |
+
preview, info_df = template_preview(vec, protection_method, secret_key)
|
| 898 |
+
|
| 899 |
+
raw_df = pd.DataFrame({
|
| 900 |
+
"index": list(range(min(24, len(vec)))),
|
| 901 |
+
"raw_feature_value": [float(x) for x in vec[:24]]
|
| 902 |
+
})
|
| 903 |
+
|
| 904 |
+
md = f"""
|
| 905 |
+
## Template protection preview
|
| 906 |
+
|
| 907 |
+
**Feature method:** {feature_method}
|
| 908 |
+
**Protection method:** {protection_method}
|
| 909 |
+
**Raw feature length:** {len(vec)}
|
| 910 |
+
|
| 911 |
+
### Protected / stored preview
|
| 912 |
+
|
| 913 |
+
```text
|
| 914 |
+
{preview}
|