File size: 16,579 Bytes
6d97b07
 
c121004
 
 
 
 
 
 
 
ab0ccca
da61644
6d97b07
 
 
c121004
 
 
 
 
 
6d97b07
 
 
c121004
 
 
6d97b07
 
c121004
 
 
 
6d97b07
 
c121004
6d97b07
c121004
 
 
 
6d97b07
 
 
 
 
 
 
 
 
c121004
 
 
ab0ccca
c121004
6d97b07
 
c121004
 
6d97b07
c121004
6d97b07
 
c121004
 
 
 
 
6d97b07
c121004
 
 
6d97b07
c121004
6d97b07
 
 
 
 
 
 
 
 
 
 
 
 
 
c121004
6d97b07
c121004
 
 
 
 
6d97b07
 
 
 
ab0ccca
6d97b07
 
 
 
 
 
 
 
ab0ccca
 
94c7aa9
ab0ccca
 
6d97b07
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0bd009b
6d97b07
 
 
 
0bd009b
c121004
 
 
 
6d97b07
c121004
6d97b07
 
 
c121004
 
 
0bd009b
 
c121004
6d97b07
0bd009b
c121004
 
 
 
 
 
 
6d97b07
c121004
6d97b07
 
c121004
 
 
 
 
 
6d97b07
c121004
6d97b07
 
c121004
 
ab0ccca
c121004
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6d97b07
 
 
 
 
 
 
 
 
 
 
ab0ccca
 
91d0c7d
ab0ccca
6d97b07
91d0c7d
c121004
 
 
 
 
6d97b07
ab0ccca
c121004
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0bd009b
 
 
da61644
6d97b07
0bd009b
 
6d97b07
ab0ccca
 
 
6d97b07
ab0ccca
0bd009b
 
ab0ccca
0bd009b
 
 
 
 
 
da61644
 
 
 
 
 
 
 
 
 
6d97b07
 
0bd009b
 
6d97b07
0bd009b
 
8cdfa09
 
0bd009b
 
8cdfa09
0bd009b
 
 
 
 
 
 
 
 
 
 
6d97b07
da61644
6d97b07
 
 
 
 
 
 
0bd009b
6d97b07
 
 
ab0ccca
91d0c7d
ab0ccca
6d97b07
91d0c7d
 
0bd009b
 
 
 
da61644
 
6d97b07
0bd009b
 
 
 
 
 
da61644
6d97b07
0bd009b
 
 
da61644
 
0bd009b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c121004
0bd009b
6d97b07
c121004
 
6d97b07
ab0ccca
 
 
6d97b07
ab0ccca
c121004
 
ab0ccca
c121004
 
 
 
 
 
 
0bd009b
 
 
 
 
 
 
c121004
 
 
 
 
 
 
 
 
 
 
6d97b07
 
 
 
 
 
 
 
 
ab0ccca
6d97b07
 
 
 
ab0ccca
 
 
6d97b07
91d0c7d
c121004
 
0bd009b
c121004
0bd009b
6d97b07
 
 
c121004
 
 
 
 
 
6d97b07
 
c121004
 
 
 
 
 
 
 
 
 
 
 
0bd009b
c121004
 
 
 
 
6d97b07
c121004
 
 
 
 
 
 
 
 
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
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
# app.py - FaceNet/ArcFace Face Verification API
from fastapi import FastAPI, File, UploadFile, HTTPException, Body, Form
from fastapi.responses import JSONResponse
import torch
import numpy as np
import cv2
from PIL import Image
import tempfile
import os
import logging
from typing import List
import json
from facenet_pytorch import InceptionResnetV1, extract_face
from facenet_pytorch import MTCNN
import torch.nn.functional as F

# Set up logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

app = FastAPI(
    title="Face Verification API",
    description="Extract face features from videos using FaceNet model - Specialized for Face Verification",
    version="2.0.0"
)

# Global variables for model
mtcnn = None
resnet = None
device = None

