blacksinisterx commited on
Commit
b2f22b1
Β·
verified Β·
1 Parent(s): 8c53a02

Fix webcam browser mode, face dedup by person, confidence field, video proxy, upsert faces

Browse files
app.py CHANGED
@@ -2395,9 +2395,39 @@ def serve_minio_image(bucket, object_path):
2395
 
2396
  @app.route('/api/v3/video/compressed/<video_id>', methods=['GET'])
2397
  def serve_compressed_video_v3(video_id):
2398
- """NEW: Simple working compressed video endpoint with local fallback"""
2399
  logger.info(f"πŸ†• V3 Request to serve compressed video: {video_id}")
2400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2401
  # 1. Try MinIO if database is enabled
2402
  if DATABASE_ENABLED:
2403
  try:
@@ -2413,38 +2443,14 @@ def serve_compressed_video_v3(video_id):
2413
  # Standard path where compressed videos should be
2414
  minio_path = f"compressed/{video_id}/video.mp4"
2415
 
2416
- logger.info(f"πŸ†• Attempting to generate presigned URL for MinIO: {bucket}/{minio_path}")
2417
 
2418
  # Check if object exists first
2419
  stat = minio_client.stat_object(bucket, minio_path)
2420
 
2421
- # Generate presigned URL (valid for 1 hour)
2422
- from datetime import timedelta
2423
- presigned_url = minio_client.presigned_get_object(
2424
- bucket,
2425
- minio_path,
2426
- expires=timedelta(hours=1),
2427
- response_headers={
2428
- 'response-content-disposition': f'inline; filename="compressed_{video_id}.mp4"',
2429
- 'response-content-type': 'video/mp4'
2430
- }
2431
- )
2432
-
2433
- # Fix for Docker vs Localhost networking issues
2434
- # If running locally but MinIO is in docker/internal network, URL might be unreachabled
2435
- # We assume if request comes to localhost, MinIO is also on localhost
2436
- if 'localhost' in request.host or '127.0.0.1' in request.host:
2437
- # Replace internal hostname (like 'minio') with localhost if present in URL
2438
- # This is a heuristic fix for common dev setups
2439
- # Extract port from presigned URL keys
2440
- parsed_url = urllib.parse.urlparse(presigned_url)
2441
- if parsed_url.hostname not in ['localhost', '127.0.0.1']:
2442
- new_netloc = parsed_url.netloc.replace(parsed_url.hostname, 'localhost')
2443
- presigned_url = parsed_url._replace(netloc=new_netloc).geturl()
2444
- logger.info(f"πŸ”„ Adjusted presigned URL for localhost: {presigned_url}")
2445
-
2446
- logger.info(f"πŸ†• Redirecting to presigned URL for video: {video_id}")
2447
- return redirect(presigned_url, code=302)
2448
 
2449
  else:
2450
  logger.warning(f"πŸ†• Video record not found in DB for: {video_id}")
@@ -2462,32 +2468,15 @@ def serve_compressed_video_v3(video_id):
2462
  original_path = video_record['minio_object_key']
2463
  bucket = video_record.get('minio_bucket', 'detectifai-videos')
2464
 
2465
- logger.info(f"πŸ†• Attempting original video from MinIO: {bucket}/{original_path}")
2466
 
2467
  # Check if original exists
2468
  stat = minio_client.stat_object(bucket, original_path)
2469
 
2470
- # Generate presigned URL for original
2471
- from datetime import timedelta
2472
- presigned_url = minio_client.presigned_get_object(
2473
- bucket,
2474
- original_path,
2475
- expires=timedelta(hours=1),
2476
- response_headers={
2477
- 'response-content-disposition': f'inline; filename="video_{video_id}.mp4"',
2478
- 'response-content-type': 'video/mp4'
2479
- }
2480
- )
2481
-
2482
- # Fix for localhost
2483
- if 'localhost' in request.host or '127.0.0.1' in request.host:
2484
- parsed_url = urllib.parse.urlparse(presigned_url)
2485
- if parsed_url.hostname not in ['localhost', '127.0.0.1']:
2486
- new_netloc = parsed_url.netloc.replace(parsed_url.hostname, 'localhost')
2487
- presigned_url = parsed_url._replace(netloc=new_netloc).geturl()
2488
-
2489
- logger.info(f"βœ… Redirecting to ORIGINAL video for: {video_id}")
2490
- return redirect(presigned_url, code=302)
2491
 
