Spaces:
Sleeping
Sleeping
Fix webcam browser mode, face dedup by person, confidence field, video proxy, upsert faces
Browse files- app.py +70 -54
- database_video_service.py +8 -3
- report_generation/data_collector.py +4 -4
- report_generation/report_builder.py +37 -13
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 |
-
"""
|
| 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"π
|
| 2417 |
|
| 2418 |
# Check if object exists first
|
| 2419 |
stat = minio_client.stat_object(bucket, minio_path)
|
| 2420 |
|
| 2421 |
-
|
| 2422 |
-
|
| 2423 |
-
|
| 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"π
|
| 2466 |
|
| 2467 |
# Check if original exists
|
| 2468 |
stat = minio_client.stat_object(bucket, original_path)
|
| 2469 |
|
| 2470 |
-
#
|
| 2471 |
-
|
| 2472 |
-
|
| 2473 |
-
|
| 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.
|
| 3653 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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':
|
| 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
|
|
|
|
|
|
|
|
|
|
| 457 |
if faces:
|
| 458 |
-
|
| 459 |
for f in faces:
|
|
|
|
| 460 |
fid = f.get('face_id', '')
|
| 461 |
-
|
| 462 |
-
#
|
| 463 |
-
#
|
| 464 |
-
|
| 465 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
|
| 467 |
-
|
| 468 |
-
content_parts.append(f"\n### Face Detections ({len(
|
| 469 |
-
for i,
|
|
|
|
|
|
|
| 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})")
|