Files changed (1) hide show
  1. app.py +0 -531
app.py DELETED
@@ -1,531 +0,0 @@
1
- import sys
2
- import os
3
- import io
4
-
5
- # Fix Windows console encoding for DeepFace emoji output
6
- if sys.platform == "win32":
7
- os.environ["PYTHONIOENCODING"] = "utf-8"
8
- try:
9
- sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
10
- sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
11
- except Exception:
12
- pass
13
-
14
- import traceback
15
- import re
16
- import time
17
- from flask import Flask, render_template, Response, request, jsonify
18
- import base64
19
- import cv2
20
- import face_recognition
21
- import numpy as np
22
- import os
23
- import pymongo
24
- from pymongo import ReturnDocument
25
- from datetime import datetime
26
- from pathlib import Path
27
- from deepface import DeepFace
28
- from dotenv import load_dotenv
29
-
30
- load_dotenv()
31
-
32
- app = Flask(__name__)
33
-
34
- # SETUP DATABASE
35
- MONGO_URI = os.environ.get("MONGO_URI")
36
-
37
- try:
38
- # Check if a password is required but missing to avoid config error (Atlas URIs require a password if username is provided)
39
- # If the MONGO_URI lacks a password field, pymongo MongoClient instantiation will fail immediately with ConfigurationError.
40
- client = pymongo.MongoClient(MONGO_URI, serverSelectionTimeoutMS=2000)
41
- # Check connection
42
- client.server_info()
43
- db = client["attendance_system"]
44
- logs_collection = db["attendance_logs"]
45
- counters_collection = db["counters"]
46
- print("Successfully connected to MongoDB Atlas!")
47
- except Exception as e:
48
- print(f"Error connecting to MongoDB: {e}.")
49
- client = None
50
- db = None
51
- logs_collection = None
52
- counters_collection = None
53
-
54
- def get_next_sequence_value(sequence_name):
55
- """Generates an auto-incrementing integer sequence ID like in relational databases."""
56
- try:
57
- if counters_collection is not None:
58
- counter = counters_collection.find_one_and_update(
59
- {"_id": sequence_name},
60
- {"$inc": {"sequence_value": 1}},
61
- upsert=True,
62
- return_document=ReturnDocument.AFTER
63
- )
64
- return counter["sequence_value"]
65
- except Exception as e:
66
- print(f"Error generating sequence value: {e}")
67
- return int(time.time())
68
-
69
- def parse_date_time_to_timestamp(date_str, time_str):
70
- """Parses date and time strings into a Unix timestamp."""
71
- try:
72
- dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %I:%M %p")
73
- return dt.timestamp()
74
- except Exception:
75
- try:
76
- for fmt in ("%H:%M:%S", "%H:%M", "%I:%M:%S %p"):
77
- try:
78
- dt = datetime.strptime(f"{date_str} {time_str}", f"%Y-%m-%d {fmt}")
79
- return dt.timestamp()
80
- except ValueError:
81
- continue
82
- except Exception:
83
- pass
84
- return 0.0
85
-
86
- def has_logged_recently(name):
87
- """Checks if the person has logged attendance in the last 24 hours."""
88
- cutoff_timestamp = time.time() - 86400 # 24 hours in seconds
89
-
90
- if logs_collection is not None:
91
- try:
92
- record = logs_collection.find_one({
93
- "name": name,
94
- "timestamp": {"$gt": cutoff_timestamp}
95
- })
96
- if record:
97
- return True
98
- except Exception as e:
99
- print(f"Error checking MongoDB logs: {e}")
100
-
101
- return False
102
-
103
-
104
- # LOAD DATA ON STARTUP
105
- DATASET_DIR = Path("dataset_extracted")
106
- DATASET_DIR.mkdir(exist_ok=True)
107
-
108
- known_encodings = []
109
- known_names = []
110
-
111
- print("Loading dataset and encoding faces. Please wait...")
112
- for person_name in os.listdir(DATASET_DIR):
113
- person_path = DATASET_DIR / person_name
114
- if not person_path.is_dir():
115
- continue
116
-
117
- for image_name in os.listdir(person_path):
118
- image_path = person_path / image_name
119
- image = face_recognition.load_image_file(image_path)
120
- encodings = face_recognition.face_encodings(image)
121
- if len(encodings) > 0:
122
- known_encodings.append(encodings[0])
123
- known_names.append(person_name.replace('_', ' '))
124
-
125
- print(f"Loaded {len(known_encodings)} faces. Starting app...")
126
-
127
-
128
- # DEEPFACE ANALYSIS CACHE
129
- # Stores { name: { "emotion": str, "age": int, "gender": str, "race": str, "timestamp": float } }
130
- analysis_cache = {}
131
- CACHE_TTL = 10 # seconds before re-analyzing a known person
132
- UNKNOWN_CACHE_TTL = 5 # seconds for unknown faces
133
-
134
-
135
- def get_cached_analysis(name):
136
- """Return cached analysis if still fresh or if we have reached the 5-frame limit."""
137
- if name in analysis_cache:
138
- entry = analysis_cache[name]
139
-
140
- # OPTIMIZATION: If we have analyzed this face 5 times, stop analyzing and use cache forever
141
- if entry.get("count", 0) >= 5:
142
- return entry
143
-
144
- ttl = UNKNOWN_CACHE_TTL if name == "Unknown" else CACHE_TTL
145
- if time.time() - entry["timestamp"] < ttl:
146
- return entry
147
- return None
148
-
149
-
150
- from concurrent.futures import ThreadPoolExecutor
151
-
152
- # Initialize thread pool for parallel analysis
153
- executor = ThreadPoolExecutor(max_workers=4)
154
-
155
- def run_deepface_analysis(face_img):
156
- """
157
- Run DeepFace.analyze on a cropped face image.
158
- Uses detector_backend='skip' for massive speedup since we already have the crop.
159
- """
160
- try:
161
- # Pre-resize to 224x224 (typical for DeepFace models) to reduce processing overhead
162
- if face_img.shape[0] > 224 or face_img.shape[1] > 224:
163
- face_img = cv2.resize(face_img, (224, 224))
164
-
165
- results = DeepFace.analyze(
166
- face_img,
167
- actions=['emotion', 'gender'],
168
- enforce_detection=False,
169
- detector_backend='skip', # Crucial: skip redundant detection
170
- silent=True
171
- )
172
- # DeepFace returns a list; take the first result
173
- result = results[0] if isinstance(results, list) else results
174
-
175
- return {
176
- "emotion": result.get("dominant_emotion", "N/A"),
177
- "gender": result.get("dominant_gender", "N/A"),
178
- "timestamp": time.time()
179
- }
180
- except Exception as e:
181
- # Don't print full traceback for every failure to keep logs clean on low-end systems
182
- # print(f"DeepFace analysis error: {e}")
183
- return None
184
-
185
- def warm_up_deepface():
186
- """Pre-loads DeepFace models to avoid lag on first detection."""
187
- print("Pre-warming DeepFace models...")
188
- try:
189
- dummy_img = np.zeros((224, 224, 3), dtype=np.uint8)
190
- DeepFace.analyze(dummy_img, actions=['emotion', 'gender'],
191
- enforce_detection=False, detector_backend='skip', silent=True)
192
- print("DeepFace models loaded successfully.")
193
- except Exception as e:
194
- print(f"DeepFace warming failed: {e}")
195
-
196
- # Run warming in background
197
- import threading
198
- threading.Thread(target=warm_up_deepface, daemon=True).start()
199
-
200
-
201
- # LOGGING LOGIC
202
- marked_names = set()
203
-
204
- def mark_attendance(name, analysis_data=None):
205
- if has_logged_recently(name):
206
- print(f"Attendance already recorded in the last 24 hours for: {name}")
207
- return False
208
-
209
- now = datetime.now()
210
- current_date = now.strftime("%Y-%m-%d")
211
- current_time = now.strftime("%I:%M %p") # 12-hour format (e.g., 01:45 PM)
212
- current_timestamp = now.timestamp()
213
-
214
- emotion = ""
215
- gender = ""
216
- if analysis_data:
217
- emotion = analysis_data.get("emotion", "")
218
- gender = analysis_data.get("gender", "")
219
-
220
- if logs_collection is not None:
221
- try:
222
- log_id = get_next_sequence_value("log_id")
223
- logs_collection.insert_one({
224
- "id": log_id,
225
- "name": name,
226
- "date": current_date,
227
- "time": current_time,
228
- "emotion": emotion,
229
- "gender": gender,
230
- "timestamp": current_timestamp
231
- })
232
- print(f"Attendance Logged in MongoDB for: {name} "
233
- f"[id={log_id}, emotion={emotion}, gender={gender}]")
234
- return True
235
- except Exception as e:
236
- print(f"Error logging attendance to MongoDB: {e}.")
237
-
238
- return False
239
-
240
-
241
- # EMOJI MAPPINGS
242
- EMOTION_EMOJIS = {
243
- "happy": "😊", "sad": "😢", "angry": "😠", "surprise": "😲",
244
- "fear": "😨", "disgust": "🤢", "neutral": "😐"
245
- }
246
-
247
-
248
- # WEBCAM FRAME PROCESSING (Cloud-compatible)
249
- @app.route('/process_frame', methods=['POST'])
250
- def process_frame():
251
- try:
252
- data = request.json['image']
253
-
254
- header, encoded = data.split(",", 1)
255
- img_data = base64.b64decode(encoded)
256
- nparr = np.frombuffer(img_data, np.uint8)
257
- frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
258
-
259
- # Optimization: Use fx=0.5 instead of 0.25 and skip upsampling in face_locations for faster detection
260
- small_frame = cv2.resize(frame, (0, 0), fx=0.5, fy=0.5)
261
- rgb_small_frame = cv2.cvtColor(small_frame, cv2.COLOR_BGR2RGB)
262
-
263
- face_locations = face_recognition.face_locations(rgb_small_frame, number_of_times_to_upsample=0)
264
- face_encodings = face_recognition.face_encodings(rgb_small_frame, face_locations)
265
-
266
- faces_analysis = [] # analysis data to send to frontend
267
-
268
- for face_encoding, face_location in zip(face_encodings, face_locations):
269
- matches = face_recognition.compare_faces(known_encodings, face_encoding, tolerance=0.5)
270
- face_distances = face_recognition.face_distance(known_encodings, face_encoding)
271
-
272
- name = "Unknown"
273
- if len(face_distances) > 0:
274
- best_match_index = np.argmin(face_distances)
275
- if matches[best_match_index]:
276
- name = known_names[best_match_index]
277
-
278
- # Scale face location back to full resolution (since fx=0.5, scale is 2)
279
- top, right, bottom, left = face_location
280
- top, right, bottom, left = top * 2, right * 2, bottom * 2, left * 2
281
-
282
- # Crop face from full frame for DeepFace analysis
283
- # Add padding for better analysis accuracy
284
- h, w = frame.shape[:2]
285
- pad = 30
286
- crop_top = max(0, top - pad)
287
- crop_bottom = min(h, bottom + pad)
288
- crop_left = max(0, left - pad)
289
- crop_right = min(w, right + pad)
290
- face_crop = frame[crop_top:crop_bottom, crop_left:crop_right]
291
-
292
- # Check cache or run analysis
293
- cache_key = name if name != "Unknown" else f"Unknown_{left}_{top}"
294
- analysis = get_cached_analysis(cache_key)
295
-
296
- # Optimization: only run analysis if face is large enough (>70px)
297
- if analysis is None and face_crop.size > 0 and (bottom - top) >= 70:
298
- processing_key = f"processing_{cache_key}"
299
- if not analysis_cache.get(processing_key):
300
- analysis_cache[processing_key] = True
301
-
302
- def background_analysis(crop, key, p_key):
303
- try:
304
- res = run_deepface_analysis(crop)
305
- if res:
306
- prev = analysis_cache.get(key, {})
307
- res["count"] = prev.get("count", 0) + 1
308
- analysis_cache[key] = res
309
- finally:
310
- analysis_cache.pop(p_key, None)
311
-
312
- executor.submit(background_analysis, face_crop.copy(), cache_key, processing_key)
313
-
314
- # Provide dummy analysis while processing
315
- analysis = analysis_cache.get(cache_key) or {"emotion": "Analyzing...", "gender": "..."}
316
-
317
- # Mark attendance with analysis data
318
- attendance_status = "none"
319
- if name != "Unknown":
320
- marked_now = mark_attendance(name, analysis)
321
- if marked_now:
322
- attendance_status = "marked"
323
- else:
324
- attendance_status = "already_marked"
325
-
326
- face_data = {
327
- "name": name,
328
- "box": {"top": top, "right": right, "bottom": bottom, "left": left},
329
- "attendance": attendance_status
330
- }
331
- if analysis:
332
- face_data["analysis"] = {
333
- "emotion": analysis.get("emotion", "N/A"),
334
- "gender": analysis.get("gender", "N/A")
335
- }
336
- faces_analysis.append(face_data)
337
-
338
- # Removed cv2 drawing and JPEG encoding to save massive amount of CPU and network bandwidth
339
- return jsonify({
340
- 'faces': faces_analysis
341
- })
342
-
343
- except Exception as e:
344
- traceback.print_exc()
345
- print(f"Error processing frame: {e}")
346
- return jsonify({'error': str(e)}), 500
347
-
348
-
349
-
350
- # NEW: USER REGISTRATION ROUTE
351
- @app.route('/register', methods=['POST'])
352
- def register():
353
- """
354
- Accepts a name and an array of base64-encoded face images (minimum 5).
355
- Saves all valid images to dataset_extracted/<name>/ and hot-reloads
356
- every face encoding into memory — no server restart required.
357
- """
358
- MIN_PHOTOS = 5
359
- MAX_PHOTOS = 10
360
-
361
- try:
362
- payload = request.json
363
- name = payload.get('name', '').strip()
364
- images_data = payload.get('images', []) # list of base64 data URLs
365
-
366
- # Validate name
367
- if not name:
368
- return jsonify({'success': False, 'error': 'Name cannot be empty.'}), 400
369
-
370
- if not re.match(r'^[\w\s\-]+$', name):
371
- return jsonify({'success': False,
372
- 'error': 'Name contains invalid characters. '
373
- 'Use letters, numbers, spaces or hyphens.'}), 400
374
-
375
- # Validate photo count
376
- if not isinstance(images_data, list) or len(images_data) < MIN_PHOTOS:
377
- return jsonify({'success': False,
378
- 'error': f'Please provide at least {MIN_PHOTOS} photos '
379
- f'for accurate recognition. '
380
- f'You sent {len(images_data)}.'}), 400
381
-
382
- # Cap at MAX_PHOTOS (browser should enforce this too, but be safe)
383
- images_data = images_data[:MAX_PHOTOS]
384
-
385
- # Process each image
386
- # We keep underscores for folder names for compatibility,
387
- # but will use the original name for display/logging.
388
- folder_name = name.replace(' ', '_')
389
- person_dir = DATASET_DIR / folder_name
390
- person_dir.mkdir(parents=True, exist_ok=True)
391
-
392
- new_encodings = [] # encodings successfully extracted from this batch
393
- saved_count = 0
394
- no_face_count = 0
395
- multi_face_count = 0
396
-
397
- for idx, image_data in enumerate(images_data):
398
- if ',' not in image_data:
399
- continue
400
-
401
- try:
402
- _, encoded = image_data.split(',', 1)
403
- img_bytes = base64.b64decode(encoded)
404
- nparr = np.frombuffer(img_bytes, np.uint8)
405
- frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
406
- except Exception:
407
- continue
408
-
409
- if frame is None:
410
- continue
411
-
412
- rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
413
- face_locations = face_recognition.face_locations(rgb_frame)
414
-
415
- if len(face_locations) == 0:
416
- no_face_count += 1
417
- continue
418
- if len(face_locations) > 1:
419
- multi_face_count += 1
420
- continue
421
-
422
- encoding = face_recognition.face_encodings(rgb_frame, face_locations)[0]
423
- new_encodings.append(encoding)
424
-
425
- # Save image
426
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S%f")
427
- image_path = person_dir / f"photo_{idx:02d}_{timestamp}.jpg"
428
- cv2.imwrite(str(image_path), frame)
429
- saved_count += 1
430
-
431
- # Require at least MIN_PHOTOS usable face photos
432
- if saved_count < MIN_PHOTOS:
433
- # Clean up any partially-saved files
434
- import shutil
435
- if person_dir.exists() and not any(person_dir.iterdir()):
436
- shutil.rmtree(person_dir)
437
-
438
- reasons = []
439
- if no_face_count:
440
- reasons.append(f"{no_face_count} photo(s) had no detectable face")
441
- if multi_face_count:
442
- reasons.append(f"{multi_face_count} photo(s) had multiple faces")
443
-
444
- detail = ('. ' + '; '.join(reasons) + '.') if reasons else '.'
445
- return jsonify({
446
- 'success': False,
447
- 'error': f'Only {saved_count} usable face photos out of '
448
- f'{len(images_data)} provided{detail} '
449
- f'Please retake with better lighting and only your face in frame.'
450
- }), 400
451
-
452
- # Duplicate check (compare first new encoding against known set)
453
- if len(known_encodings) > 0:
454
- distances = face_recognition.face_distance(known_encodings, new_encodings[0])
455
- best_idx = np.argmin(distances)
456
- if distances[best_idx] < 0.5:
457
- existing = known_names[best_idx]
458
- # Remove newly saved folder since it's a duplicate
459
- import shutil
460
- shutil.rmtree(person_dir, ignore_errors=True)
461
- return jsonify({
462
- 'success': False,
463
- 'error': f'This face is already registered as "{existing}".'
464
- }), 409
465
-
466
- # Hot-reload all new encodings into memory
467
- for enc in new_encodings:
468
- known_encodings.append(enc)
469
- known_names.append(name)
470
-
471
- print(f"[REGISTER] '{name}' registered with {saved_count} photos. "
472
- f"Total known face encodings: {len(known_encodings)}")
473
-
474
- return jsonify({
475
- 'success': True,
476
- 'message': f'"{name}" registered successfully with {saved_count} photos! '
477
- f'Attendance will now be marked automatically.'
478
- })
479
-
480
- except Exception as e:
481
- traceback.print_exc()
482
- return jsonify({'success': False, 'error': f'Server error: {str(e)}'}), 500
483
-
484
-
485
- # ROUTES
486
- @app.route('/')
487
- def index():
488
- return render_template('index.html')
489
-
490
- @app.route('/logs')
491
- def view_logs():
492
- records = []
493
- if logs_collection is not None:
494
- try:
495
- # Retrieve all logs from MongoDB sorted by id in descending order
496
- records_cursor = logs_collection.find().sort("id", -1)
497
- for doc in records_cursor:
498
- records.append((
499
- doc.get("id", 0),
500
- doc.get("name", "N/A"),
501
- doc.get("date", "N/A"),
502
- doc.get("time", "N/A"),
503
- doc.get("emotion", ""),
504
- doc.get("gender", "")
505
- ))
506
- except Exception as e:
507
- print(f"Error fetching logs from MongoDB: {e}.")
508
-
509
- # Format time to AM/PM for display (handles old 24h records too)
510
- formatted_records = []
511
- for row in records:
512
- row_list = list(row)
513
- time_str = row_list[3]
514
- try:
515
- # Try to parse and reformat if it looks like 24h time or has seconds
516
- for fmt in ("%H:%M:%S", "%H:%M", "%I:%M:%S %p"):
517
- try:
518
- t = datetime.strptime(time_str, fmt)
519
- row_list[3] = t.strftime("%I:%M %p")
520
- break
521
- except ValueError:
522
- continue
523
- except Exception:
524
- pass
525
- formatted_records.append(tuple(row_list))
526
-
527
- return render_template('logs.html', records=formatted_records)
528
-
529
-
530
- if __name__ == "__main__":
531
- app.run(host="0.0.0.0", port=7860, debug=False)