2492
  except Exception as original_e:
2493
  logger.warning(f"πŸ†• Original video fallback also failed: {original_e}")
@@ -3637,6 +3626,7 @@ def start_live_stream():
3637
  camera_id = data.get('camera_id', 'webcam_01')
3638
  camera_index = data.get('camera_index', 0) # 0 = default webcam
3639
  stream_url = data.get('stream_url', '').strip() # RTSP/HTTP stream URL
 
3640
 
3641
  from live_stream_processor import get_live_processor
3642
 
@@ -3649,14 +3639,22 @@ def start_live_stream():
3649
  }), 400
3650
 
3651
  # Store source info on processor β€” actual processing happens in feed endpoint
3652
- processor.camera_index = camera_index
3653
- processor.stream_url = stream_url if stream_url else None
 
 
 
 
 
 
 
3654
 
3655
  return jsonify({
3656
  'success': True,
3657
  'camera_id': camera_id,
 
3658
  'stream_url': stream_url or None,
3659
- 'message': 'Live stream ready' + (f' (URL: {stream_url})' if stream_url else ''),
3660
  'video_feed_url': f'/api/live/feed/{camera_id}'
3661
  })
3662
 
@@ -3721,9 +3719,27 @@ def live_video_feed(camera_id):
3721
  from live_stream_processor import get_live_processor
3722
 
3723
  processor = get_live_processor(camera_id)
 
3724
  camera_index = getattr(processor, 'camera_index', 0)
3725
  stream_url = getattr(processor, 'stream_url', None)
3726
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3727
  logger.info(f"πŸ“Ή Video feed requested for camera {camera_id} (index {camera_index}, url={stream_url})")
3728
  logger.info(f"πŸ“Ή Processor is_processing: {processor.is_processing}")
3729
 
 
2395
 
2396
  @app.route('/api/v3/video/compressed/<video_id>', methods=['GET'])
2397
  def serve_compressed_video_v3(video_id):
2398
+ """Serve compressed video β€” proxy from MinIO/B2 to avoid CORS/redirect issues with <video> tags"""
2399
  logger.info(f"πŸ†• V3 Request to serve compressed video: {video_id}")
2400
 
2401
+ # Helper to stream a MinIO object through Flask (avoids CORS redirect problems)
2402
+ def _stream_from_minio(minio_client, bucket, obj_path, content_length=None):
2403
+ """Stream MinIO object as a Flask response with proper video headers."""
2404
+ try:
2405
+ resp_obj = minio_client.get_object(bucket, obj_path)
2406
+ content_length = content_length or resp_obj.headers.get('Content-Length', '')
2407
+
2408
+ def generate():
2409
+ try:
2410
+ for chunk in resp_obj.stream(32 * 1024): # 32 KB chunks
2411
+ yield chunk
2412
+ finally:
2413
+ resp_obj.close()
2414
+ resp_obj.release_conn()
2415
+
2416
+ headers = {
2417
+ 'Content-Type': 'video/mp4',
2418
+ 'Content-Disposition': f'inline; filename="compressed_{video_id}.mp4"',
2419
+ 'Accept-Ranges': 'bytes',
2420
+ 'Access-Control-Allow-Origin': '*',
2421
+ 'Cache-Control': 'public, max-age=3600',
2422
+ }
2423
+ if content_length:
2424
+ headers['Content-Length'] = str(content_length)
2425
+
2426
+ return Response(generate(), mimetype='video/mp4', headers=headers)
2427
+ except Exception as e:
2428
+ logger.warning(f"Failed to stream {bucket}/{obj_path}: {e}")
2429
+ return None
2430
+
2431
  # 1. Try MinIO if database is enabled
2432
  if DATABASE_ENABLED:
2433
  try:
 
2443
  # Standard path where compressed videos should be
2444
  minio_path = f"compressed/{video_id}/video.mp4"
2445
 
2446
+ logger.info(f"πŸ†• Streaming compressed video from MinIO: {bucket}/{minio_path}")
2447
 
2448
  # Check if object exists first
2449
  stat = minio_client.stat_object(bucket, minio_path)
2450
 