@app.on_event("startup")
async def load_model():
    """Load the FaceNet model when the application starts"""
    global mtcnn, resnet, device
    
    logger.info("Loading FaceNet model and MTCNN face detector...")
    try:
        device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
        logger.info(f"Using device: {device}")
        
        # MTCNN for face detection (much better than Haar Cascade)
        mtcnn = MTCNN(image_size=160, margin=0, min_face_size=20, device=device)
        logger.info("✅ MTCNN face detector loaded!")
        
        # FaceNet model (InceptionResnetV1) - trained on VGGFace2
        # Returns 512-dimensional embeddings
        resnet = InceptionResnetV1(pretrained='vggface2', device=device).eval()
        logger.info("✅ FaceNet (InceptionResnetV1) model loaded!")
        logger.info("Model trained on VGGFace2 - specifically for face verification")
        
    except Exception as e:
        logger.error(f"❌ Error loading model: {str(e)}")
        raise e

def extract_faces_from_video(video_path: str, frame_interval: int = 5, max_faces: int = None):
    """Extract and align face images from video frames using MTCNN"""
    try:
        cap = cv2.VideoCapture(video_path)
        faces = []
        frame_idx = 0
        total_frames = 0
        faces_detected = 0

        while True:
            ret, frame = cap.read()
            if not ret:
                break
            
            if frame_idx % frame_interval == 0:
                # Convert BGR to RGB
                rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
                pil_image = Image.fromarray(rgb_frame)
                
                try:
                    # MTCNN detects and aligns faces
                    face_tensor = mtcnn(pil_image)
                    
                    if face_tensor is not None:
                        # face_tensor shape: (1, 3, 160, 160) or (batch, 3, 160, 160)
                        if face_tensor.dim() == 3:
                            face_tensor = face_tensor.unsqueeze(0)
                        
                        faces.append(face_tensor)
                        faces_detected += 1
                        
                except Exception as e:
                    logger.warning(f"Could not detect face in frame {frame_idx}: {str(e)}")
                
                total_frames += 1
            
            frame_idx += 1

        cap.release()
        
        logger.info(f"Video processing: Processed {total_frames} frames, detected {faces_detected} faces")
        
        if len(faces) == 0:
            logger.warning("No faces detected in video")
            return None
        
        # Limit number of faces if specified
        if max_faces and len(faces) > max_faces:
            faces = faces[:max_faces]
        
        # Concatenate all face tensors
        faces_batch = torch.cat(faces, dim=0)
        return faces_batch
    
    except Exception as e:
        logger.error(f"Error processing video: {str(e)}")
        return None

def extract_facenet_embeddings(faces_batch):
    """Extract FaceNet embeddings (512-dim) from face batch"""
    try:
        if faces_batch is None:
            return None
        
        # Move to device
        faces_batch = faces_batch.to(device)
        
        with torch.no_grad():
            # Get 512-dimensional embeddings from FaceNet
            embeddings = resnet(faces_batch)  # Shape: (batch_size, 512)
            
            # L2 normalize embeddings
            embeddings = F.normalize(embeddings, p=2, dim=1)
            
            # Average embeddings from all frames
            avg_embedding = embeddings.mean(dim=0).cpu().numpy()
            
            logger.info(f"Extracted {embeddings.shape[0]} face embeddings, averaging to get final embedding")
        
        return avg_embedding
    
    except Exception as e:
        logger.error(f"Error extracting embeddings: {str(e)}")
        return None

def calculate_cosine_similarity(embedding1: np.ndarray, embedding2: np.ndarray) -> float:
    """Calculate cosine similarity between two normalized embeddings"""
    # Embeddings should already be normalized by FaceNet
    similarity = np.dot(embedding1, embedding2)
    return float(np.clip(similarity, -1.0, 1.0))

@app.get("/")
async def root():
    """Root endpoint with API information"""
    return {
        "message": "🚀 Face Verification API is running!",
        "status": "healthy",
        "model": "FaceNet (InceptionResnetV1) trained on VGGFace2",
        "embedding_dimension": 512,
        "accuracy_improvement": "99.65% on LFW (using face-specialized model)",
        "endpoints": {
            "health": "/health",
            "extract_features": "/extract",
            "verify_with_embedding": "/verify-with-embedding",
            "verify_two_videos": "/verify-two-videos"
        },
        "recommended_threshold": 0.6,
        "usage": "Use POST requests to extract or verify endpoints"
    }

