File size: 15,613 Bytes
a484243
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
01cab7b
 
a484243
 
 
 
 
01cab7b
 
 
a484243
 
 
01cab7b
a484243
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
01cab7b
 
a484243
 
 
 
 
 
01cab7b
a484243
 
 
 
 
 
 
 
01cab7b
a484243
 
01cab7b
 
 
a484243
 
 
 
 
01cab7b
a484243
 
 
 
 
 
01cab7b
a484243
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
01cab7b
a484243
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
01cab7b
a484243
 
 
 
 
 
 
 
 
 
 
 
 
 
 
01cab7b
 
 
a484243
01cab7b
 
a484243
01cab7b
a484243
 
 
 
 
 
 
 
 
 
 
 
 
01cab7b
a484243
 
 
 
 
 
 
 
 
 
 
 
 
 
01cab7b
a484243
01cab7b
 
 
 
 
 
 
 
a484243
01cab7b
 
 
 
 
 
a484243
 
 
 
 
 
 
01cab7b
 
a484243
01cab7b
a484243
 
 
 
 
01cab7b
a484243
 
 
 
01cab7b
a484243
 
 
 
 
 
 
 
 
 
 
01cab7b
a484243
 
01cab7b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a484243
 
 
 
01cab7b
 
 
 
 
 
a484243
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
01cab7b
a484243
 
01cab7b
a484243
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
01cab7b
 
a484243
01cab7b
 
 
 
 
a484243
 
01cab7b
a484243
 
01cab7b
a484243
 
01cab7b
a484243
 
01cab7b
 
 
 
a484243
 
 
 
 
 
01cab7b
 
 
 
 
a484243
 
01cab7b
 
 
 
 
a484243
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
"""
face_engine.py
--------------
The face-recognition core, rebuilt around embeddings + similarity matching
instead of a retrained softmax classifier. See the README for the full
rationale; short version:

    OLD: photos -> train a fresh N-class classifier -> predict a class index
         (needs 2+ students, full retrain to add anyone, poor stranger-rejection)

    NEW: photos -> CNN embedding (a 1280-d "fingerprint" vector) -> stored in
         a small gallery file -> new faces are matched by cosine similarity
         against every stored vector
         (works with 1 student, registering someone is instant, strangers are
         rejected by threshold + margin instead of forced into a class)

Pipeline for a single photo:
  1. DETECT   - find the face region. Haar Cascade runs first (fast); if it
                finds nothing, MTCNN (a small CNN-based detector) is tried as
                a fallback since it handles angled/harder faces better.
  2. QUALITY  - reject/warn on faces that are too small, blurry (Laplacian
                variance), or too dark/bright, using OpenCV metrics -- this
                catches bad registration photos before they ever hurt
                recognition accuracy.
  3. PREPROCESS - resize to 96x96, normalize for MobileNetV2.
  4. EMBED    - MobileNetV2 (frozen, ImageNet weights, pooling='avg') maps the
                face to a 1280-d vector. No training involved -- this is pure
                feature extraction, which is why registration is instant.
  5. MATCH    - the new embedding is compared via cosine similarity against
                every embedding in the gallery (static/... no, DATA_DIR/embeddings/
                embeddings.json). The closest student wins IF (a) the
                similarity clears MATCH_THRESHOLD and (b) it beats the
                second-best candidate by at least MATCH_MARGIN -- the margin
                check is what catches "two plausible but wrong" matches that
                a bare threshold would let through.
"""

import os
import json
import tempfile
import shutil
import numpy as np
import config

try:
    import cv2
    _haar_cascade = cv2.CascadeClassifier(
        cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
    )
except ImportError:
    cv2 = None
    _haar_cascade = None
    print("[face_engine] OpenCV not available - install with 'pip install opencv-python-headless'")

_mtcnn_detector = None


def _get_mtcnn():
    global _mtcnn_detector
    if _mtcnn_detector is None:
        try:
            from mtcnn import MTCNN
            _mtcnn_detector = MTCNN()
        except ImportError:
            print("[face_engine] MTCNN not available - install with 'pip install mtcnn'")
            return None
    return _mtcnn_detector