2451
+ stream_resp = _stream_from_minio(minio_client, bucket, minio_path, stat.size)
2452
+ if stream_resp:
2453
+ return stream_resp
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2454
 
2455
  else:
2456
  logger.warning(f"πŸ†• Video record not found in DB for: {video_id}")
 
2468
  original_path = video_record['minio_object_key']
2469
  bucket = video_record.get('minio_bucket', 'detectifai-videos')
2470
 
2471
+ logger.info(f"πŸ†• Streaming original video from MinIO: {bucket}/{original_path}")
2472
 
2473
  # Check if original exists
2474
  stat = minio_client.stat_object(bucket, original_path)
2475
 
2476
+ # Stream through Flask proxy
2477
+ stream_resp = _stream_from_minio(minio_client, bucket, original_path, stat.size)
2478
+ if stream_resp:
2479
+ return stream_resp
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2480
 
2481
  except Exception as original_e:
2482
  logger.warning(f"πŸ†• Original video fallback also failed: {original_e}")
 
3626
  camera_id = data.get('camera_id', 'webcam_01')
3627
  camera_index = data.get('camera_index', 0) # 0 = default webcam
3628
  stream_url = data.get('stream_url', '').strip() # RTSP/HTTP stream URL
3629
+ source_type = data.get('source_type', '') # 'browser_webcam' or 'url'
3630
 
3631
  from live_stream_processor import get_live_processor
3632
 
 
3639
  }), 400
3640
 
3641
  # Store source info on processor β€” actual processing happens in feed endpoint
3642
+ processor.source_type = source_type # Track source type
3643
+ if source_type == 'browser_webcam':
3644
+ # Browser webcam: frames arrive via /api/live/process-frame, no server camera needed
3645
+ processor.camera_index = -1 # Sentinel: do NOT open any camera
3646
+ processor.stream_url = None
3647
+ logger.info(f"Browser webcam mode for {camera_id} β€” frames via /api/live/process-frame")
3648
+ else:
3649
+ processor.camera_index = camera_index
3650
+ processor.stream_url = stream_url if stream_url else None
3651
 
3652
  return jsonify({
3653
  'success': True,
3654
  'camera_id': camera_id,
3655
+ 'source_type': source_type,
3656
  'stream_url': stream_url or None,
3657
+ 'message': 'Live stream ready' + (' (browser webcam)' if source_type == 'browser_webcam' else f' (URL: {stream_url})' if stream_url else ''),
3658
  'video_feed_url': f'/api/live/feed/{camera_id}'
3659
  })
3660
 
 
3719
  from live_stream_processor import get_live_processor
3720
 
3721
  processor = get_live_processor(camera_id)
3722
+ source_type = getattr(processor, 'source_type', '')
3723
  camera_index = getattr(processor, 'camera_index', 0)
3724
  stream_url = getattr(processor, 'stream_url', None)
3725
 
3726
+ # Browser webcam mode: frames arrive via /api/live/process-frame, don't open camera
3727
+ if source_type == 'browser_webcam' or camera_index == -1:
3728
+ logger.info(f"πŸ“Ή Browser webcam mode β€” feed endpoint not used (frames via process-frame)")
3729
+ # Return a single "waiting" frame so the request completes gracefully
3730
+ import cv2
3731
+ import numpy as np
3732
+ placeholder = np.zeros((480, 640, 3), dtype=np.uint8)
3733
+ cv2.putText(placeholder, 'Browser Webcam Active', (120, 230), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 255, 0), 2)
3734
+ cv2.putText(placeholder, 'Frames via /api/live/process-frame', (80, 270), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (200, 200, 200), 1)
3735
+ ret, buffer = cv2.imencode('.jpg', placeholder)
3736
+ frame_bytes = buffer.tobytes() if ret else b''
3737
+ return Response(
3738
+ b'--frame\r\nContent-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n',
3739
+ mimetype='multipart/x-mixed-replace; boundary=frame',
3740
+ headers={'Cache-Control': 'no-cache', 'Access-Control-Allow-Origin': '*'}
3741
+ )
3742
+
3743
  logger.info(f"πŸ“Ή Video feed requested for camera {camera_id} (index {camera_index}, url={stream_url})")
3744
  logger.info(f"πŸ“Ή Processor is_processing: {processor.is_processing}")