@app.get("/health")
async def health_check():
    """Health check endpoint"""
    return {
        "status": "healthy",
        "model_loaded": resnet is not None,
        "device": str(device),
        "model_type": "FaceNet (InceptionResnetV1)",
        "embedding_size": 512,
        "timestamp": np.datetime64('now').astype(str)
    }

@app.post("/extract")
async def extract_features(file: UploadFile = File(...)):
    """
    Extract facial features from a video file using FaceNet
    
    - **file**: Video file (mp4, avi, mov, mkv)
    - **returns**: Embedding vector of length 512 (FaceNet embeddings)
    """
    try:
        # Validate file type
        if not file.content_type.startswith('video/') and not file.filename.lower().endswith(('.mp4', '.avi', '.mov', '.mkv')):
            raise HTTPException(
                status_code=400, 
                detail="Only video files are supported (mp4, avi, mov, mkv)"
            )

        # Create temporary file
        with tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") as tmp:
            content = await file.read()
            if len(content) == 0:
                raise HTTPException(status_code=400, detail="Empty file")
            tmp.write(content)
            tmp_path = tmp.name

        try:
            # Extract faces from video
            faces_batch = extract_faces_from_video(tmp_path)
            
            if faces_batch is None:
                raise HTTPException(
                    status_code=400, 
                    detail="Could not process video or no faces detected. Ensure video contains clear face(s)."
                )
            
            # Extract FaceNet embeddings
            emb = extract_facenet_embeddings(faces_batch)

            if emb is None:
                raise HTTPException(
                    status_code=400, 
                    detail="Could not extract face embeddings"
                )

            return {
                "success": True,
                "embedding": emb.tolist(), 
                "embedding_length": len(emb),
                "embedding_model": "FaceNet (InceptionResnetV1)",
                "message": "Features extracted successfully. Store this embedding in your database."
            }
        finally:
            # Cleanup
            if os.path.exists(tmp_path):
                os.remove(tmp_path)

    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"Extract features error: {str(e)}")
        raise HTTPException(
            status_code=500, 
            detail=f"Internal server error: {str(e)}"
        )

@app.post("/verify-with-embedding")
async def verify_with_embedding(
    file: UploadFile = File(..., description="New video file to verify"),
    stored_embedding: str = Form(..., description="Stored embedding as JSON string"),
    threshold: float = Form(0.6, description="Similarity threshold (0.0-1.0), default 0.6")
):
    """
    Verify a new video against a stored FaceNet embedding from database
    
    - **file**: New video file to verify (FormData)
    - **stored_embedding**: Pre-existing embedding array from database as JSON string (FormData)
    - **threshold**: Similarity threshold (default: 0.6 for FaceNet) (FormData)
    - **returns**: Similarity score and verification result
    """
    try:
        # Validate file type
        if not file.content_type.startswith('video/') and not file.filename.lower().endswith(('.mp4', '.avi', '.mov', '.mkv')):
            raise HTTPException(
                status_code=400, 
                detail="Only video files are supported (mp4, avi, mov, mkv)"
            )

        # Parse stored_embedding from JSON string
        try:
            stored_emb_list = json.loads(stored_embedding)
            stored_emb = np.array(stored_emb_list, dtype=np.float32)
        except Exception as e:
            raise HTTPException(
                status_code=400,
                detail=f"Invalid embedding format: {str(e)}"
            )

        # Validate stored embedding (FaceNet uses 512 dimensions)
        if not stored_emb_list or len(stored_emb_list) != 512:
            raise HTTPException(
                status_code=400,
                detail="Stored embedding must be a 512-dimensional FaceNet embedding"
            )

        # Validate threshold
        if not 0.0 <= threshold <= 1.0:
            raise HTTPException(
                status_code=400,
                detail="Threshold must be between 0.0 and 1.0"
            )

        # Create temporary file for new video
        with tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") as tmp:
            content = await file.read()
            if len(content) == 0:
                raise HTTPException(status_code=400, detail="Video file is empty")
            tmp.write(content)
            tmp_path = tmp.name

        try:
            # Extract faces from new video
            logger.info(f"Processing video file: {file.filename}")
            faces_batch = extract_faces_from_video(tmp_path)

            if faces_batch is None:
                raise HTTPException(
                    status_code=400, 
                    detail="Could not process the video or no faces detected"
                )

            # Extract FaceNet embeddings
            new_emb = extract_facenet_embeddings(faces_batch)
            
            if new_emb is None:
                raise HTTPException(
                    status_code=400, 
                    detail="Could not extract face embeddings"
                )

            # Calculate cosine similarity
            similarity = calculate_cosine_similarity(stored_emb, new_emb)
            
            # Determine result
            result = "same_person" if similarity >= threshold else "different_person"

            logger.info(f"Verification completed - Similarity: {similarity:.4f}, Threshold: {threshold}, Result: {result}")

            return {
                "success": True,
                "similarity": round(similarity, 4),
                "threshold_used": threshold,
                "result": result,
                "is_match": similarity >= threshold,
                "model": "FaceNet (InceptionResnetV1) on VGGFace2",
                "message": "Verification completed successfully",
                "comparison": {
                    "stored_embedding_length": len(stored_emb),
                    "new_embedding_length": len(new_emb),
                    "file_processed": file.filename
                }
            }

        finally:
            # Cleanup temp file
            if os.path.exists(tmp_path):
                os.remove(tmp_path)

    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"Verify with embedding error: {str(e)}")
        raise HTTPException(
            status_code=500, 
            detail=f"Internal server error: {str(e)}"
        )

