Spaces:
Build error
Build error
Upload 22 files
Browse files- Dockerfile +26 -0
- app/__init__.py +49 -0
- app/__pycache__/__init__.cpython-310.pyc +0 -0
- app/__pycache__/models.cpython-310.pyc +0 -0
- app/models.py +71 -0
- app/routes/__init__.py +6 -0
- app/routes/__pycache__/__init__.cpython-310.pyc +0 -0
- app/routes/__pycache__/images.cpython-310.pyc +0 -0
- app/routes/__pycache__/people.cpython-310.pyc +0 -0
- app/routes/__pycache__/stats.cpython-310.pyc +0 -0
- app/routes/images.py +433 -0
- app/routes/people.py +219 -0
- app/routes/stats.py +110 -0
- app/services/__init__.py +12 -0
- app/services/__pycache__/__init__.cpython-310.pyc +0 -0
- app/services/__pycache__/cloudinary_service.cpython-310.pyc +0 -0
- app/services/__pycache__/face_recognition_service.cpython-310.pyc +0 -0
- app/services/cloudinary_service.py +97 -0
- app/services/face_recognition_service.py +155 -0
- config.py +46 -0
- requirements.txt +23 -0
- run.py +7 -0
Dockerfile
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# Install system dependencies for face_recognition
|
| 4 |
+
RUN apt-get update && apt-get install -y \
|
| 5 |
+
build-essential \
|
| 6 |
+
cmake \
|
| 7 |
+
libopenblas-dev \
|
| 8 |
+
liblapack-dev \
|
| 9 |
+
libx11-dev \
|
| 10 |
+
libgtk-3-dev \
|
| 11 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
+
|
| 13 |
+
WORKDIR /app
|
| 14 |
+
|
| 15 |
+
# Install Python dependencies
|
| 16 |
+
COPY requirements.txt .
|
| 17 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 18 |
+
|
| 19 |
+
# Copy application code
|
| 20 |
+
COPY . .
|
| 21 |
+
|
| 22 |
+
# Expose port
|
| 23 |
+
EXPOSE 5000
|
| 24 |
+
|
| 25 |
+
# Run with gunicorn
|
| 26 |
+
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--timeout", "120", "run:app"]
|
app/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Flask application factory."""
|
| 2 |
+
import os
|
| 3 |
+
from flask import Flask
|
| 4 |
+
from flask_cors import CORS
|
| 5 |
+
from flask_pymongo import PyMongo
|
| 6 |
+
|
| 7 |
+
from config import config
|
| 8 |
+
|
| 9 |
+
mongo = PyMongo()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def create_app(config_name=None):
|
| 13 |
+
"""Create and configure the Flask application."""
|
| 14 |
+
if config_name is None:
|
| 15 |
+
config_name = os.environ.get('FLASK_ENV', 'development')
|
| 16 |
+
|
| 17 |
+
app = Flask(__name__)
|
| 18 |
+
app.config.from_object(config[config_name])
|
| 19 |
+
|
| 20 |
+
# Initialize extensions
|
| 21 |
+
CORS(app, resources={
|
| 22 |
+
r"/api/*": {
|
| 23 |
+
"origins": ["http://localhost:3000", "http://localhost:5173"],
|
| 24 |
+
"methods": ["GET", "POST", "PUT", "DELETE", "PATCH"],
|
| 25 |
+
"allow_headers": ["Content-Type", "Authorization"]
|
| 26 |
+
}
|
| 27 |
+
})
|
| 28 |
+
|
| 29 |
+
mongo.init_app(app)
|
| 30 |
+
|
| 31 |
+
# Initialize Cloudinary
|
| 32 |
+
from app.services.cloudinary_service import init_cloudinary
|
| 33 |
+
init_cloudinary(app.config)
|
| 34 |
+
|
| 35 |
+
# Register blueprints
|
| 36 |
+
from app.routes.people import people_bp
|
| 37 |
+
from app.routes.images import images_bp
|
| 38 |
+
from app.routes.stats import stats_bp
|
| 39 |
+
|
| 40 |
+
app.register_blueprint(people_bp, url_prefix='/api/people')
|
| 41 |
+
app.register_blueprint(images_bp, url_prefix='/api/images')
|
| 42 |
+
app.register_blueprint(stats_bp, url_prefix='/api/stats')
|
| 43 |
+
|
| 44 |
+
# Health check route
|
| 45 |
+
@app.route('/api/health')
|
| 46 |
+
def health_check():
|
| 47 |
+
return {'status': 'healthy', 'message': 'Image Organizer API is running'}
|
| 48 |
+
|
| 49 |
+
return app
|
app/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (1.53 kB). View file
|
|
|
app/__pycache__/models.cpython-310.pyc
ADDED
|
Binary file (2.28 kB). View file
|
|
|
app/models.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Database models and schemas."""
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from bson import ObjectId
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class PersonModel:
|
| 7 |
+
"""Person model for MongoDB."""
|
| 8 |
+
|
| 9 |
+
@staticmethod
|
| 10 |
+
def create_person(name, face_encoding=None, thumbnail_url=None):
|
| 11 |
+
"""Create a new person document."""
|
| 12 |
+
return {
|
| 13 |
+
'name': name,
|
| 14 |
+
'face_encodings': [face_encoding] if face_encoding else [],
|
| 15 |
+
'thumbnail_url': thumbnail_url,
|
| 16 |
+
'image_count': 0,
|
| 17 |
+
'created_at': datetime.utcnow(),
|
| 18 |
+
'updated_at': datetime.utcnow()
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
@staticmethod
|
| 22 |
+
def to_response(person):
|
| 23 |
+
"""Convert person document to API response."""
|
| 24 |
+
return {
|
| 25 |
+
'id': str(person['_id']),
|
| 26 |
+
'name': person['name'],
|
| 27 |
+
'thumbnail_url': person.get('thumbnail_url'),
|
| 28 |
+
'image_count': person.get('image_count', 0),
|
| 29 |
+
'created_at': person['created_at'].isoformat() if person.get('created_at') else None,
|
| 30 |
+
'updated_at': person['updated_at'].isoformat() if person.get('updated_at') else None
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class ImageModel:
|
| 35 |
+
"""Image model for MongoDB."""
|
| 36 |
+
|
| 37 |
+
@staticmethod
|
| 38 |
+
def create_image(cloudinary_url, cloudinary_public_id, original_filename,
|
| 39 |
+
face_encodings=None, person_id=None, face_locations=None):
|
| 40 |
+
"""Create a new image document."""
|
| 41 |
+
return {
|
| 42 |
+
'cloudinary_url': cloudinary_url,
|
| 43 |
+
'cloudinary_public_id': cloudinary_public_id,
|
| 44 |
+
'original_filename': original_filename,
|
| 45 |
+
'face_encodings': face_encodings or [],
|
| 46 |
+
'face_locations': face_locations or [],
|
| 47 |
+
'person_id': ObjectId(person_id) if person_id else None,
|
| 48 |
+
'has_face': bool(face_encodings and len(face_encodings) > 0),
|
| 49 |
+
'is_identified': person_id is not None,
|
| 50 |
+
'created_at': datetime.utcnow(),
|
| 51 |
+
'updated_at': datetime.utcnow()
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
@staticmethod
|
| 55 |
+
def to_response(image, include_person=False):
|
| 56 |
+
"""Convert image document to API response."""
|
| 57 |
+
response = {
|
| 58 |
+
'id': str(image['_id']),
|
| 59 |
+
'url': image['cloudinary_url'],
|
| 60 |
+
'original_filename': image.get('original_filename'),
|
| 61 |
+
'has_face': image.get('has_face', False),
|
| 62 |
+
'is_identified': image.get('is_identified', False),
|
| 63 |
+
'face_count': len(image.get('face_locations', [])),
|
| 64 |
+
'person_id': str(image['person_id']) if image.get('person_id') else None,
|
| 65 |
+
'created_at': image['created_at'].isoformat() if image.get('created_at') else None
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
if include_person and image.get('person'):
|
| 69 |
+
response['person'] = PersonModel.to_response(image['person'])
|
| 70 |
+
|
| 71 |
+
return response
|
app/routes/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Routes package."""
|
| 2 |
+
from .people import people_bp
|
| 3 |
+
from .images import images_bp
|
| 4 |
+
from .stats import stats_bp
|
| 5 |
+
|
| 6 |
+
__all__ = ['people_bp', 'images_bp', 'stats_bp']
|
app/routes/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (344 Bytes). View file
|
|
|
app/routes/__pycache__/images.cpython-310.pyc
ADDED
|
Binary file (8.66 kB). View file
|
|
|
app/routes/__pycache__/people.cpython-310.pyc
ADDED
|
Binary file (5.54 kB). View file
|
|
|
app/routes/__pycache__/stats.cpython-310.pyc
ADDED
|
Binary file (3.3 kB). View file
|
|
|
app/routes/images.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Image management routes."""
|
| 2 |
+
from flask import Blueprint, request, jsonify, current_app
|
| 3 |
+
from bson import ObjectId
|
| 4 |
+
from bson.errors import InvalidId
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from werkzeug.utils import secure_filename
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
from app import mongo
|
| 10 |
+
from app.models import ImageModel, PersonModel
|
| 11 |
+
from app.services.cloudinary_service import upload_image, delete_image, get_thumbnail_url
|
| 12 |
+
from app.services.face_recognition_service import get_face_service
|
| 13 |
+
|
| 14 |
+
images_bp = Blueprint('images', __name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def allowed_file(filename):
|
| 18 |
+
"""Check if file extension is allowed."""
|
| 19 |
+
allowed_extensions = current_app.config.get('ALLOWED_EXTENSIONS', {'png', 'jpg', 'jpeg', 'gif', 'webp'})
|
| 20 |
+
return '.' in filename and filename.rsplit('.', 1)[1].lower() in allowed_extensions
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@images_bp.route('', methods=['GET'])
|
| 24 |
+
def get_images():
|
| 25 |
+
"""Get all images with filtering options."""
|
| 26 |
+
try:
|
| 27 |
+
page = int(request.args.get('page', 1))
|
| 28 |
+
per_page = int(request.args.get('per_page', 20))
|
| 29 |
+
filter_type = request.args.get('filter', 'all') # all, identified, unidentified
|
| 30 |
+
|
| 31 |
+
skip = (page - 1) * per_page
|
| 32 |
+
|
| 33 |
+
# Build query based on filter
|
| 34 |
+
query = {}
|
| 35 |
+
if filter_type == 'identified':
|
| 36 |
+
query['is_identified'] = True
|
| 37 |
+
elif filter_type == 'unidentified':
|
| 38 |
+
query['has_face'] = True
|
| 39 |
+
query['is_identified'] = False
|
| 40 |
+
|
| 41 |
+
# Get images with pagination
|
| 42 |
+
images = list(
|
| 43 |
+
mongo.db.images.find(query)
|
| 44 |
+
.sort('created_at', -1)
|
| 45 |
+
.skip(skip)
|
| 46 |
+
.limit(per_page)
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# Get person info for identified images
|
| 50 |
+
person_ids = [img['person_id'] for img in images if img.get('person_id')]
|
| 51 |
+
people = {str(p['_id']): p for p in mongo.db.people.find({'_id': {'$in': person_ids}})}
|
| 52 |
+
|
| 53 |
+
# Attach person to images
|
| 54 |
+
for img in images:
|
| 55 |
+
if img.get('person_id') and str(img['person_id']) in people:
|
| 56 |
+
img['person'] = people[str(img['person_id'])]
|
| 57 |
+
|
| 58 |
+
total = mongo.db.images.count_documents(query)
|
| 59 |
+
|
| 60 |
+
return jsonify({
|
| 61 |
+
'success': True,
|
| 62 |
+
'data': [ImageModel.to_response(img, include_person=True) for img in images],
|
| 63 |
+
'pagination': {
|
| 64 |
+
'page': page,
|
| 65 |
+
'per_page': per_page,
|
| 66 |
+
'total': total,
|
| 67 |
+
'pages': (total + per_page - 1) // per_page
|
| 68 |
+
}
|
| 69 |
+
})
|
| 70 |
+
|
| 71 |
+
except Exception as e:
|
| 72 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@images_bp.route('/<image_id>', methods=['GET'])
|
| 76 |
+
def get_image(image_id):
|
| 77 |
+
"""Get a specific image by ID."""
|
| 78 |
+
try:
|
| 79 |
+
image = mongo.db.images.find_one({'_id': ObjectId(image_id)})
|
| 80 |
+
|
| 81 |
+
if not image:
|
| 82 |
+
return jsonify({'success': False, 'error': 'Image not found'}), 404
|
| 83 |
+
|
| 84 |
+
# Get person info if assigned
|
| 85 |
+
if image.get('person_id'):
|
| 86 |
+
person = mongo.db.people.find_one({'_id': image['person_id']})
|
| 87 |
+
if person:
|
| 88 |
+
image['person'] = person
|
| 89 |
+
|
| 90 |
+
return jsonify({
|
| 91 |
+
'success': True,
|
| 92 |
+
'data': ImageModel.to_response(image, include_person=True)
|
| 93 |
+
})
|
| 94 |
+
|
| 95 |
+
except InvalidId:
|
| 96 |
+
return jsonify({'success': False, 'error': 'Invalid image ID'}), 400
|
| 97 |
+
except Exception as e:
|
| 98 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@images_bp.route('/upload', methods=['POST'])
|
| 102 |
+
def upload_images():
|
| 103 |
+
"""Upload one or more images with face detection."""
|
| 104 |
+
try:
|
| 105 |
+
if 'files' not in request.files:
|
| 106 |
+
return jsonify({'success': False, 'error': 'No files provided'}), 400
|
| 107 |
+
|
| 108 |
+
files = request.files.getlist('files')
|
| 109 |
+
|
| 110 |
+
if not files or all(f.filename == '' for f in files):
|
| 111 |
+
return jsonify({'success': False, 'error': 'No files selected'}), 400
|
| 112 |
+
|
| 113 |
+
face_service = get_face_service(current_app.config.get('FACE_RECOGNITION_TOLERANCE', 0.6))
|
| 114 |
+
people = list(mongo.db.people.find({'face_encodings': {'$ne': []}}))
|
| 115 |
+
|
| 116 |
+
results = []
|
| 117 |
+
|
| 118 |
+
for file in files:
|
| 119 |
+
if file and file.filename:
|
| 120 |
+
if not allowed_file(file.filename):
|
| 121 |
+
results.append({
|
| 122 |
+
'filename': file.filename,
|
| 123 |
+
'success': False,
|
| 124 |
+
'error': 'File type not allowed'
|
| 125 |
+
})
|
| 126 |
+
continue
|
| 127 |
+
|
| 128 |
+
original_filename = secure_filename(file.filename)
|
| 129 |
+
|
| 130 |
+
# Read file data for both upload and face detection
|
| 131 |
+
file_data = file.read()
|
| 132 |
+
|
| 133 |
+
# Upload to Cloudinary
|
| 134 |
+
upload_result = upload_image(file_data)
|
| 135 |
+
|
| 136 |
+
if not upload_result['success']:
|
| 137 |
+
results.append({
|
| 138 |
+
'filename': original_filename,
|
| 139 |
+
'success': False,
|
| 140 |
+
'error': upload_result.get('error', 'Upload failed')
|
| 141 |
+
})
|
| 142 |
+
continue
|
| 143 |
+
|
| 144 |
+
# Detect faces
|
| 145 |
+
face_result = face_service.detect_faces(file_data)
|
| 146 |
+
|
| 147 |
+
face_encodings = face_result.get('face_encodings', [])
|
| 148 |
+
face_locations = face_result.get('face_locations', [])
|
| 149 |
+
|
| 150 |
+
# Try to match faces to existing people
|
| 151 |
+
matched_person_id = None
|
| 152 |
+
if face_encodings:
|
| 153 |
+
for encoding in face_encodings:
|
| 154 |
+
matched_person_id = face_service.find_matching_person(encoding, people)
|
| 155 |
+
if matched_person_id:
|
| 156 |
+
break
|
| 157 |
+
|
| 158 |
+
# Create image document
|
| 159 |
+
image_doc = ImageModel.create_image(
|
| 160 |
+
cloudinary_url=upload_result['url'],
|
| 161 |
+
cloudinary_public_id=upload_result['public_id'],
|
| 162 |
+
original_filename=original_filename,
|
| 163 |
+
face_encodings=face_encodings,
|
| 164 |
+
face_locations=face_locations,
|
| 165 |
+
person_id=str(matched_person_id) if matched_person_id else None
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
result = mongo.db.images.insert_one(image_doc)
|
| 169 |
+
image_doc['_id'] = result.inserted_id
|
| 170 |
+
|
| 171 |
+
# Update person's image count and thumbnail if matched
|
| 172 |
+
if matched_person_id:
|
| 173 |
+
person = mongo.db.people.find_one({'_id': matched_person_id})
|
| 174 |
+
update_data = {
|
| 175 |
+
'image_count': person.get('image_count', 0) + 1,
|
| 176 |
+
'updated_at': datetime.utcnow()
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
# Set thumbnail if not set
|
| 180 |
+
if not person.get('thumbnail_url'):
|
| 181 |
+
update_data['thumbnail_url'] = get_thumbnail_url(upload_result['url'])
|
| 182 |
+
|
| 183 |
+
# Add face encoding to person if new
|
| 184 |
+
if face_encodings:
|
| 185 |
+
mongo.db.people.update_one(
|
| 186 |
+
{'_id': matched_person_id},
|
| 187 |
+
{
|
| 188 |
+
'$set': update_data,
|
| 189 |
+
'$addToSet': {'face_encodings': face_encodings[0]}
|
| 190 |
+
}
|
| 191 |
+
)
|
| 192 |
+
else:
|
| 193 |
+
mongo.db.people.update_one(
|
| 194 |
+
{'_id': matched_person_id},
|
| 195 |
+
{'$set': update_data}
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
results.append({
|
| 199 |
+
'filename': original_filename,
|
| 200 |
+
'success': True,
|
| 201 |
+
'image': ImageModel.to_response(image_doc),
|
| 202 |
+
'faces_detected': len(face_encodings),
|
| 203 |
+
'matched_person': str(matched_person_id) if matched_person_id else None
|
| 204 |
+
})
|
| 205 |
+
|
| 206 |
+
successful = sum(1 for r in results if r['success'])
|
| 207 |
+
|
| 208 |
+
return jsonify({
|
| 209 |
+
'success': True,
|
| 210 |
+
'message': f'Uploaded {successful} of {len(results)} images',
|
| 211 |
+
'results': results
|
| 212 |
+
})
|
| 213 |
+
|
| 214 |
+
except Exception as e:
|
| 215 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
@images_bp.route('/<image_id>', methods=['DELETE'])
|
| 219 |
+
def delete_image_route(image_id):
|
| 220 |
+
"""Delete an image."""
|
| 221 |
+
try:
|
| 222 |
+
image = mongo.db.images.find_one({'_id': ObjectId(image_id)})
|
| 223 |
+
|
| 224 |
+
if not image:
|
| 225 |
+
return jsonify({'success': False, 'error': 'Image not found'}), 404
|
| 226 |
+
|
| 227 |
+
# Delete from Cloudinary
|
| 228 |
+
cloudinary_result = delete_image(image.get('cloudinary_public_id'))
|
| 229 |
+
|
| 230 |
+
# Update person's image count
|
| 231 |
+
if image.get('person_id'):
|
| 232 |
+
mongo.db.people.update_one(
|
| 233 |
+
{'_id': image['person_id']},
|
| 234 |
+
{
|
| 235 |
+
'$inc': {'image_count': -1},
|
| 236 |
+
'$set': {'updated_at': datetime.utcnow()}
|
| 237 |
+
}
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
# Delete from database
|
| 241 |
+
mongo.db.images.delete_one({'_id': ObjectId(image_id)})
|
| 242 |
+
|
| 243 |
+
return jsonify({
|
| 244 |
+
'success': True,
|
| 245 |
+
'message': 'Image deleted successfully'
|
| 246 |
+
})
|
| 247 |
+
|
| 248 |
+
except InvalidId:
|
| 249 |
+
return jsonify({'success': False, 'error': 'Invalid image ID'}), 400
|
| 250 |
+
except Exception as e:
|
| 251 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
@images_bp.route('/<image_id>/assign', methods=['PATCH'])
|
| 255 |
+
def assign_image(image_id):
|
| 256 |
+
"""Assign or reassign an image to a person."""
|
| 257 |
+
try:
|
| 258 |
+
data = request.get_json()
|
| 259 |
+
person_id = data.get('person_id')
|
| 260 |
+
|
| 261 |
+
image = mongo.db.images.find_one({'_id': ObjectId(image_id)})
|
| 262 |
+
if not image:
|
| 263 |
+
return jsonify({'success': False, 'error': 'Image not found'}), 404
|
| 264 |
+
|
| 265 |
+
old_person_id = image.get('person_id')
|
| 266 |
+
|
| 267 |
+
# Handle unassignment
|
| 268 |
+
if not person_id:
|
| 269 |
+
mongo.db.images.update_one(
|
| 270 |
+
{'_id': ObjectId(image_id)},
|
| 271 |
+
{'$set': {
|
| 272 |
+
'person_id': None,
|
| 273 |
+
'is_identified': False,
|
| 274 |
+
'updated_at': datetime.utcnow()
|
| 275 |
+
}}
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
# Update old person's count
|
| 279 |
+
if old_person_id:
|
| 280 |
+
mongo.db.people.update_one(
|
| 281 |
+
{'_id': old_person_id},
|
| 282 |
+
{
|
| 283 |
+
'$inc': {'image_count': -1},
|
| 284 |
+
'$set': {'updated_at': datetime.utcnow()}
|
| 285 |
+
}
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
return jsonify({
|
| 289 |
+
'success': True,
|
| 290 |
+
'message': 'Image unassigned successfully'
|
| 291 |
+
})
|
| 292 |
+
|
| 293 |
+
# Verify person exists
|
| 294 |
+
person = mongo.db.people.find_one({'_id': ObjectId(person_id)})
|
| 295 |
+
if not person:
|
| 296 |
+
return jsonify({'success': False, 'error': 'Person not found'}), 404
|
| 297 |
+
|
| 298 |
+
# Update image
|
| 299 |
+
mongo.db.images.update_one(
|
| 300 |
+
{'_id': ObjectId(image_id)},
|
| 301 |
+
{'$set': {
|
| 302 |
+
'person_id': ObjectId(person_id),
|
| 303 |
+
'is_identified': True,
|
| 304 |
+
'updated_at': datetime.utcnow()
|
| 305 |
+
}}
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
# Update old person's count
|
| 309 |
+
if old_person_id and old_person_id != ObjectId(person_id):
|
| 310 |
+
mongo.db.people.update_one(
|
| 311 |
+
{'_id': old_person_id},
|
| 312 |
+
{
|
| 313 |
+
'$inc': {'image_count': -1},
|
| 314 |
+
'$set': {'updated_at': datetime.utcnow()}
|
| 315 |
+
}
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
# Update new person's count and face encoding
|
| 319 |
+
update_ops = {
|
| 320 |
+
'$set': {'updated_at': datetime.utcnow()}
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
if old_person_id != ObjectId(person_id):
|
| 324 |
+
update_ops['$inc'] = {'image_count': 1}
|
| 325 |
+
|
| 326 |
+
# Add face encoding to help future matching
|
| 327 |
+
if image.get('face_encodings') and len(image['face_encodings']) > 0:
|
| 328 |
+
mongo.db.people.update_one(
|
| 329 |
+
{'_id': ObjectId(person_id)},
|
| 330 |
+
{
|
| 331 |
+
**update_ops,
|
| 332 |
+
'$addToSet': {'face_encodings': image['face_encodings'][0]}
|
| 333 |
+
}
|
| 334 |
+
)
|
| 335 |
+
else:
|
| 336 |
+
mongo.db.people.update_one(
|
| 337 |
+
{'_id': ObjectId(person_id)},
|
| 338 |
+
update_ops
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
# Set thumbnail if not set
|
| 342 |
+
if not person.get('thumbnail_url'):
|
| 343 |
+
mongo.db.people.update_one(
|
| 344 |
+
{'_id': ObjectId(person_id)},
|
| 345 |
+
{'$set': {'thumbnail_url': get_thumbnail_url(image['cloudinary_url'])}}
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
return jsonify({
|
| 349 |
+
'success': True,
|
| 350 |
+
'message': f'Image assigned to {person["name"]}'
|
| 351 |
+
})
|
| 352 |
+
|
| 353 |
+
except InvalidId:
|
| 354 |
+
return jsonify({'success': False, 'error': 'Invalid ID'}), 400
|
| 355 |
+
except Exception as e:
|
| 356 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
@images_bp.route('/<image_id>/reprocess', methods=['POST'])
|
| 360 |
+
def reprocess_image(image_id):
|
| 361 |
+
"""Reprocess an image to re-detect faces."""
|
| 362 |
+
try:
|
| 363 |
+
image = mongo.db.images.find_one({'_id': ObjectId(image_id)})
|
| 364 |
+
|
| 365 |
+
if not image:
|
| 366 |
+
return jsonify({'success': False, 'error': 'Image not found'}), 404
|
| 367 |
+
|
| 368 |
+
# Download image from Cloudinary and reprocess
|
| 369 |
+
import requests
|
| 370 |
+
from io import BytesIO
|
| 371 |
+
|
| 372 |
+
response = requests.get(image['cloudinary_url'])
|
| 373 |
+
if response.status_code != 200:
|
| 374 |
+
return jsonify({'success': False, 'error': 'Failed to download image'}), 500
|
| 375 |
+
|
| 376 |
+
face_service = get_face_service(current_app.config.get('FACE_RECOGNITION_TOLERANCE', 0.6))
|
| 377 |
+
face_result = face_service.detect_faces(response.content)
|
| 378 |
+
|
| 379 |
+
face_encodings = face_result.get('face_encodings', [])
|
| 380 |
+
face_locations = face_result.get('face_locations', [])
|
| 381 |
+
|
| 382 |
+
# Try to match faces
|
| 383 |
+
matched_person_id = None
|
| 384 |
+
if face_encodings:
|
| 385 |
+
people = list(mongo.db.people.find({'face_encodings': {'$ne': []}}))
|
| 386 |
+
for encoding in face_encodings:
|
| 387 |
+
matched_person_id = face_service.find_matching_person(encoding, people)
|
| 388 |
+
if matched_person_id:
|
| 389 |
+
break
|
| 390 |
+
|
| 391 |
+
# Update image
|
| 392 |
+
old_person_id = image.get('person_id')
|
| 393 |
+
|
| 394 |
+
update_data = {
|
| 395 |
+
'face_encodings': face_encodings,
|
| 396 |
+
'face_locations': face_locations,
|
| 397 |
+
'has_face': bool(face_encodings),
|
| 398 |
+
'updated_at': datetime.utcnow()
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
if matched_person_id:
|
| 402 |
+
update_data['person_id'] = matched_person_id
|
| 403 |
+
update_data['is_identified'] = True
|
| 404 |
+
|
| 405 |
+
mongo.db.images.update_one(
|
| 406 |
+
{'_id': ObjectId(image_id)},
|
| 407 |
+
{'$set': update_data}
|
| 408 |
+
)
|
| 409 |
+
|
| 410 |
+
# Update person counts
|
| 411 |
+
if old_person_id and old_person_id != matched_person_id:
|
| 412 |
+
mongo.db.people.update_one(
|
| 413 |
+
{'_id': old_person_id},
|
| 414 |
+
{'$inc': {'image_count': -1}}
|
| 415 |
+
)
|
| 416 |
+
|
| 417 |
+
if matched_person_id and old_person_id != matched_person_id:
|
| 418 |
+
mongo.db.people.update_one(
|
| 419 |
+
{'_id': matched_person_id},
|
| 420 |
+
{'$inc': {'image_count': 1}}
|
| 421 |
+
)
|
| 422 |
+
|
| 423 |
+
return jsonify({
|
| 424 |
+
'success': True,
|
| 425 |
+
'message': f'Reprocessed image. Found {len(face_encodings)} face(s).',
|
| 426 |
+
'faces_detected': len(face_encodings),
|
| 427 |
+
'matched_person': str(matched_person_id) if matched_person_id else None
|
| 428 |
+
})
|
| 429 |
+
|
| 430 |
+
except InvalidId:
|
| 431 |
+
return jsonify({'success': False, 'error': 'Invalid image ID'}), 400
|
| 432 |
+
except Exception as e:
|
| 433 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
app/routes/people.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""People management routes."""
|
| 2 |
+
from flask import Blueprint, request, jsonify
|
| 3 |
+
from bson import ObjectId
|
| 4 |
+
from bson.errors import InvalidId
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
from app import mongo
|
| 8 |
+
from app.models import PersonModel, ImageModel
|
| 9 |
+
from app.services.cloudinary_service import get_thumbnail_url
|
| 10 |
+
|
| 11 |
+
people_bp = Blueprint('people', __name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@people_bp.route('', methods=['GET'])
|
| 15 |
+
def get_people():
|
| 16 |
+
"""Get all people with optional search."""
|
| 17 |
+
try:
|
| 18 |
+
search = request.args.get('search', '').strip()
|
| 19 |
+
sort_by = request.args.get('sort', 'name')
|
| 20 |
+
order = request.args.get('order', 'asc')
|
| 21 |
+
|
| 22 |
+
# Build query
|
| 23 |
+
query = {}
|
| 24 |
+
if search:
|
| 25 |
+
query['name'] = {'$regex': search, '$options': 'i'}
|
| 26 |
+
|
| 27 |
+
# Sort options
|
| 28 |
+
sort_order = 1 if order == 'asc' else -1
|
| 29 |
+
sort_field = sort_by if sort_by in ['name', 'created_at', 'image_count'] else 'name'
|
| 30 |
+
|
| 31 |
+
people = list(mongo.db.people.find(query).sort(sort_field, sort_order))
|
| 32 |
+
|
| 33 |
+
return jsonify({
|
| 34 |
+
'success': True,
|
| 35 |
+
'data': [PersonModel.to_response(p) for p in people],
|
| 36 |
+
'total': len(people)
|
| 37 |
+
})
|
| 38 |
+
|
| 39 |
+
except Exception as e:
|
| 40 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@people_bp.route('/<person_id>', methods=['GET'])
|
| 44 |
+
def get_person(person_id):
|
| 45 |
+
"""Get a specific person by ID."""
|
| 46 |
+
try:
|
| 47 |
+
person = mongo.db.people.find_one({'_id': ObjectId(person_id)})
|
| 48 |
+
|
| 49 |
+
if not person:
|
| 50 |
+
return jsonify({'success': False, 'error': 'Person not found'}), 404
|
| 51 |
+
|
| 52 |
+
return jsonify({
|
| 53 |
+
'success': True,
|
| 54 |
+
'data': PersonModel.to_response(person)
|
| 55 |
+
})
|
| 56 |
+
|
| 57 |
+
except InvalidId:
|
| 58 |
+
return jsonify({'success': False, 'error': 'Invalid person ID'}), 400
|
| 59 |
+
except Exception as e:
|
| 60 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@people_bp.route('', methods=['POST'])
|
| 64 |
+
def create_person():
|
| 65 |
+
"""Create a new person."""
|
| 66 |
+
try:
|
| 67 |
+
data = request.get_json()
|
| 68 |
+
|
| 69 |
+
if not data or not data.get('name'):
|
| 70 |
+
return jsonify({'success': False, 'error': 'Name is required'}), 400
|
| 71 |
+
|
| 72 |
+
name = data['name'].strip()
|
| 73 |
+
|
| 74 |
+
# Check if person with same name exists
|
| 75 |
+
existing = mongo.db.people.find_one({'name': {'$regex': f'^{name}$', '$options': 'i'}})
|
| 76 |
+
if existing:
|
| 77 |
+
return jsonify({'success': False, 'error': 'A person with this name already exists'}), 400
|
| 78 |
+
|
| 79 |
+
person = PersonModel.create_person(name)
|
| 80 |
+
result = mongo.db.people.insert_one(person)
|
| 81 |
+
|
| 82 |
+
person['_id'] = result.inserted_id
|
| 83 |
+
|
| 84 |
+
return jsonify({
|
| 85 |
+
'success': True,
|
| 86 |
+
'message': 'Person created successfully',
|
| 87 |
+
'data': PersonModel.to_response(person)
|
| 88 |
+
}), 201
|
| 89 |
+
|
| 90 |
+
except Exception as e:
|
| 91 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@people_bp.route('/<person_id>', methods=['PUT'])
|
| 95 |
+
def update_person(person_id):
|
| 96 |
+
"""Update a person's details."""
|
| 97 |
+
try:
|
| 98 |
+
data = request.get_json()
|
| 99 |
+
|
| 100 |
+
if not data:
|
| 101 |
+
return jsonify({'success': False, 'error': 'No data provided'}), 400
|
| 102 |
+
|
| 103 |
+
person = mongo.db.people.find_one({'_id': ObjectId(person_id)})
|
| 104 |
+
if not person:
|
| 105 |
+
return jsonify({'success': False, 'error': 'Person not found'}), 404
|
| 106 |
+
|
| 107 |
+
update_data = {'updated_at': datetime.utcnow()}
|
| 108 |
+
|
| 109 |
+
if 'name' in data:
|
| 110 |
+
name = data['name'].strip()
|
| 111 |
+
if not name:
|
| 112 |
+
return jsonify({'success': False, 'error': 'Name cannot be empty'}), 400
|
| 113 |
+
|
| 114 |
+
# Check for duplicate name
|
| 115 |
+
existing = mongo.db.people.find_one({
|
| 116 |
+
'name': {'$regex': f'^{name}$', '$options': 'i'},
|
| 117 |
+
'_id': {'$ne': ObjectId(person_id)}
|
| 118 |
+
})
|
| 119 |
+
if existing:
|
| 120 |
+
return jsonify({'success': False, 'error': 'A person with this name already exists'}), 400
|
| 121 |
+
|
| 122 |
+
update_data['name'] = name
|
| 123 |
+
|
| 124 |
+
mongo.db.people.update_one(
|
| 125 |
+
{'_id': ObjectId(person_id)},
|
| 126 |
+
{'$set': update_data}
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
updated_person = mongo.db.people.find_one({'_id': ObjectId(person_id)})
|
| 130 |
+
|
| 131 |
+
return jsonify({
|
| 132 |
+
'success': True,
|
| 133 |
+
'message': 'Person updated successfully',
|
| 134 |
+
'data': PersonModel.to_response(updated_person)
|
| 135 |
+
})
|
| 136 |
+
|
| 137 |
+
except InvalidId:
|
| 138 |
+
return jsonify({'success': False, 'error': 'Invalid person ID'}), 400
|
| 139 |
+
except Exception as e:
|
| 140 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@people_bp.route('/<person_id>', methods=['DELETE'])
|
| 144 |
+
def delete_person(person_id):
|
| 145 |
+
"""Delete a person and optionally their images."""
|
| 146 |
+
try:
|
| 147 |
+
delete_images = request.args.get('delete_images', 'false').lower() == 'true'
|
| 148 |
+
|
| 149 |
+
person = mongo.db.people.find_one({'_id': ObjectId(person_id)})
|
| 150 |
+
if not person:
|
| 151 |
+
return jsonify({'success': False, 'error': 'Person not found'}), 404
|
| 152 |
+
|
| 153 |
+
if delete_images:
|
| 154 |
+
# Delete all images associated with this person
|
| 155 |
+
from app.services.cloudinary_service import delete_image
|
| 156 |
+
|
| 157 |
+
images = mongo.db.images.find({'person_id': ObjectId(person_id)})
|
| 158 |
+
for image in images:
|
| 159 |
+
delete_image(image.get('cloudinary_public_id'))
|
| 160 |
+
|
| 161 |
+
mongo.db.images.delete_many({'person_id': ObjectId(person_id)})
|
| 162 |
+
else:
|
| 163 |
+
# Just unassign images from this person
|
| 164 |
+
mongo.db.images.update_many(
|
| 165 |
+
{'person_id': ObjectId(person_id)},
|
| 166 |
+
{'$set': {'person_id': None, 'is_identified': False, 'updated_at': datetime.utcnow()}}
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
# Delete the person
|
| 170 |
+
mongo.db.people.delete_one({'_id': ObjectId(person_id)})
|
| 171 |
+
|
| 172 |
+
return jsonify({
|
| 173 |
+
'success': True,
|
| 174 |
+
'message': f'Person deleted successfully. Images {"deleted" if delete_images else "unassigned"}.'
|
| 175 |
+
})
|
| 176 |
+
|
| 177 |
+
except InvalidId:
|
| 178 |
+
return jsonify({'success': False, 'error': 'Invalid person ID'}), 400
|
| 179 |
+
except Exception as e:
|
| 180 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
@people_bp.route('/<person_id>/images', methods=['GET'])
|
| 184 |
+
def get_person_images(person_id):
|
| 185 |
+
"""Get all images for a specific person."""
|
| 186 |
+
try:
|
| 187 |
+
person = mongo.db.people.find_one({'_id': ObjectId(person_id)})
|
| 188 |
+
if not person:
|
| 189 |
+
return jsonify({'success': False, 'error': 'Person not found'}), 404
|
| 190 |
+
|
| 191 |
+
page = int(request.args.get('page', 1))
|
| 192 |
+
per_page = int(request.args.get('per_page', 20))
|
| 193 |
+
skip = (page - 1) * per_page
|
| 194 |
+
|
| 195 |
+
images = list(
|
| 196 |
+
mongo.db.images.find({'person_id': ObjectId(person_id)})
|
| 197 |
+
.sort('created_at', -1)
|
| 198 |
+
.skip(skip)
|
| 199 |
+
.limit(per_page)
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
total = mongo.db.images.count_documents({'person_id': ObjectId(person_id)})
|
| 203 |
+
|
| 204 |
+
return jsonify({
|
| 205 |
+
'success': True,
|
| 206 |
+
'data': [ImageModel.to_response(img) for img in images],
|
| 207 |
+
'person': PersonModel.to_response(person),
|
| 208 |
+
'pagination': {
|
| 209 |
+
'page': page,
|
| 210 |
+
'per_page': per_page,
|
| 211 |
+
'total': total,
|
| 212 |
+
'pages': (total + per_page - 1) // per_page
|
| 213 |
+
}
|
| 214 |
+
})
|
| 215 |
+
|
| 216 |
+
except InvalidId:
|
| 217 |
+
return jsonify({'success': False, 'error': 'Invalid person ID'}), 400
|
| 218 |
+
except Exception as e:
|
| 219 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
app/routes/stats.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Statistics and dashboard routes."""
|
| 2 |
+
from flask import Blueprint, jsonify
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
|
| 5 |
+
from app import mongo
|
| 6 |
+
from app.models import PersonModel, ImageModel
|
| 7 |
+
|
| 8 |
+
stats_bp = Blueprint('stats', __name__)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@stats_bp.route('', methods=['GET'])
|
| 12 |
+
def get_stats():
|
| 13 |
+
"""Get dashboard statistics."""
|
| 14 |
+
try:
|
| 15 |
+
# Count totals
|
| 16 |
+
total_images = mongo.db.images.count_documents({})
|
| 17 |
+
total_people = mongo.db.people.count_documents({})
|
| 18 |
+
identified_images = mongo.db.images.count_documents({'is_identified': True})
|
| 19 |
+
unidentified_faces = mongo.db.images.count_documents({
|
| 20 |
+
'has_face': True,
|
| 21 |
+
'is_identified': False
|
| 22 |
+
})
|
| 23 |
+
|
| 24 |
+
# Calculate percentages
|
| 25 |
+
identification_rate = (identified_images / total_images * 100) if total_images > 0 else 0
|
| 26 |
+
|
| 27 |
+
return jsonify({
|
| 28 |
+
'success': True,
|
| 29 |
+
'data': {
|
| 30 |
+
'total_images': total_images,
|
| 31 |
+
'total_people': total_people,
|
| 32 |
+
'identified_images': identified_images,
|
| 33 |
+
'unidentified_faces': unidentified_faces,
|
| 34 |
+
'identification_rate': round(identification_rate, 1)
|
| 35 |
+
}
|
| 36 |
+
})
|
| 37 |
+
|
| 38 |
+
except Exception as e:
|
| 39 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@stats_bp.route('/recent', methods=['GET'])
|
| 43 |
+
def get_recent():
|
| 44 |
+
"""Get recent uploads."""
|
| 45 |
+
try:
|
| 46 |
+
# Get recent images (last 7 days)
|
| 47 |
+
week_ago = datetime.utcnow() - timedelta(days=7)
|
| 48 |
+
|
| 49 |
+
recent_images = list(
|
| 50 |
+
mongo.db.images.find({'created_at': {'$gte': week_ago}})
|
| 51 |
+
.sort('created_at', -1)
|
| 52 |
+
.limit(12)
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
# Get person info for identified images
|
| 56 |
+
person_ids = [img['person_id'] for img in recent_images if img.get('person_id')]
|
| 57 |
+
people = {str(p['_id']): p for p in mongo.db.people.find({'_id': {'$in': person_ids}})}
|
| 58 |
+
|
| 59 |
+
for img in recent_images:
|
| 60 |
+
if img.get('person_id') and str(img['person_id']) in people:
|
| 61 |
+
img['person'] = people[str(img['person_id'])]
|
| 62 |
+
|
| 63 |
+
return jsonify({
|
| 64 |
+
'success': True,
|
| 65 |
+
'data': [ImageModel.to_response(img, include_person=True) for img in recent_images]
|
| 66 |
+
})
|
| 67 |
+
|
| 68 |
+
except Exception as e:
|
| 69 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@stats_bp.route('/unidentified', methods=['GET'])
|
| 73 |
+
def get_unidentified():
|
| 74 |
+
"""Get images with unidentified faces."""
|
| 75 |
+
try:
|
| 76 |
+
unidentified = list(
|
| 77 |
+
mongo.db.images.find({
|
| 78 |
+
'has_face': True,
|
| 79 |
+
'is_identified': False
|
| 80 |
+
})
|
| 81 |
+
.sort('created_at', -1)
|
| 82 |
+
.limit(12)
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
return jsonify({
|
| 86 |
+
'success': True,
|
| 87 |
+
'data': [ImageModel.to_response(img) for img in unidentified]
|
| 88 |
+
})
|
| 89 |
+
|
| 90 |
+
except Exception as e:
|
| 91 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@stats_bp.route('/people-summary', methods=['GET'])
|
| 95 |
+
def get_people_summary():
|
| 96 |
+
"""Get people with most images."""
|
| 97 |
+
try:
|
| 98 |
+
people = list(
|
| 99 |
+
mongo.db.people.find()
|
| 100 |
+
.sort('image_count', -1)
|
| 101 |
+
.limit(8)
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
return jsonify({
|
| 105 |
+
'success': True,
|
| 106 |
+
'data': [PersonModel.to_response(p) for p in people]
|
| 107 |
+
})
|
| 108 |
+
|
| 109 |
+
except Exception as e:
|
| 110 |
+
return jsonify({'success': False, 'error': str(e)}), 500
|
app/services/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Services package."""
|
| 2 |
+
from .cloudinary_service import upload_image, delete_image, get_thumbnail_url, init_cloudinary
|
| 3 |
+
from .face_recognition_service import FaceRecognitionService, get_face_service
|
| 4 |
+
|
| 5 |
+
__all__ = [
|
| 6 |
+
'upload_image',
|
| 7 |
+
'delete_image',
|
| 8 |
+
'get_thumbnail_url',
|
| 9 |
+
'init_cloudinary',
|
| 10 |
+
'FaceRecognitionService',
|
| 11 |
+
'get_face_service'
|
| 12 |
+
]
|
app/services/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (475 Bytes). View file
|
|
|
app/services/__pycache__/cloudinary_service.cpython-310.pyc
ADDED
|
Binary file (2.44 kB). View file
|
|
|
app/services/__pycache__/face_recognition_service.cpython-310.pyc
ADDED
|
Binary file (4.12 kB). View file
|
|
|
app/services/cloudinary_service.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cloudinary service for image uploads and management."""
|
| 2 |
+
import cloudinary
|
| 3 |
+
import cloudinary.uploader
|
| 4 |
+
import cloudinary.api
|
| 5 |
+
from werkzeug.utils import secure_filename
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def init_cloudinary(config):
|
| 9 |
+
"""Initialize Cloudinary with configuration."""
|
| 10 |
+
cloudinary.config(
|
| 11 |
+
cloud_name=config.get('CLOUDINARY_CLOUD_NAME'),
|
| 12 |
+
api_key=config.get('CLOUDINARY_API_KEY'),
|
| 13 |
+
api_secret=config.get('CLOUDINARY_API_SECRET'),
|
| 14 |
+
secure=True
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def upload_image(file, folder='image-organizer'):
|
| 19 |
+
"""
|
| 20 |
+
Upload an image to Cloudinary.
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
file: File object or file path
|
| 24 |
+
folder: Cloudinary folder to store the image
|
| 25 |
+
|
| 26 |
+
Returns:
|
| 27 |
+
dict: Upload result containing url and public_id
|
| 28 |
+
"""
|
| 29 |
+
try:
|
| 30 |
+
result = cloudinary.uploader.upload(
|
| 31 |
+
file,
|
| 32 |
+
folder=folder,
|
| 33 |
+
resource_type='image',
|
| 34 |
+
transformation=[
|
| 35 |
+
{'quality': 'auto:good'},
|
| 36 |
+
{'fetch_format': 'auto'}
|
| 37 |
+
]
|
| 38 |
+
)
|
| 39 |
+
return {
|
| 40 |
+
'success': True,
|
| 41 |
+
'url': result['secure_url'],
|
| 42 |
+
'public_id': result['public_id'],
|
| 43 |
+
'width': result.get('width'),
|
| 44 |
+
'height': result.get('height'),
|
| 45 |
+
'format': result.get('format')
|
| 46 |
+
}
|
| 47 |
+
except Exception as e:
|
| 48 |
+
return {
|
| 49 |
+
'success': False,
|
| 50 |
+
'error': str(e)
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def delete_image(public_id):
|
| 55 |
+
"""
|
| 56 |
+
Delete an image from Cloudinary.
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
public_id: Cloudinary public ID of the image
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
dict: Deletion result
|
| 63 |
+
"""
|
| 64 |
+
try:
|
| 65 |
+
result = cloudinary.uploader.destroy(public_id)
|
| 66 |
+
return {
|
| 67 |
+
'success': result.get('result') == 'ok',
|
| 68 |
+
'result': result
|
| 69 |
+
}
|
| 70 |
+
except Exception as e:
|
| 71 |
+
return {
|
| 72 |
+
'success': False,
|
| 73 |
+
'error': str(e)
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def get_thumbnail_url(url, width=200, height=200):
|
| 78 |
+
"""
|
| 79 |
+
Generate a thumbnail URL for an image.
|
| 80 |
+
|
| 81 |
+
Args:
|
| 82 |
+
url: Original Cloudinary URL
|
| 83 |
+
width: Thumbnail width
|
| 84 |
+
height: Thumbnail height
|
| 85 |
+
|
| 86 |
+
Returns:
|
| 87 |
+
str: Thumbnail URL
|
| 88 |
+
"""
|
| 89 |
+
if not url:
|
| 90 |
+
return None
|
| 91 |
+
|
| 92 |
+
# Insert transformation into Cloudinary URL
|
| 93 |
+
parts = url.split('/upload/')
|
| 94 |
+
if len(parts) == 2:
|
| 95 |
+
transformation = f'c_fill,w_{width},h_{height},g_face'
|
| 96 |
+
return f'{parts[0]}/upload/{transformation}/{parts[1]}'
|
| 97 |
+
return url
|
app/services/face_recognition_service.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Face recognition service for detecting and matching faces."""
|
| 2 |
+
import face_recognition
|
| 3 |
+
import numpy as np
|
| 4 |
+
from io import BytesIO
|
| 5 |
+
from PIL import Image
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class FaceRecognitionService:
|
| 12 |
+
"""Service for face detection and recognition."""
|
| 13 |
+
|
| 14 |
+
def __init__(self, tolerance=0.6):
|
| 15 |
+
"""
|
| 16 |
+
Initialize the face recognition service.
|
| 17 |
+
|
| 18 |
+
Args:
|
| 19 |
+
tolerance: How much distance between faces to consider it a match.
|
| 20 |
+
Lower is stricter. 0.6 is typical best performance.
|
| 21 |
+
"""
|
| 22 |
+
self.tolerance = tolerance
|
| 23 |
+
|
| 24 |
+
def detect_faces(self, image_data):
|
| 25 |
+
"""
|
| 26 |
+
Detect faces in an image.
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
image_data: Image file data (bytes or file-like object)
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
dict: Contains face_encodings (list) and face_locations (list)
|
| 33 |
+
"""
|
| 34 |
+
try:
|
| 35 |
+
# Load image
|
| 36 |
+
if isinstance(image_data, bytes):
|
| 37 |
+
image = face_recognition.load_image_file(BytesIO(image_data))
|
| 38 |
+
else:
|
| 39 |
+
image_data.seek(0)
|
| 40 |
+
image = face_recognition.load_image_file(image_data)
|
| 41 |
+
|
| 42 |
+
# Detect face locations
|
| 43 |
+
face_locations = face_recognition.face_locations(image, model='hog')
|
| 44 |
+
|
| 45 |
+
if not face_locations:
|
| 46 |
+
return {
|
| 47 |
+
'success': True,
|
| 48 |
+
'face_encodings': [],
|
| 49 |
+
'face_locations': [],
|
| 50 |
+
'face_count': 0
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
# Get face encodings
|
| 54 |
+
face_encodings = face_recognition.face_encodings(image, face_locations)
|
| 55 |
+
|
| 56 |
+
# Convert to serializable format
|
| 57 |
+
encodings_list = [encoding.tolist() for encoding in face_encodings]
|
| 58 |
+
locations_list = [list(loc) for loc in face_locations]
|
| 59 |
+
|
| 60 |
+
return {
|
| 61 |
+
'success': True,
|
| 62 |
+
'face_encodings': encodings_list,
|
| 63 |
+
'face_locations': locations_list,
|
| 64 |
+
'face_count': len(face_locations)
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
except Exception as e:
|
| 68 |
+
logger.error(f"Face detection error: {str(e)}")
|
| 69 |
+
return {
|
| 70 |
+
'success': False,
|
| 71 |
+
'error': str(e),
|
| 72 |
+
'face_encodings': [],
|
| 73 |
+
'face_locations': [],
|
| 74 |
+
'face_count': 0
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
def find_matching_person(self, face_encoding, people):
|
| 78 |
+
"""
|
| 79 |
+
Find a matching person for a face encoding.
|
| 80 |
+
|
| 81 |
+
Args:
|
| 82 |
+
face_encoding: The face encoding to match (list)
|
| 83 |
+
people: List of person documents with face_encodings
|
| 84 |
+
|
| 85 |
+
Returns:
|
| 86 |
+
ObjectId or None: The matched person's ID or None
|
| 87 |
+
"""
|
| 88 |
+
if not face_encoding or not people:
|
| 89 |
+
return None
|
| 90 |
+
|
| 91 |
+
target_encoding = np.array(face_encoding)
|
| 92 |
+
best_match = None
|
| 93 |
+
best_distance = float('inf')
|
| 94 |
+
|
| 95 |
+
for person in people:
|
| 96 |
+
person_encodings = person.get('face_encodings', [])
|
| 97 |
+
|
| 98 |
+
for stored_encoding in person_encodings:
|
| 99 |
+
if not stored_encoding:
|
| 100 |
+
continue
|
| 101 |
+
|
| 102 |
+
stored_np = np.array(stored_encoding)
|
| 103 |
+
|
| 104 |
+
# Calculate face distance
|
| 105 |
+
distance = np.linalg.norm(target_encoding - stored_np)
|
| 106 |
+
|
| 107 |
+
if distance < self.tolerance and distance < best_distance:
|
| 108 |
+
best_distance = distance
|
| 109 |
+
best_match = person['_id']
|
| 110 |
+
|
| 111 |
+
return best_match
|
| 112 |
+
|
| 113 |
+
def compare_faces(self, encoding1, encoding2):
|
| 114 |
+
"""
|
| 115 |
+
Compare two face encodings.
|
| 116 |
+
|
| 117 |
+
Args:
|
| 118 |
+
encoding1: First face encoding (list)
|
| 119 |
+
encoding2: Second face encoding (list)
|
| 120 |
+
|
| 121 |
+
Returns:
|
| 122 |
+
dict: Contains 'match' (bool) and 'distance' (float)
|
| 123 |
+
"""
|
| 124 |
+
try:
|
| 125 |
+
enc1 = np.array(encoding1)
|
| 126 |
+
enc2 = np.array(encoding2)
|
| 127 |
+
|
| 128 |
+
distance = np.linalg.norm(enc1 - enc2)
|
| 129 |
+
is_match = distance <= self.tolerance
|
| 130 |
+
|
| 131 |
+
return {
|
| 132 |
+
'match': is_match,
|
| 133 |
+
'distance': float(distance),
|
| 134 |
+
'confidence': max(0, 1 - (distance / self.tolerance)) if is_match else 0
|
| 135 |
+
}
|
| 136 |
+
except Exception as e:
|
| 137 |
+
logger.error(f"Face comparison error: {str(e)}")
|
| 138 |
+
return {
|
| 139 |
+
'match': False,
|
| 140 |
+
'distance': float('inf'),
|
| 141 |
+
'confidence': 0,
|
| 142 |
+
'error': str(e)
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# Global service instance
|
| 147 |
+
face_service = None
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def get_face_service(tolerance=0.6):
|
| 151 |
+
"""Get or create the face recognition service instance."""
|
| 152 |
+
global face_service
|
| 153 |
+
if face_service is None:
|
| 154 |
+
face_service = FaceRecognitionService(tolerance)
|
| 155 |
+
return face_service
|
config.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application configuration."""
|
| 2 |
+
import os
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class Config:
|
| 9 |
+
"""Base configuration."""
|
| 10 |
+
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key')
|
| 11 |
+
MONGO_URI = os.environ.get('MONGODB_URI')
|
| 12 |
+
|
| 13 |
+
# Cloudinary settings
|
| 14 |
+
CLOUDINARY_CLOUD_NAME = os.environ.get('CLOUDINARY_CLOUD_NAME')
|
| 15 |
+
CLOUDINARY_API_KEY = os.environ.get('CLOUDINARY_API_KEY')
|
| 16 |
+
CLOUDINARY_API_SECRET = os.environ.get('CLOUDINARY_API_SECRET')
|
| 17 |
+
|
| 18 |
+
# Face recognition settings
|
| 19 |
+
FACE_RECOGNITION_TOLERANCE = float(os.environ.get('FACE_RECOGNITION_TOLERANCE', 0.6))
|
| 20 |
+
|
| 21 |
+
# File upload settings
|
| 22 |
+
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB
|
| 23 |
+
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class DevelopmentConfig(Config):
|
| 27 |
+
"""Development configuration."""
|
| 28 |
+
DEBUG = True
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class ProductionConfig(Config):
|
| 32 |
+
"""Production configuration."""
|
| 33 |
+
DEBUG = False
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class TestingConfig(Config):
|
| 37 |
+
"""Testing configuration."""
|
| 38 |
+
TESTING = True
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
config = {
|
| 42 |
+
'development': DevelopmentConfig,
|
| 43 |
+
'production': ProductionConfig,
|
| 44 |
+
'testing': TestingConfig,
|
| 45 |
+
'default': DevelopmentConfig
|
| 46 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Flask and extensions
|
| 2 |
+
Flask==3.0.0
|
| 3 |
+
Flask-CORS==4.0.0
|
| 4 |
+
Flask-PyMongo==2.3.0
|
| 5 |
+
python-dotenv==1.0.0
|
| 6 |
+
|
| 7 |
+
# MongoDB
|
| 8 |
+
pymongo==4.6.1
|
| 9 |
+
dnspython==2.4.2
|
| 10 |
+
|
| 11 |
+
# Face Recognition
|
| 12 |
+
face-recognition==1.3.0
|
| 13 |
+
numpy==1.26.2
|
| 14 |
+
Pillow==10.1.0
|
| 15 |
+
|
| 16 |
+
# Cloudinary
|
| 17 |
+
cloudinary==1.37.0
|
| 18 |
+
|
| 19 |
+
# Utilities
|
| 20 |
+
Werkzeug==3.0.1
|
| 21 |
+
gunicorn==21.2.0
|
| 22 |
+
python-dateutil==2.8.2
|
| 23 |
+
requests==2.32.5
|
run.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application entry point."""
|
| 2 |
+
from app import create_app
|
| 3 |
+
|
| 4 |
+
app = create_app()
|
| 5 |
+
|
| 6 |
+
if __name__ == '__main__':
|
| 7 |
+
app.run(host='0.0.0.0', port=5000, debug=True)
|