3745
 
database_video_service.py CHANGED
@@ -557,16 +557,21 @@ class DatabaseIntegratedVideoService:
557
  logger.debug(traceback.format_exc())
558
 
559
  # Clean up temp file AFTER MongoDB save (not before)
560
- # Save to MongoDB
561
  try:
562
  # Ensure face_image_path is a string (not None) for schema validation
563
  if not face_data.get('face_image_path'):
564
  face_data['face_image_path'] = '' # Empty string is valid
565
 
566
  faces_collection = self.db_manager.db.detected_faces
567
- faces_collection.insert_one(face_data)
 
 
 
 
 
568
  face_results.append(face_data)
569
- logger.info(f"βœ… Saved face to MongoDB: {face_data['face_id']}")
570
  except Exception as e:
571
  logger.error(f"Failed to save face to MongoDB: {e}")
572
  import traceback
 
557
  logger.debug(traceback.format_exc())
558
 
559
  # Clean up temp file AFTER MongoDB save (not before)
560
+ # Save to MongoDB (upsert to avoid duplicates β€” facial_recognition.py may have already saved this face_id)
561
  try:
562
  # Ensure face_image_path is a string (not None) for schema validation
563
  if not face_data.get('face_image_path'):
564
  face_data['face_image_path'] = '' # Empty string is valid
565
 
566
  faces_collection = self.db_manager.db.detected_faces
567
+ # Use update_one with upsert to prevent double-inserts for the same face_id
568
+ faces_collection.update_one(
569
+ {'face_id': face_data['face_id'], 'video_id': face_data.get('video_id', '')},
570
+ {'$set': face_data},
571
+ upsert=True
572
+ )
573
  face_results.append(face_data)
574
+ logger.info(f"βœ… Saved face to MongoDB (upsert): {face_data['face_id']}")
575
  except Exception as e:
576
  logger.error(f"Failed to save face to MongoDB: {e}")
577
  import traceback
report_generation/data_collector.py CHANGED
@@ -392,13 +392,13 @@ class DataCollector:
392
  )
393
 
394
  face = {
395
- 'face_id': str(doc.get('_id', doc.get('face_id', ''))),
396
  'video_id': doc.get('video_id'),
397
  'timestamp': datetime.utcfromtimestamp(doc.get('timestamp')) if isinstance(doc.get('timestamp'), (int, float)) else doc.get('timestamp'),
398
  'frame_number': doc.get('frame_number', 0),
399
- 'confidence': doc.get('confidence', 0),
400
- 'bbox': doc.get('bbox', {}),
401
- 'person_id': doc.get('person_id'),
402
  'crop_path': crop_path if include_crops else None,
403
  'minio_path': minio_object_key or face_image_path,
404
  'crop_url': crop_url
 
392
  )
393
 