@app.post("/verify-two-videos")
async def verify_two_videos(
    file1: UploadFile = File(..., description="First video file"),
    file2: UploadFile = File(..., description="Second video file"),
    threshold: float = Body(0.6, description="Similarity threshold (0.0-1.0), default 0.6")
):
    """
    Verify if two videos contain the same person using FaceNet
    
    - **file1**: First video file
    - **file2**: Second video file  
    - **threshold**: Similarity threshold (default: 0.6 for FaceNet)
    - **returns**: Similarity score and verification result
    """
    try:
        # Validate files
        for file in [file1, file2]:
            if not file.content_type.startswith('video/') and not file.filename.lower().endswith(('.mp4', '.avi', '.mov', '.mkv')):
                raise HTTPException(
                    status_code=400, 
                    detail="Only video files are supported (mp4, avi, mov, mkv)"
                )

        # Validate threshold
        if not 0.0 <= threshold <= 1.0:
            raise HTTPException(
                status_code=400,
                detail="Threshold must be between 0.0 and 1.0"
            )

        temp_files = []
        try:
            # Save files temporarily
            for i, file in enumerate([file1, file2]):
                with tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") as tmp:
                    content = await file.read()
                    if len(content) == 0:
                        raise HTTPException(status_code=400, detail=f"File {i+1} is empty")
                    tmp.write(content)
                    temp_files.append(tmp.name)

            # Extract faces from both videos
            faces_batch1 = extract_faces_from_video(temp_files[0])
            faces_batch2 = extract_faces_from_video(temp_files[1])

            if faces_batch1 is None or faces_batch2 is None:
                raise HTTPException(
                    status_code=400, 
                    detail="Could not process one or both videos or no faces detected"
                )

            # Extract embeddings
            emb1 = extract_facenet_embeddings(faces_batch1)
            emb2 = extract_facenet_embeddings(faces_batch2)
            
            if emb1 is None or emb2 is None:
                raise HTTPException(
                    status_code=400, 
                    detail="Could not extract embeddings from one or both videos"
                )

            # Calculate cosine similarity
            similarity = calculate_cosine_similarity(emb1, emb2)
            
            # Determine result
            result = "same_person" if similarity >= threshold else "different_person"

            logger.info(f"Two-video verification - Similarity: {similarity:.4f}, Result: {result}")

            return {
                "success": True,
                "similarity": round(similarity, 4),
                "threshold_used": threshold,
                "result": result,
                "is_match": similarity >= threshold,
                "model": "FaceNet (InceptionResnetV1) on VGGFace2",
                "message": "Verification completed successfully"
            }

        finally:
            # Cleanup temp files
            for temp_file in temp_files:
                if os.path.exists(temp_file):
                    os.remove(temp_file)

    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"Verify two videos error: {str(e)}")
        raise HTTPException(
            status_code=500, 
            detail=f"Internal server error: {str(e)}"
        )

# Add CORS middleware
from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)