def detect_face(image_bgr):
    """
    Finds the largest/most confident face in a BGR image and returns the
    cropped face region (BGR). Returns None if no face could be found by
    either detector.
    """
    if cv2 is None:
        print("[face_engine] OpenCV not available, cannot detect faces")
        return None

    if _haar_cascade is None or _haar_cascade.empty():
        # Haar unavailable -- try MTCNN exclusively
        mtcnn = _get_mtcnn()
        if mtcnn is not None:
            try:
                image_rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
                results = mtcnn.detect_faces(image_rgb)
                if results:
                    best = max(results, key=lambda r: r["box"][2] * r["box"][3])
                    x, y, w, h = best["box"]
                    x, y = max(0, x), max(0, y)
                    face = image_bgr[y:y + h, x:x + w]
                    if face.size > 0:
                        return face
            except Exception as e:
                print(f"[face_engine] MTCNN detection failed: {e}")
        return None

    try:
        gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
        faces = _haar_cascade.detectMultiScale(
            gray, scaleFactor=1.05, minNeighbors=3, minSize=(30, 30)
        )

        if len(faces) > 0:
            x, y, w, h = max(faces, key=lambda box: box[2] * box[3])
            return image_bgr[y:y + h, x:x + w]

        # Haar found nothing -- try MTCNN fallback
        mtcnn = _get_mtcnn()
        if mtcnn is not None:
            try:
                image_rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
                results = mtcnn.detect_faces(image_rgb)
                if results:
                    best = max(results, key=lambda r: r["box"][2] * r["box"][3])
                    x, y, w, h = best["box"]
                    x, y = max(0, x), max(0, y)
                    face = image_bgr[y:y + h, x:x + w]
                    if face.size > 0:
                        return face
            except Exception as e:
                print(f"[face_engine] MTCNN fallback failed: {e}")

    except Exception as e:
        print(f"[face_engine] Error in detect_face: {e}")
        return None

    return None


def assess_quality(face_bgr):
    """
    Runs cheap, fast heuristics on a cropped face and returns a list of
    human-readable warning strings (empty list = looks good).
    """
    warnings = []
    gray = cv2.cvtColor(face_bgr, cv2.COLOR_BGR2GRAY)
    h, w = gray.shape[:2]

    if min(h, w) < config.MIN_FACE_SIZE:
        warnings.append(f"Face looks small in frame ({w}x{h}px) β€” try moving closer.")

    blur_score = cv2.Laplacian(gray, cv2.CV_64F).var()
    if blur_score < config.BLUR_THRESHOLD:
        warnings.append("Image looks blurry β€” hold still and make sure the camera is focused.")

    brightness = float(np.mean(gray))
    if brightness < config.MIN_BRIGHTNESS:
        warnings.append("Image is quite dark β€” try better lighting.")
    elif brightness > config.MAX_BRIGHTNESS:
        warnings.append("Image is overexposed β€” reduce direct light or glare.")

    return warnings


# ---------------------------------------------------------------------------
# Preprocessing + embedding extraction
# ---------------------------------------------------------------------------
def preprocess_face(face_bgr):
    """Resize to IMG_SIZE and normalize the way MobileNetV2 expects."""
    from keras.applications.mobilenet_v2 import preprocess_input

    if face_bgr is None:
        raise ValueError("preprocess_face(): received None")

    if not isinstance(face_bgr, np.ndarray):
        raise TypeError(f"preprocess_face(): expected numpy array, got {type(face_bgr)}")

    if face_bgr.ndim != 3 or face_bgr.shape[2] != 3:
        raise ValueError(f"preprocess_face(): expected HxWx3 BGR image, got shape {face_bgr.shape}")

    h, w = face_bgr.shape[:2]
    if h < 2 or w < 2:
        raise ValueError(f"preprocess_face(): crop too small with shape {face_bgr.shape}")

    face_resized = cv2.resize(
        face_bgr, (config.IMG_SIZE, config.IMG_SIZE), interpolation=cv2.INTER_LINEAR
    )
    face_rgb = cv2.cvtColor(face_resized, cv2.COLOR_BGR2RGB)

    # Add batch dimension: (96, 96, 3) -> (1, 96, 96, 3)
    face_rgb = np.expand_dims(face_rgb, axis=0)

    out = preprocess_input(face_rgb)

    if not (isinstance(out, np.ndarray) and out.shape == (1, config.IMG_SIZE, config.IMG_SIZE, 3)):
        raise ValueError(f"preprocess_face(): unexpected output shape {getattr(out, 'shape', None)}")

    return out


