PhoenixBomb commited on
Commit
3612559
·
verified ·
1 Parent(s): 0e61845

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +914 -0
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}