394
  face = {
395
+ 'face_id': doc.get('face_id', str(doc.get('_id', ''))), # Use actual face_id, not MongoDB _id
396
  'video_id': doc.get('video_id'),
397
  'timestamp': datetime.utcfromtimestamp(doc.get('timestamp')) if isinstance(doc.get('timestamp'), (int, float)) else doc.get('timestamp'),
398
  'frame_number': doc.get('frame_number', 0),
399
+ 'confidence': doc.get('confidence_score', doc.get('confidence', 0)), # confidence_score is the field name in DB
400
+ 'bbox': doc.get('bbox', doc.get('bounding_boxes', {})),
401
+ 'person_id': doc.get('person_name') or doc.get('person_id'), # person_name is the actual field
402
  'crop_path': crop_path if include_crops else None,
403
  'minio_path': minio_object_key or face_image_path,
404
  'crop_url': crop_url
report_generation/report_builder.py CHANGED
@@ -453,29 +453,53 @@ class ReportGenerator:
453
  content_parts.append("**No keyframes were captured for this video.**\n")
454
 
455
  # --- Face Detections subsection ---
456
- # Deduplicate faces: group by face_id, keep first occurrence per person
 
 
 
457
  if faces:
458
- seen_face_ids = {} # face_id -> first face dict
459
  for f in faces:
 
460
  fid = f.get('face_id', '')
461
- # Extract base identity from face_id
462
- # face_id format: face_{person}_{event}_{type}_{ts}_{idx}_{uuid}
463
- # Same person re-detected keeps the same face_id
464
- if fid and fid not in seen_face_ids:
465
- seen_face_ids[fid] = f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
 
467
- unique_faces = list(seen_face_ids.values())
468
- content_parts.append(f"\n### Face Detections ({len(unique_faces)} unique person(s) from {len(faces)} detections)\n")
469
- for i, f in enumerate(unique_faces[:10], 1):
 
 
470
  ts = f.get('timestamp')
471
  ts_str = ts.strftime('%H:%M:%S') if hasattr(ts, 'strftime') else str(ts or 'Unknown')
472
  conf = f.get('confidence', 0)
473
  person_id = f.get('person_id') or 'Unidentified'
474
  url = f.get('crop_url')
475
 
476
- # Count how many times this face_id appears
477
- fid = f.get('face_id', '')
478
- appearances = sum(1 for x in faces if x.get('face_id') == fid)
479
  appearance_text = f", seen {appearances}x" if appearances > 1 else ""
480
 
481
  content_parts.append(f"**Person {i}** β€” First seen at {ts_str} (confidence: {conf:.2f}, ID: {person_id}{appearance_text})")
 
453
  content_parts.append("**No keyframes were captured for this video.**\n")
454
 
455
  # --- Face Detections subsection ---
456
+ # Deduplicate faces by PERSON IDENTITY (person_id/person_name), not just face_id.
457
+ # face_id includes a UUID that differs per detection; same person may have
458
+ # multiple face_ids if FAISS didn't match them. Grouping by person_name or
459
+ # by the face_id prefix (person name part) gives true dedup.
460
  if faces:
461
+ person_groups = {} # dedup_key -> {'face': first_face_dict, 'count': N}
462
  for f in faces:
463
+ pid = f.get('person_id') or ''
464
  fid = f.get('face_id', '')
465
+
466
+ # Build a dedup key:
467
+ # 1) Named person β†’ use person name (e.g. "ahmed")
468
+ # 2) Unknown person β†’ extract prefix from face_id (face_{name}_event...)
469
+ # Two unknowns at different times are treated as separate persons
470
+ # UNLESS they share the same face_id (FAISS matched them).
471
+ if pid and pid.lower() not in ('unidentified', 'unknown', ''):
472
+ dedup_key = pid.strip().lower()
473
+ elif fid:
474
+ # Extract person-name portion: face_{name}_event_...
475
+ # e.g. face_unknown_event_obj_detection_1_000030_00_d127574f
476
+ parts = fid.split('_event_')
477
+ dedup_key = parts[0] if len(parts) > 1 else fid
478
+ # Further: collapse all "face_unknown" into unique entries by full face_id
479
+ if dedup_key == 'face_unknown':
480
+ dedup_key = fid # keep separate β€” can't know if same person
481
+ else:
482
+ dedup_key = f"anon_{id(f)}" # fallback, unique per entry
483
+
484
+ if dedup_key not in person_groups:
485
+ person_groups[dedup_key] = {'face': f, 'count': 1}
486
+ else:
487
+ person_groups[dedup_key]['count'] += 1
488
+ # Keep the one with the image/crop_url if the first one didn't have it
489
+ if not person_groups[dedup_key]['face'].get('crop_url') and f.get('crop_url'):
490
+ person_groups[dedup_key]['face'] = f
491
 
492
+ unique_persons = list(person_groups.values())
493
+ content_parts.append(f"\n### Face Detections ({len(unique_persons)} unique person(s) from {len(faces)} detections)\n")
494
+ for i, pg in enumerate(unique_persons[:10], 1):
495
+ f = pg['face']
496
+ appearances = pg['count']
497
  ts = f.get('timestamp')
498
  ts_str = ts.strftime('%H:%M:%S') if hasattr(ts, 'strftime') else str(ts or 'Unknown')
499
  conf = f.get('confidence', 0)
500
  person_id = f.get('person_id') or 'Unidentified'
501
  url = f.get('crop_url')
502
 
 
 
 
503
  appearance_text = f", seen {appearances}x" if appearances > 1 else ""
504
 
505
  content_parts.append(f"**Person {i}** β€” First seen at {ts_str} (confidence: {conf:.2f}, ID: {person_id}{appearance_text})")