_embedder = None


def _get_embedder():
    """
    Lazy-loads MobileNetV2 as a pure feature extractor.
    """
    global _embedder
    if _embedder is None:
        from keras.applications.mobilenet_v2 import MobileNetV2
        _embedder = MobileNetV2(
            input_shape=(config.IMG_SIZE, config.IMG_SIZE, 3),
            include_top=False,
            weights="imagenet",
            pooling="avg"
        )
    return _embedder


def warm_up_models():
    """Warm up all models at startup to reduce latency during first use."""
    print("[face_engine] Warming up models...")
    try:
        embedder = _get_embedder()
        # Lightweight warm-up: single dummy prediction
        dummy = np.zeros((1, config.IMG_SIZE, config.IMG_SIZE, 3), dtype="float32")
        embedder.predict(dummy, verbose=0)
        print("[face_engine] Embedder warmed up.")
    except Exception as e:
        print(f"[face_engine] Embedder warm-up failed: {e}")

    try:
        mtcnn = _get_mtcnn()
        if mtcnn:
            print("[face_engine] MTCNN warmed up.")
    except Exception as e:
        print(f"[face_engine] MTCNN warm-up failed: {e}")


def compute_embedding(face_bgr):
    """Returns an L2-normalized 1280-d embedding vector for a cropped face."""
    embedder = _get_embedder()
    face_array = preprocess_face(face_bgr)

    if face_array.ndim != 4 or face_array.shape[1:] != (config.IMG_SIZE, config.IMG_SIZE, 3):
        raise ValueError(f"compute_embedding(): unexpected batch shape {face_array.shape}")

    raw = embedder.predict(face_array, verbose=0)[0]
    norm = np.linalg.norm(raw)
    return (raw / norm) if norm > 0 else raw


def cosine_similarity(a, b):
    """Dot product of two already-L2-normalized vectors == cosine similarity."""
    return float(np.dot(a, b))


# ---------------------------------------------------------------------------
# Gallery persistence  (ATOMIC writes to prevent corruption on crash/OOM)
# ---------------------------------------------------------------------------
def load_gallery():
    """Returns {student_id: [embedding, embedding, ...]} as numpy arrays."""
    if not os.path.exists(config.EMBEDDINGS_PATH):
        return {}
    with open(config.EMBEDDINGS_PATH) as f:
        raw = json.load(f)
    return {sid: [np.array(e, dtype="float32") for e in embeddings] for sid, embeddings in raw.items()}


def save_gallery(gallery):
    """Atomically write gallery JSON to prevent corruption if process is killed mid-write."""
    config.ensure_directories()
    serializable = {sid: [e.tolist() for e in embeddings] for sid, embeddings in gallery.items()}

    # Write to temp file in same directory, then atomic rename
    dir_name = os.path.dirname(config.EMBEDDINGS_PATH)
    fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp")
    try:
        with os.fdopen(fd, "w") as f:
            json.dump(serializable, f)
        shutil.move(tmp_path, config.EMBEDDINGS_PATH)
    except Exception:
        # Clean up temp file on failure
        try:
            os.remove(tmp_path)
        except OSError:
            pass
        raise


def add_photo_to_gallery(student_id, face_bgr):
    """Computes and stores one more reference embedding for a student."""
    try:
        embedding = compute_embedding(face_bgr)
    except Exception as e:
        print(f"[face_engine] compute_embedding failed for {student_id}: {e}")
        raise RuntimeError(f"Could not compute face embedding: {e}")

    gallery = load_gallery()
    gallery.setdefault(student_id, []).append(embedding)
    save_gallery(gallery)
    return embedding


def remove_student_from_gallery(student_id):
    gallery = load_gallery()
    if student_id in gallery:
        del gallery[student_id]
        save_gallery(gallery)


def reindex_gallery():
    """
    Rebuilds embeddings.json from scratch by re-reading every photo under
    DATASET_DIR/<student_id>/*. Useful after bulk-importing photos directly
    onto disk, or if the embedding model ever changes and old vectors need
    recomputing.
    """
    gallery = {}
    processed_images = 0
    skipped_images = 0

    if os.path.isdir(config.DATASET_DIR):
        for student_id in sorted(os.listdir(config.DATASET_DIR)):
            folder = os.path.join(config.DATASET_DIR, student_id)
            if not os.path.isdir(folder):
                continue

            embeddings = []
            try:
                for filename in sorted(os.listdir(folder)):
                    filepath = os.path.join(folder, filename)
                    try:
                        image_bgr = cv2.imread(filepath)
                        if image_bgr is None:
                            skipped_images += 1
                            continue
                        face = detect_face(image_bgr)
                        if face is None:
                            skipped_images += 1
                            continue
                        embeddings.append(compute_embedding(face))
                        processed_images += 1
                    except Exception as e:
                        print(f"[face_engine] Error processing image {filepath}: {e}")
                        skipped_images += 1
            except PermissionError:
                print(f"[face_engine] Permission denied accessing folder {folder}")
                continue

            if embeddings:
                gallery[student_id] = embeddings

    save_gallery(gallery)
    return {
        "students_indexed": len(gallery),
        "images_processed": processed_images,
        "images_skipped": skipped_images
    }


# ---------------------------------------------------------------------------
# Recognition
# ---------------------------------------------------------------------------
def match_face(image_bgr):
    """
    Full pipeline for an attendance check: detect -> embed -> compare.

    Returns a dict:
        {"student_id": "STU001", "confidence": 0.81}                    on match
        {"student_id": None, "confidence": 0.0, "reason": "..."}        otherwise
    """
    face = detect_face(image_bgr)
    if face is None:
        return {"student_id": None, "confidence": 0.0, "reason": "No face detected in the image."}

    gallery = load_gallery()
    if not gallery:
        return {"student_id": None, "confidence": 0.0, "reason": "No students registered yet."}

    try:
        query_embedding = compute_embedding(face)
    except Exception as e:
        print(f"[face_engine] Failed to compute embedding: {e}")
        return {"student_id": None, "confidence": 0.0, "reason": f"Could not compute face embedding: {e}"}

    # Flatten all embeddings with their student IDs
    student_ids = []
    all_embeddings = []
    for sid, embeddings in gallery.items():
        for emb in embeddings:
            student_ids.append(sid)
            all_embeddings.append(emb)

    if not all_embeddings:
        return {"student_id": None, "confidence": 0.0, "reason": "No valid face embeddings to compare against."}

    all_embeddings = np.array(all_embeddings)
    query_embedding = np.array(query_embedding)

    # Compute similarities in batch
    similarities = np.dot(all_embeddings, query_embedding)

    # Group by student and find best score per student
    best_per_student = {}
    for i, sid in enumerate(student_ids):
        sim = similarities[i]
        if sid not in best_per_student or best_per_student[sid] < sim:
            best_per_student[sid] = sim

    ranked = sorted(best_per_student.items(), key=lambda item: item[1], reverse=True)
    best_id, best_score = ranked[0]
    second_score = ranked[1][1] if len(ranked) > 1 else -1.0

    if best_score < config.MATCH_THRESHOLD:
        return {
            "student_id": None,
            "confidence": best_score,
            "reason": f"Face not recognized β€” confidence {best_score:.2f} is below threshold {config.MATCH_THRESHOLD:.2f}."
        }

    if (best_score - second_score) < config.MATCH_MARGIN and len(ranked) > 1:
        return {
            "student_id": None,
            "confidence": best_score,
            "reason": "Match too close between two students β€” please retake the photo."
        }

    return {"student_id": best_id, "confidence": best_score}