Update project documentation and structure; enhance README, finalize project reorganization, and improve testing standards.

This commit is contained in:
2025-09-03 17:07:17 -04:00
parent f4a83b3c40
commit 0e66b2253f
56 changed files with 1267 additions and 14713 deletions
-8
View File
@@ -1,8 +0,0 @@
"""
PunimTag Source Package
This package contains all the source code for the PunimTag application.
"""
__version__ = "1.0.0"
__author__ = "PunimTag Team"
-6
View File
@@ -1,6 +0,0 @@
"""
PunimTag Backend Package
This package contains all backend-related code including Flask app, database operations,
and face recognition functionality.
"""
-4415
View File
File diff suppressed because it is too large Load Diff
-308
View File
@@ -1,308 +0,0 @@
#!/usr/bin/env python3
"""
Database Management Utility for PunimTag
Clean, reset, inspect, and manage the database
"""
import os
import sqlite3
import shutil
from datetime import datetime
from typing import Dict, List
import json
class DatabaseManager:
def __init__(self, db_path: str = 'punimtag_simple.db'):
self.db_path = db_path
def backup_database(self, backup_name: str = None) -> str:
"""Create a backup of the current database"""
if not os.path.exists(self.db_path):
print(f"Database {self.db_path} does not exist")
return None
if backup_name is None:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_name = f"{self.db_path}.backup_{timestamp}"
shutil.copy2(self.db_path, backup_name)
print(f"✅ Database backed up to: {backup_name}")
return backup_name
def clean_database(self):
"""Clean all data but keep schema"""
if not os.path.exists(self.db_path):
print(f"Database {self.db_path} does not exist")
return
# Backup first
self.backup_database()
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
try:
# Clear all data but keep schema
c.execute("DELETE FROM image_tags")
c.execute("DELETE FROM faces")
c.execute("DELETE FROM tags")
c.execute("DELETE FROM people")
c.execute("DELETE FROM images")
# Reset auto-increment counters
c.execute("DELETE FROM sqlite_sequence")
conn.commit()
print("✅ Database cleaned successfully")
except Exception as e:
print(f"❌ Error cleaning database: {e}")
conn.rollback()
finally:
conn.close()
def delete_database(self):
"""Completely delete the database file"""
if os.path.exists(self.db_path):
# Backup first
self.backup_database()
os.remove(self.db_path)
print(f"✅ Database {self.db_path} deleted")
else:
print(f"Database {self.db_path} does not exist")
def get_database_stats(self) -> Dict:
"""Get comprehensive database statistics"""
if not os.path.exists(self.db_path):
return {"error": "Database does not exist"}
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
try:
stats = {}
# Basic counts
c.execute("SELECT COUNT(*) FROM images")
stats['images'] = c.fetchone()[0]
c.execute("SELECT COUNT(*) FROM faces")
stats['faces'] = c.fetchone()[0]
c.execute("SELECT COUNT(*) FROM faces WHERE person_id IS NOT NULL")
stats['identified_faces'] = c.fetchone()[0]
c.execute("SELECT COUNT(*) FROM people")
stats['people'] = c.fetchone()[0]
c.execute("SELECT COUNT(*) FROM tags")
stats['tags'] = c.fetchone()[0]
c.execute("SELECT COUNT(*) FROM image_tags")
stats['tagged_images'] = c.fetchone()[0]
# Derived stats
stats['unidentified_faces'] = stats['faces'] - stats['identified_faces']
# Top people by face count
c.execute("""SELECT p.name, COUNT(f.id) as face_count
FROM people p
JOIN faces f ON p.id = f.person_id
GROUP BY p.id
ORDER BY face_count DESC
LIMIT 5""")
stats['top_people'] = [{"name": row[0], "faces": row[1]} for row in c.fetchall()]
# Top tags
c.execute("""SELECT t.name, t.category, COUNT(it.image_id) as usage_count
FROM tags t
JOIN image_tags it ON t.id = it.tag_id
GROUP BY t.id
ORDER BY usage_count DESC
LIMIT 5""")
stats['top_tags'] = [{"name": row[0], "category": row[1], "usage": row[2]} for row in c.fetchall()]
# Database file size
stats['file_size_bytes'] = os.path.getsize(self.db_path)
stats['file_size_mb'] = round(stats['file_size_bytes'] / (1024 * 1024), 2)
return stats
except Exception as e:
return {"error": str(e)}
finally:
conn.close()
def inspect_database(self):
"""Detailed inspection of database contents"""
stats = self.get_database_stats()
if "error" in stats:
print(f"{stats['error']}")
return
print("\n📊 DATABASE INSPECTION")
print("=" * 50)
print(f"Database: {self.db_path}")
print(f"File size: {stats['file_size_mb']} MB")
print()
print("📈 COUNTS:")
print(f" Images: {stats['images']}")
print(f" Faces: {stats['faces']}")
print(f" - Identified: {stats['identified_faces']}")
print(f" - Unidentified: {stats['unidentified_faces']}")
print(f" People: {stats['people']}")
print(f" Tags: {stats['tags']}")
print(f" Tagged images: {stats['tagged_images']}")
print()
if stats['top_people']:
print("👥 TOP PEOPLE:")
for person in stats['top_people']:
print(f" {person['name']}: {person['faces']} faces")
print()
if stats['top_tags']:
print("🏷️ TOP TAGS:")
for tag in stats['top_tags']:
category = f"({tag['category']})" if tag['category'] else ""
print(f" {tag['name']} {category}: {tag['usage']} uses")
def list_all_people(self):
"""List all people in the database"""
if not os.path.exists(self.db_path):
print("Database does not exist")
return
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
try:
c.execute("""SELECT p.id, p.name, COUNT(f.id) as face_count, p.created_at
FROM people p
LEFT JOIN faces f ON p.id = f.person_id
GROUP BY p.id
ORDER BY face_count DESC""")
people = c.fetchall()
if not people:
print("No people found in database")
return
print("\n👥 ALL PEOPLE:")
print("-" * 60)
print(f"{'ID':<4} {'Name':<25} {'Faces':<8} {'Created':<15}")
print("-" * 60)
for person_id, name, face_count, created in people:
created_short = created[:10] if created else "N/A"
print(f"{person_id:<4} {name:<25} {face_count:<8} {created_short:<15}")
except Exception as e:
print(f"Error: {e}")
finally:
conn.close()
def list_all_tags(self):
"""List all tags in the database"""
if not os.path.exists(self.db_path):
print("Database does not exist")
return
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
try:
c.execute("""SELECT t.id, t.name, t.category, COUNT(it.image_id) as usage_count
FROM tags t
LEFT JOIN image_tags it ON t.id = it.tag_id
GROUP BY t.id
ORDER BY t.category, usage_count DESC""")
tags = c.fetchall()
if not tags:
print("No tags found in database")
return
print("\n🏷️ ALL TAGS:")
print("-" * 60)
print(f"{'ID':<4} {'Name':<25} {'Category':<15} {'Usage':<8}")
print("-" * 60)
for tag_id, name, category, usage in tags:
category = category or "None"
print(f"{tag_id:<4} {name:<25} {category:<15} {usage:<8}")
except Exception as e:
print(f"Error: {e}")
finally:
conn.close()
def main():
"""Interactive database management"""
import sys
if len(sys.argv) > 1:
db_path = sys.argv[1]
else:
db_path = 'punimtag_simple.db'
manager = DatabaseManager(db_path)
while True:
print("\n🗄️ DATABASE MANAGER")
print("=" * 30)
print("1. Inspect database")
print("2. Clean database (keep schema)")
print("3. Delete database completely")
print("4. Backup database")
print("5. List all people")
print("6. List all tags")
print("7. Exit")
try:
choice = input("\nSelect option (1-7): ").strip()
if choice == '1':
manager.inspect_database()
elif choice == '2':
confirm = input("⚠️ Clean all data? (y/N): ").strip().lower()
if confirm == 'y':
manager.clean_database()
else:
print("Cancelled")
elif choice == '3':
confirm = input("⚠️ Delete database completely? (y/N): ").strip().lower()
if confirm == 'y':
manager.delete_database()
break
else:
print("Cancelled")
elif choice == '4':
backup_name = input("Backup name (or Enter for auto): ").strip()
if not backup_name:
backup_name = None
manager.backup_database(backup_name)
elif choice == '5':
manager.list_all_people()
elif choice == '6':
manager.list_all_tags()
elif choice == '7':
break
else:
print("Invalid option")
except KeyboardInterrupt:
print("\n\nExiting...")
break
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
main()
-744
View File
@@ -1,744 +0,0 @@
import os
import sqlite3
import face_recognition
import numpy as np
from PIL import Image
from PIL.ExifTags import TAGS, GPSTAGS
from datetime import datetime
import json
from typing import List, Dict, Tuple, Optional
import pickle
from sklearn.cluster import DBSCAN
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
import concurrent.futures
from config import get_config
import dlib
class PunimTag:
def __init__(self, db_path: str = 'punimtag.db', photos_dir: str = 'photos'):
self.db_path = db_path
self.photos_dir = photos_dir
self.config = get_config()
self.conn = self._init_db()
def _init_db(self) -> sqlite3.Connection:
"""Initialize database with comprehensive schema"""
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
# Images table with metadata
c.execute('''CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT UNIQUE NOT NULL,
filename TEXT NOT NULL,
date_taken TIMESTAMP,
latitude REAL,
longitude REAL,
camera_make TEXT,
camera_model TEXT,
width INTEGER,
height INTEGER,
file_size INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)''')
# People table for identified individuals
c.execute('''CREATE TABLE IF NOT EXISTS people (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)''')
# Faces table with locations and encodings
c.execute('''CREATE TABLE IF NOT EXISTS faces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
image_id INTEGER NOT NULL,
person_id INTEGER,
top INTEGER NOT NULL,
right INTEGER NOT NULL,
bottom INTEGER NOT NULL,
left INTEGER NOT NULL,
encoding BLOB NOT NULL,
confidence REAL,
is_confirmed BOOLEAN DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(image_id) REFERENCES images(id) ON DELETE CASCADE,
FOREIGN KEY(person_id) REFERENCES people(id) ON DELETE SET NULL
)''')
# Tags table
c.execute('''CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
category TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)''')
# Image-tag relationship
c.execute('''CREATE TABLE IF NOT EXISTS image_tags (
image_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(image_id, tag_id),
FOREIGN KEY(image_id) REFERENCES images(id) ON DELETE CASCADE,
FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE
)''')
# Create indexes for performance
c.execute('CREATE INDEX IF NOT EXISTS idx_faces_person ON faces(person_id)')
c.execute('CREATE INDEX IF NOT EXISTS idx_faces_image ON faces(image_id)')
c.execute('CREATE INDEX IF NOT EXISTS idx_image_tags_image ON image_tags(image_id)')
c.execute('CREATE INDEX IF NOT EXISTS idx_image_tags_tag ON image_tags(tag_id)')
conn.commit()
return conn
def extract_metadata(self, image_path: str) -> Dict:
"""Extract EXIF metadata from image"""
metadata = {
'date_taken': None,
'latitude': None,
'longitude': None,
'camera_make': None,
'camera_model': None,
'width': None,
'height': None,
'file_size': os.path.getsize(image_path)
}
try:
with Image.open(image_path) as img:
metadata['width'], metadata['height'] = img.size
exifdata = img.getexif()
if exifdata:
for tag_id, value in exifdata.items():
tag = TAGS.get(tag_id, tag_id)
if tag == 'DateTime':
metadata['date_taken'] = datetime.strptime(value, '%Y:%m:%d %H:%M:%S')
elif tag == 'Make':
metadata['camera_make'] = value
elif tag == 'Model':
metadata['camera_model'] = value
elif tag == 'GPSInfo':
gps_data = {}
for t in value:
sub_tag = GPSTAGS.get(t, t)
gps_data[sub_tag] = value[t]
# Extract GPS coordinates
if 'GPSLatitude' in gps_data and 'GPSLongitude' in gps_data:
lat = self._convert_to_degrees(gps_data['GPSLatitude'])
lon = self._convert_to_degrees(gps_data['GPSLongitude'])
if gps_data.get('GPSLatitudeRef') == 'S':
lat = -lat
if gps_data.get('GPSLongitudeRef') == 'W':
lon = -lon
metadata['latitude'] = lat
metadata['longitude'] = lon
except Exception as e:
print(f"Error extracting metadata from {image_path}: {e}")
return metadata
def _convert_to_degrees(self, value):
"""Convert GPS coordinates to degrees"""
d, m, s = value
return d + (m / 60.0) + (s / 3600.0)
def process_image(self, image_path: str) -> int:
"""Process a single image and return its database ID"""
c = self.conn.cursor()
# Extract metadata
metadata = self.extract_metadata(image_path)
filename = os.path.basename(image_path)
# Insert or update image record
c.execute('''INSERT OR REPLACE INTO images
(path, filename, date_taken, latitude, longitude,
camera_make, camera_model, width, height, file_size)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
(image_path, filename, metadata['date_taken'],
metadata['latitude'], metadata['longitude'],
metadata['camera_make'], metadata['camera_model'],
metadata['width'], metadata['height'], metadata['file_size']))
image_id = c.lastrowid
# Detect and process faces
try:
image = face_recognition.load_image_file(image_path)
model = self.config.face_recognition.detection_model if dlib.DLIB_USE_CUDA and self.config.face_recognition.enable_gpu else 'hog'
face_locations = face_recognition.face_locations(image, model=model)
face_encodings = face_recognition.face_encodings(image, face_locations)
for location, encoding in zip(face_locations, face_encodings):
top, right, bottom, left = location
encoding_blob = pickle.dumps(encoding)
# Try to identify the person
person_id, confidence = self.identify_face(encoding)
c.execute('''INSERT INTO faces
(image_id, person_id, top, right, bottom, left, encoding, confidence)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)''',
(image_id, person_id, top, right, bottom, left, encoding_blob, confidence))
except Exception as e:
print(f"Error processing faces in {image_path}: {e}")
self.conn.commit()
return image_id
def identify_face(self, unknown_encoding: np.ndarray, threshold: float = 0.6) -> Tuple[Optional[int], Optional[float]]:
"""Identify a face by comparing with known faces"""
c = self.conn.cursor()
# Get all known face encodings
c.execute('''SELECT f.person_id, f.encoding
FROM faces f
WHERE f.person_id IS NOT NULL
AND f.is_confirmed = 1''')
known_faces = c.fetchall()
if not known_faces:
return None, None
# Group encodings by person
person_encodings = {}
for person_id, encoding_blob in known_faces:
encoding = pickle.loads(encoding_blob)
if person_id not in person_encodings:
person_encodings[person_id] = []
person_encodings[person_id].append(encoding)
# Compare with each person's encodings
best_match = None
best_distance = float('inf')
for person_id, encodings in person_encodings.items():
distances = face_recognition.face_distance(encodings, unknown_encoding)
min_distance = np.min(distances)
if min_distance < best_distance and min_distance < threshold:
best_distance = min_distance
best_match = person_id
if best_match:
confidence = 1.0 - best_distance
return best_match, confidence
return None, None
def add_person(self, name: str) -> int:
"""Add a new person to the database"""
c = self.conn.cursor()
c.execute('INSERT OR IGNORE INTO people (name) VALUES (?)', (name,))
self.conn.commit()
c.execute('SELECT id FROM people WHERE name = ?', (name,))
result = c.fetchone()
if result:
return result[0]
else:
# This shouldn't happen due to INSERT OR IGNORE, but handle it
c.execute('SELECT id FROM people WHERE name = ?', (name,))
return c.fetchone()[0]
def assign_face_to_person(self, face_id: int, person_id: int, is_confirmed: bool = True):
"""Assign a face to a person"""
c = self.conn.cursor()
c.execute('''UPDATE faces
SET person_id = ?, is_confirmed = ?
WHERE id = ?''',
(person_id, is_confirmed, face_id))
self.conn.commit()
def add_tag(self, name: str, category: Optional[str] = None) -> int:
"""Add a new tag"""
c = self.conn.cursor()
c.execute('INSERT OR IGNORE INTO tags (name, category) VALUES (?, ?)',
(name, category))
self.conn.commit()
c.execute('SELECT id FROM tags WHERE name = ?', (name,))
return c.fetchone()[0]
def tag_image(self, image_id: int, tag_id: int):
"""Add a tag to an image"""
c = self.conn.cursor()
c.execute('INSERT OR IGNORE INTO image_tags (image_id, tag_id) VALUES (?, ?)',
(image_id, tag_id))
self.conn.commit()
def get_unidentified_faces(self) -> List[Dict]:
"""Get all faces that haven't been identified"""
c = self.conn.cursor()
c.execute('''SELECT f.id, f.image_id, i.path, f.top, f.right, f.bottom, f.left
FROM faces f
JOIN images i ON f.image_id = i.id
WHERE f.person_id IS NULL
ORDER BY i.path''')
faces = []
for row in c.fetchall():
faces.append({
'face_id': row[0],
'image_id': row[1],
'image_path': row[2],
'location': (row[3], row[4], row[5], row[6])
})
return faces
def search_images(self, people: Optional[List[str]] = None, tags: Optional[List[str]] = None,
date_from: Optional[datetime] = None, date_to: Optional[datetime] = None) -> List[Dict]:
"""Search images by people, tags, and date range"""
c = self.conn.cursor()
query = '''SELECT DISTINCT i.id, i.path, i.filename, i.date_taken
FROM images i'''
joins = []
conditions = []
params = []
if people:
joins.append('JOIN faces f ON i.id = f.image_id')
joins.append('JOIN people p ON f.person_id = p.id')
placeholders = ','.join(['?' for _ in people])
conditions.append(f'p.name IN ({placeholders})')
params.extend(people)
if tags:
joins.append('JOIN image_tags it ON i.id = it.image_id')
joins.append('JOIN tags t ON it.tag_id = t.id')
placeholders = ','.join(['?' for _ in tags])
conditions.append(f't.name IN ({placeholders})')
params.extend(tags)
if date_from:
conditions.append('i.date_taken >= ?')
params.append(date_from)
if date_to:
conditions.append('i.date_taken <= ?')
params.append(date_to)
if joins:
query += ' ' + ' '.join(joins)
if conditions:
query += ' WHERE ' + ' AND '.join(conditions)
query += ' ORDER BY i.date_taken DESC'
c.execute(query, params)
results = []
for row in c.fetchall():
results.append({
'id': row[0],
'path': row[1],
'filename': row[2],
'date_taken': row[3]
})
return results
def process_directory(self):
"""Process all images in the photos directory"""
image_extensions = {'.jpg', '.jpeg', '.png', '.bmp', '.gif'}
processed = 0
for root, _, files in os.walk(self.photos_dir):
for file in files:
if any(file.lower().endswith(ext) for ext in image_extensions):
image_path = os.path.join(root, file)
print(f"Processing: {image_path}")
try:
self.process_image(image_path)
processed += 1
except Exception as e:
print(f"Error processing {image_path}: {e}")
print(f"\nProcessed {processed} images")
return processed
def calculate_face_quality(self, face_encoding: np.ndarray, face_location: Tuple[int, int, int, int]) -> float:
"""Calculate face quality score based on size and encoding variance"""
top, right, bottom, left = face_location
# Calculate face size
face_width = right - left
face_height = bottom - top
face_area = face_width * face_height
# Normalize face size (assuming typical face sizes)
size_score = min(face_area / (100 * 100), 1.0) # Normalize to 100x100 baseline
# Calculate encoding variance (higher variance = more distinctive features)
encoding_variance = np.var(face_encoding)
variance_score = min(encoding_variance / 0.01, 1.0) # Normalize to typical variance
# Combined quality score
quality_score = (size_score * 0.3) + (variance_score * 0.7)
return quality_score
def cluster_unknown_faces(self) -> Dict[int, List[int]]:
"""Cluster unidentified faces to group similar faces together"""
c = self.conn.cursor()
# Get all unidentified faces
c.execute('''SELECT id, encoding FROM faces WHERE person_id IS NULL''')
unidentified_faces = c.fetchall()
if len(unidentified_faces) < 2:
return {}
print(f"Clustering {len(unidentified_faces)} unidentified faces...")
# Extract encodings
face_ids = []
encodings = []
for face_id, encoding_blob in unidentified_faces:
face_ids.append(face_id)
encoding = pickle.loads(encoding_blob)
encodings.append(encoding)
encodings = np.array(encodings)
# Apply DBSCAN clustering
clustering = DBSCAN(
eps=self.config.face_recognition.cluster_epsilon,
min_samples=self.config.face_recognition.cluster_min_size,
metric='euclidean'
).fit(encodings)
# Group faces by cluster
clusters = {}
for i, cluster_id in enumerate(clustering.labels_):
if cluster_id != -1: # Ignore noise points
if cluster_id not in clusters:
clusters[cluster_id] = []
clusters[cluster_id].append(face_ids[i])
print(f"Found {len(clusters)} face clusters")
return clusters
def get_face_clusters(self) -> List[Dict]:
"""Get all face clusters with representative faces"""
clusters = self.cluster_unknown_faces()
c = self.conn.cursor()
cluster_data = []
for cluster_id, face_ids in clusters.items():
# Get representative face (first face in cluster for now)
representative_face_id = face_ids[0]
# Get face details
c.execute('''SELECT f.id, f.image_id, i.path, f.top, f.right, f.bottom, f.left
FROM faces f
JOIN images i ON f.image_id = i.id
WHERE f.id = ?''', (representative_face_id,))
face_info = c.fetchone()
if face_info:
cluster_data.append({
'cluster_id': cluster_id,
'face_count': len(face_ids),
'face_ids': face_ids,
'representative_face': {
'face_id': face_info[0],
'image_id': face_info[1],
'image_path': face_info[2],
'location': (face_info[3], face_info[4], face_info[5], face_info[6])
}
})
# Sort by face count (most common faces first)
cluster_data.sort(key=lambda x: x['face_count'], reverse=True)
return cluster_data
def assign_cluster_to_person(self, cluster_id: int, person_name: str):
"""Assign all faces in a cluster to a person"""
clusters = self.cluster_unknown_faces()
if cluster_id not in clusters:
return False
# Add or get person
person_id = self.add_person(person_name)
# Assign all faces in cluster
for face_id in clusters[cluster_id]:
self.assign_face_to_person(face_id, person_id, is_confirmed=True)
print(f"Assigned {len(clusters[cluster_id])} faces to {person_name}")
return True
def get_most_common_faces(self, limit: int = 20) -> List[Dict]:
"""Get faces sorted by frequency (most photographed people)"""
c = self.conn.cursor()
# Get identified people with face counts
c.execute('''SELECT p.id, p.name, COUNT(f.id) as face_count,
MIN(f.id) as sample_face_id
FROM people p
JOIN faces f ON p.id = f.person_id
WHERE f.is_confirmed = 1
GROUP BY p.id
ORDER BY face_count DESC
LIMIT ?''', (limit,))
people_data = []
for person_id, name, face_count, sample_face_id in c.fetchall():
# Get sample face details
c.execute('''SELECT f.image_id, i.path, f.top, f.right, f.bottom, f.left
FROM faces f
JOIN images i ON f.image_id = i.id
WHERE f.id = ?''', (sample_face_id,))
face_info = c.fetchone()
if face_info:
people_data.append({
'person_id': person_id,
'name': name,
'face_count': face_count,
'sample_face': {
'face_id': sample_face_id,
'image_id': face_info[0],
'image_path': face_info[1],
'location': (face_info[2], face_info[3], face_info[4], face_info[5])
}
})
return people_data
def verify_person_faces(self, person_id: int) -> List[Dict]:
"""Get all faces assigned to a person for verification"""
c = self.conn.cursor()
c.execute('''SELECT f.id, f.image_id, i.path, f.top, f.right, f.bottom, f.left, f.confidence
FROM faces f
JOIN images i ON f.image_id = i.id
WHERE f.person_id = ?
ORDER BY f.confidence DESC''', (person_id,))
faces = []
for row in c.fetchall():
faces.append({
'face_id': row[0],
'image_id': row[1],
'image_path': row[2],
'location': (row[3], row[4], row[5], row[6]),
'confidence': row[7]
})
return faces
def remove_incorrect_face_assignment(self, face_id: int):
"""Remove incorrect face assignment (set person_id to NULL)"""
c = self.conn.cursor()
c.execute('UPDATE faces SET person_id = NULL, is_confirmed = 0 WHERE id = ?', (face_id,))
self.conn.commit()
def batch_process_images(self, image_paths: List[str], batch_size: int = None) -> Dict[str, int]:
"""Process images in batches for better performance"""
if batch_size is None:
batch_size = self.config.processing.batch_size
results = {
'processed': 0,
'errors': 0,
'skipped': 0,
'faces_detected': 0
}
print(f"Processing {len(image_paths)} images in batches of {batch_size}")
for i in range(0, len(image_paths), batch_size):
batch = image_paths[i:i + batch_size]
print(f"Processing batch {i//batch_size + 1}/{(len(image_paths) + batch_size - 1)//batch_size}")
for image_path in batch:
try:
# Check if already processed
if self.config.processing.skip_processed:
c = self.conn.cursor()
c.execute('SELECT id FROM images WHERE path = ?', (image_path,))
if c.fetchone():
results['skipped'] += 1
continue
# Process image
image_id = self.process_image(image_path)
# Count faces detected
c = self.conn.cursor()
c.execute('SELECT COUNT(*) FROM faces WHERE image_id = ?', (image_id,))
face_count = c.fetchone()[0]
results['processed'] += 1
results['faces_detected'] += face_count
except Exception as e:
print(f"Error processing {image_path}: {e}")
results['errors'] += 1
return results
def advanced_search(self, **kwargs) -> List[Dict]:
"""Advanced search with multiple criteria and complex queries"""
c = self.conn.cursor()
# Base query
query = '''SELECT DISTINCT i.id, i.path, i.filename, i.date_taken,
i.latitude, i.longitude, i.camera_make, i.camera_model'''
from_clause = ' FROM images i'
joins = []
conditions = []
params = []
# People filter
if 'people' in kwargs and kwargs['people']:
joins.append('JOIN faces f ON i.id = f.image_id')
joins.append('JOIN people p ON f.person_id = p.id')
people_names = kwargs['people']
if isinstance(people_names, str):
people_names = [people_names]
placeholders = ','.join(['?' for _ in people_names])
conditions.append(f'p.name IN ({placeholders})')
params.extend(people_names)
# Tags filter
if 'tags' in kwargs and kwargs['tags']:
joins.append('JOIN image_tags it ON i.id = it.image_id')
joins.append('JOIN tags t ON it.tag_id = t.id')
tags = kwargs['tags']
if isinstance(tags, str):
tags = [tags]
placeholders = ','.join(['?' for _ in tags])
conditions.append(f't.name IN ({placeholders})')
params.extend(tags)
# Date range filters
if 'date_from' in kwargs and kwargs['date_from']:
conditions.append('i.date_taken >= ?')
params.append(kwargs['date_from'])
if 'date_to' in kwargs and kwargs['date_to']:
conditions.append('i.date_taken <= ?')
params.append(kwargs['date_to'])
# Location filters
if 'latitude_min' in kwargs and kwargs['latitude_min']:
conditions.append('i.latitude >= ?')
params.append(kwargs['latitude_min'])
if 'latitude_max' in kwargs and kwargs['latitude_max']:
conditions.append('i.latitude <= ?')
params.append(kwargs['latitude_max'])
if 'longitude_min' in kwargs and kwargs['longitude_min']:
conditions.append('i.longitude >= ?')
params.append(kwargs['longitude_min'])
if 'longitude_max' in kwargs and kwargs['longitude_max']:
conditions.append('i.longitude <= ?')
params.append(kwargs['longitude_max'])
# Camera filters
if 'camera_make' in kwargs and kwargs['camera_make']:
conditions.append('i.camera_make LIKE ?')
params.append(f"%{kwargs['camera_make']}%")
# Multiple people requirement
if 'min_people' in kwargs and kwargs['min_people']:
if 'people' not in kwargs: # Add people join if not already added
joins.append('JOIN faces f ON i.id = f.image_id')
joins.append('JOIN people p ON f.person_id = p.id')
# This requires a subquery to count distinct people per image
having_clause = f' HAVING COUNT(DISTINCT p.id) >= {kwargs["min_people"]}'
else:
having_clause = ''
# Build final query
full_query = query + from_clause
if joins:
full_query += ' ' + ' '.join(joins)
if conditions:
full_query += ' WHERE ' + ' AND '.join(conditions)
# Group by image to handle multiple joins
full_query += ' GROUP BY i.id'
if having_clause:
full_query += having_clause
# Order by date
full_query += ' ORDER BY i.date_taken DESC'
# Limit results
if 'limit' in kwargs and kwargs['limit']:
full_query += f' LIMIT {kwargs["limit"]}'
c.execute(full_query, params)
results = []
for row in c.fetchall():
results.append({
'id': row[0],
'path': row[1],
'filename': row[2],
'date_taken': row[3],
'latitude': row[4],
'longitude': row[5],
'camera_make': row[6],
'camera_model': row[7]
})
return results
def close(self):
"""Close database connection"""
self.conn.close()
def main():
"""Main entry point"""
tagger = PunimTag()
print("PunimTag - Face Recognition and Photo Tagging System")
print("=" * 50)
# Process all images
tagger.process_directory()
# Show unidentified faces count
unidentified = tagger.get_unidentified_faces()
print(f"\nFound {len(unidentified)} unidentified faces")
tagger.close()
if __name__ == "__main__":
main()
-462
View File
@@ -1,462 +0,0 @@
#!/usr/bin/env python3
"""
Simplified PunimTag for initial testing
Core functionality without advanced clustering (sklearn dependency)
"""
import os
import sqlite3
import face_recognition
import numpy as np
from PIL import Image
from PIL.ExifTags import TAGS, GPSTAGS
from datetime import datetime
import json
from typing import List, Dict, Tuple, Optional
import pickle
class SimplePunimTag:
def __init__(self, db_path: str = 'punimtag_simple.db', photos_dir: str = 'photos'):
self.db_path = db_path
self.photos_dir = photos_dir
self.conn = self._init_db()
def _init_db(self) -> sqlite3.Connection:
"""Initialize database with comprehensive schema"""
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
# Images table with metadata
c.execute('''CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT UNIQUE NOT NULL,
filename TEXT NOT NULL,
date_taken TIMESTAMP,
latitude REAL,
longitude REAL,
camera_make TEXT,
camera_model TEXT,
width INTEGER,
height INTEGER,
file_size INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)''')
# People table for identified individuals
c.execute('''CREATE TABLE IF NOT EXISTS people (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)''')
# Faces table with locations and encodings
c.execute('''CREATE TABLE IF NOT EXISTS faces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
image_id INTEGER NOT NULL,
person_id INTEGER,
top INTEGER NOT NULL,
right INTEGER NOT NULL,
bottom INTEGER NOT NULL,
left INTEGER NOT NULL,
encoding BLOB NOT NULL,
confidence REAL,
is_confirmed BOOLEAN DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(image_id) REFERENCES images(id) ON DELETE CASCADE,
FOREIGN KEY(person_id) REFERENCES people(id) ON DELETE SET NULL
)''')
# Tags table
c.execute('''CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
category TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)''')
# Image-tag relationship
c.execute('''CREATE TABLE IF NOT EXISTS image_tags (
image_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(image_id, tag_id),
FOREIGN KEY(image_id) REFERENCES images(id) ON DELETE CASCADE,
FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE
)''')
# Create indexes for performance
c.execute('CREATE INDEX IF NOT EXISTS idx_faces_person ON faces(person_id)')
c.execute('CREATE INDEX IF NOT EXISTS idx_faces_image ON faces(image_id)')
c.execute('CREATE INDEX IF NOT EXISTS idx_image_tags_image ON image_tags(image_id)')
c.execute('CREATE INDEX IF NOT EXISTS idx_image_tags_tag ON image_tags(tag_id)')
conn.commit()
return conn
def extract_metadata(self, image_path: str) -> Dict:
"""Extract EXIF metadata from image with better error handling"""
metadata = {
'date_taken': None,
'latitude': None,
'longitude': None,
'camera_make': None,
'camera_model': None,
'width': None,
'height': None,
'file_size': None
}
try:
# Get file size
if os.path.exists(image_path):
metadata['file_size'] = os.path.getsize(image_path)
else:
print(f"Warning: File not found: {image_path}")
return metadata
# Try to open image
img = Image.open(image_path)
metadata['width'], metadata['height'] = img.size
# Extract EXIF data
exifdata = img.getexif()
if exifdata:
for tag_id, value in exifdata.items():
tag = TAGS.get(tag_id, tag_id)
try:
if tag == 'DateTime':
metadata['date_taken'] = datetime.strptime(value, '%Y:%m:%d %H:%M:%S')
elif tag == 'Make':
metadata['camera_make'] = str(value).strip()
elif tag == 'Model':
metadata['camera_model'] = str(value).strip()
elif tag == 'GPSInfo':
gps_data = {}
for t in value:
sub_tag = GPSTAGS.get(t, t)
gps_data[sub_tag] = value[t]
# Extract GPS coordinates
if 'GPSLatitude' in gps_data and 'GPSLongitude' in gps_data:
try:
lat = self._convert_to_degrees(gps_data['GPSLatitude'])
lon = self._convert_to_degrees(gps_data['GPSLongitude'])
if gps_data.get('GPSLatitudeRef') == 'S':
lat = -lat
if gps_data.get('GPSLongitudeRef') == 'W':
lon = -lon
metadata['latitude'] = lat
metadata['longitude'] = lon
except Exception as e:
print(f"Error parsing GPS data: {e}")
except Exception as e:
print(f"Error parsing EXIF tag {tag}: {e}")
except Exception as e:
print(f"Error extracting metadata from {image_path}: {e}")
# Set defaults for missing values
for key, value in metadata.items():
if value is None and key not in ['date_taken', 'latitude', 'longitude']:
metadata[key] = 'N/A'
return metadata
def _convert_to_degrees(self, value):
"""Convert GPS coordinates to degrees"""
if len(value) == 3:
d, m, s = value
return d + (m / 60.0) + (s / 3600.0)
return 0.0
def process_image(self, image_path: str) -> int:
"""Process a single image and return its database ID"""
c = self.conn.cursor()
print(f"Processing: {image_path}")
# Extract metadata
metadata = self.extract_metadata(image_path)
filename = os.path.basename(image_path)
# Insert or update image record
c.execute('''INSERT OR REPLACE INTO images
(path, filename, date_taken, latitude, longitude,
camera_make, camera_model, width, height, file_size)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
(image_path, filename, metadata['date_taken'],
metadata['latitude'], metadata['longitude'],
metadata['camera_make'], metadata['camera_model'],
metadata['width'], metadata['height'], metadata['file_size']))
image_id = c.lastrowid
# Detect and process faces
try:
image = face_recognition.load_image_file(image_path)
face_locations = face_recognition.face_locations(image, model='hog')
face_encodings = face_recognition.face_encodings(image, face_locations)
print(f" Found {len(face_locations)} faces")
for location, encoding in zip(face_locations, face_encodings):
top, right, bottom, left = location
encoding_blob = pickle.dumps(encoding)
# Try to identify the person
person_id, confidence = self.identify_face(encoding)
c.execute('''INSERT INTO faces
(image_id, person_id, top, right, bottom, left, encoding, confidence)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)''',
(image_id, person_id, top, right, bottom, left, encoding_blob, confidence))
except Exception as e:
print(f" Error processing faces: {e}")
self.conn.commit()
return image_id
def identify_face(self, unknown_encoding: np.ndarray, threshold: float = 0.6) -> Tuple[Optional[int], Optional[float]]:
"""Identify a face by comparing with known faces"""
c = self.conn.cursor()
# Get all known face encodings
c.execute('''SELECT f.person_id, f.encoding
FROM faces f
WHERE f.person_id IS NOT NULL
AND f.is_confirmed = 1''')
known_faces = c.fetchall()
if not known_faces:
return None, None
# Group encodings by person
person_encodings = {}
for person_id, encoding_blob in known_faces:
encoding = pickle.loads(encoding_blob)
if person_id not in person_encodings:
person_encodings[person_id] = []
person_encodings[person_id].append(encoding)
# Compare with each person's encodings
best_match = None
best_distance = float('inf')
for person_id, encodings in person_encodings.items():
distances = face_recognition.face_distance(encodings, unknown_encoding)
min_distance = np.min(distances)
if min_distance < best_distance and min_distance < threshold:
best_distance = min_distance
best_match = person_id
if best_match:
confidence = 1.0 - best_distance
return best_match, confidence
return None, None
def add_person(self, name: str) -> int:
"""Add a new person to the database"""
c = self.conn.cursor()
c.execute('INSERT OR IGNORE INTO people (name) VALUES (?)', (name,))
self.conn.commit()
c.execute('SELECT id FROM people WHERE name = ?', (name,))
result = c.fetchone()
return result[0] if result else None
def assign_face_to_person(self, face_id: int, person_id: int, is_confirmed: bool = True):
"""Assign a face to a person"""
c = self.conn.cursor()
c.execute('''UPDATE faces
SET person_id = ?, is_confirmed = ?
WHERE id = ?''',
(person_id, is_confirmed, face_id))
self.conn.commit()
def add_tag(self, name: str, category: str = None) -> int:
"""Add a new tag"""
c = self.conn.cursor()
c.execute('INSERT OR IGNORE INTO tags (name, category) VALUES (?, ?)',
(name, category))
self.conn.commit()
c.execute('SELECT id FROM tags WHERE name = ?', (name,))
result = c.fetchone()
return result[0] if result else None
def tag_image(self, image_id: int, tag_id: int):
"""Add a tag to an image"""
c = self.conn.cursor()
c.execute('INSERT OR IGNORE INTO image_tags (image_id, tag_id) VALUES (?, ?)',
(image_id, tag_id))
self.conn.commit()
def get_unidentified_faces(self) -> List[Dict]:
"""Get all faces that haven't been identified"""
c = self.conn.cursor()
c.execute('''SELECT f.id, f.image_id, i.path, f.top, f.right, f.bottom, f.left
FROM faces f
JOIN images i ON f.image_id = i.id
WHERE f.person_id IS NULL
ORDER BY i.path''')
faces = []
for row in c.fetchall():
faces.append({
'face_id': row[0],
'image_id': row[1],
'image_path': row[2],
'location': (row[3], row[4], row[5], row[6])
})
return faces
def simple_search(self, people: List[str] = None, tags: List[str] = None,
date_from: datetime = None, date_to: datetime = None) -> List[Dict]:
"""Simple search by people, tags, and date range"""
c = self.conn.cursor()
query = '''SELECT DISTINCT i.id, i.path, i.filename, i.date_taken
FROM images i'''
joins = []
conditions = []
params = []
if people:
joins.append('JOIN faces f ON i.id = f.image_id')
joins.append('JOIN people p ON f.person_id = p.id')
placeholders = ','.join(['?' for _ in people])
conditions.append(f'p.name IN ({placeholders})')
params.extend(people)
if tags:
joins.append('JOIN image_tags it ON i.id = it.image_id')
joins.append('JOIN tags t ON it.tag_id = t.id')
placeholders = ','.join(['?' for _ in tags])
conditions.append(f't.name IN ({placeholders})')
params.extend(tags)
if date_from:
conditions.append('i.date_taken >= ?')
params.append(date_from)
if date_to:
conditions.append('i.date_taken <= ?')
params.append(date_to)
if joins:
query += ' ' + ' '.join(joins)
if conditions:
query += ' WHERE ' + ' AND '.join(conditions)
query += ' ORDER BY i.date_taken DESC'
c.execute(query, params)
results = []
for row in c.fetchall():
results.append({
'id': row[0],
'path': row[1],
'filename': row[2],
'date_taken': row[3]
})
return results
def process_directory(self):
"""Process all images in the photos directory"""
image_extensions = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.gif'}
processed = 0
errors = 0
for root, _, files in os.walk(self.photos_dir):
for file in files:
if any(file.lower().endswith(ext) for ext in image_extensions):
image_path = os.path.join(root, file)
try:
self.process_image(image_path)
processed += 1
except Exception as e:
print(f"Error processing {image_path}: {e}")
errors += 1
print(f"\nProcessed {processed} images, {errors} errors")
return processed
def get_stats(self) -> Dict:
"""Get database statistics"""
c = self.conn.cursor()
c.execute("SELECT COUNT(*) FROM images")
image_count = c.fetchone()[0]
c.execute("SELECT COUNT(*) FROM faces")
face_count = c.fetchone()[0]
c.execute("SELECT COUNT(*) FROM faces WHERE person_id IS NOT NULL")
identified_faces = c.fetchone()[0]
c.execute("SELECT COUNT(*) FROM people")
people_count = c.fetchone()[0]
c.execute("SELECT COUNT(*) FROM tags")
tag_count = c.fetchone()[0]
return {
'images': image_count,
'faces': face_count,
'identified_faces': identified_faces,
'unidentified_faces': face_count - identified_faces,
'people': people_count,
'tags': tag_count
}
def close(self):
"""Close database connection"""
self.conn.close()
def main():
"""Main entry point for testing"""
print("SimplePunimTag - Testing Backend")
print("=" * 50)
tagger = SimplePunimTag()
# Get initial stats
stats = tagger.get_stats()
print(f"Initial stats: {stats}")
# Process images if photos directory exists
if os.path.exists(tagger.photos_dir):
processed = tagger.process_directory()
# Show final stats
final_stats = tagger.get_stats()
print(f"\nFinal stats: {final_stats}")
# Show unidentified faces
unidentified = tagger.get_unidentified_faces()
print(f"Unidentified faces: {len(unidentified)}")
else:
print(f"Photos directory '{tagger.photos_dir}' not found")
print("Create the directory and add some photos to test")
tagger.close()
print("\nTesting completed!")
if __name__ == "__main__":
main()
-204
View File
@@ -1,204 +0,0 @@
#!/usr/bin/env python3
"""
Visual Face Identifier for PunimTag
Shows face crops so you can see who you're identifying
"""
import sqlite3
import os
from PIL import Image
import subprocess
import sys
class VisualFaceIdentifier:
def __init__(self, db_path='punimtag_simple.db'):
self.db_path = db_path
def get_unidentified_faces(self, limit=10):
"""Get a limited number of unidentified faces"""
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute('''SELECT f.id, f.image_id, i.path, i.filename, f.top, f.right, f.bottom, f.left
FROM faces f
JOIN images i ON f.image_id = i.id
WHERE f.person_id IS NULL
LIMIT ?''', (limit,))
faces = c.fetchall()
conn.close()
return faces
def extract_face_crop(self, image_path, top, right, bottom, left):
"""Extract and save a face crop"""
try:
if not os.path.exists(image_path):
return None
with Image.open(image_path) as img:
# Crop the face with some padding
padding = 20
crop_top = max(0, top - padding)
crop_left = max(0, left - padding)
crop_bottom = min(img.height, bottom + padding)
crop_right = min(img.width, right + padding)
face_crop = img.crop((crop_left, crop_top, crop_right, crop_bottom))
# Save temporary crop
temp_path = f"temp_face_crop_{os.getpid()}.jpg"
face_crop.save(temp_path, "JPEG")
return temp_path
except Exception as e:
print(f"Error extracting face: {e}")
return None
def open_image(self, image_path):
"""Open image with default system viewer"""
try:
import platform
if platform.system() == "Windows":
# For Windows
os.startfile(image_path)
return True
elif image_path.startswith('/mnt/c/'):
# Convert WSL path to Windows path for explorer
win_path = image_path.replace('/mnt/c/', 'C:\\').replace('/', '\\')
subprocess.run(['explorer.exe', win_path], check=True)
return True
else:
# For Linux/Mac
subprocess.run(['xdg-open', image_path], check=True)
return True
except Exception as e:
print(f"Could not open image: {e}")
return False
def add_person(self, name):
"""Add a new person"""
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute('INSERT OR IGNORE INTO people (name) VALUES (?)', (name,))
c.execute('SELECT id FROM people WHERE name = ?', (name,))
person_id = c.fetchone()[0]
conn.commit()
conn.close()
return person_id
def assign_face(self, face_id, person_id):
"""Assign a face to a person"""
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute('UPDATE faces SET person_id = ?, is_confirmed = 1 WHERE id = ?',
(person_id, face_id))
conn.commit()
conn.close()
def run_visual_identifier(self):
"""Run visual identifier with image viewing"""
print("\n🏷️ Visual Face Identifier")
print("=" * 50)
print("This will show you face crops to help identify people")
print()
faces = self.get_unidentified_faces(20) # Process 20 at a time
if not faces:
print("No unidentified faces found!")
return
print(f"Found {len(faces)} unidentified faces to process...")
print("Commands:")
print(" - Enter person's name to identify")
print(" - 's' to skip")
print(" - 'o' to open original image")
print(" - 'q' to quit")
print()
try:
for i, (face_id, image_id, path, filename, top, right, bottom, left) in enumerate(faces):
print(f"\n{'='*60}")
print(f"Face {i+1}/{len(faces)}")
print(f"📁 File: {filename}")
print(f"📍 Face location: top={top}, right={right}, bottom={bottom}, left={left}")
# Check if original file exists
if not os.path.exists(path):
print("⚠️ Original image file not found, skipping...")
continue
# Extract and show face crop
face_crop_path = self.extract_face_crop(path, top, right, bottom, left)
if face_crop_path:
print(f"🖼️ Face crop saved as: {face_crop_path}")
print("📖 Opening face crop...")
if self.open_image(face_crop_path):
print("✅ Face crop opened in image viewer")
else:
print("❌ Could not open image viewer")
print(f" You can manually open: {face_crop_path}")
while True:
response = input(f"\n👤 Who is this person? (name/'s'/'o'/'q'): ").strip()
if response.lower() == 'q':
print("🛑 Quitting...")
return
elif response.lower() == 's':
print("⏭️ Skipped")
break
elif response.lower() == 'o':
print("📖 Opening original image...")
if self.open_image(path):
print("✅ Original image opened")
else:
print(f"❌ Could not open: {path}")
elif response:
try:
person_id = self.add_person(response)
self.assign_face(face_id, person_id)
print(f"✅ Identified as '{response}'")
break
except Exception as e:
print(f"❌ Error: {e}")
else:
print("Please enter a name, 's', 'o', or 'q'")
# Clean up this face crop
if face_crop_path and os.path.exists(face_crop_path):
os.remove(face_crop_path)
finally:
# Clean up any remaining temp files
for file in os.listdir('.'):
if file.startswith(f'temp_face_crop_{os.getpid()}'):
try:
os.remove(file)
except:
pass
print(f"\n🎉 Completed processing!")
# Show remaining count
remaining = self.get_remaining_count()
if remaining > 0:
print(f"📊 {remaining} unidentified faces remaining")
print("Run the script again to continue identifying faces")
else:
print("🏆 All faces have been identified!")
def get_remaining_count(self):
"""Get count of remaining unidentified faces"""
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute('SELECT COUNT(*) FROM faces WHERE person_id IS NULL')
count = c.fetchone()[0]
conn.close()
return count
if __name__ == "__main__":
identifier = VisualFaceIdentifier()
identifier.run_visual_identifier()
-431
View File
@@ -1,431 +0,0 @@
#!/usr/bin/env python3
"""
Web GUI for PunimTag using Flask
Face clustering and identification interface
"""
from flask import Flask, render_template, request, jsonify, send_from_directory
import os
import sqlite3
import base64
from io import BytesIO
from PIL import Image
import pickle
import numpy as np
from typing import List, Dict
app = Flask(__name__)
DB_PATH = 'punimtag_simple.db'
def get_face_clusters() -> List[Dict]:
"""Get face clusters using simple clustering"""
if not os.path.exists(DB_PATH):
return []
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
try:
# Get unidentified faces
c.execute('''SELECT f.id, f.image_id, i.path, f.top, f.right, f.bottom, f.left, f.encoding
FROM faces f
JOIN images i ON f.image_id = i.id
WHERE f.person_id IS NULL''')
faces = c.fetchall()
if len(faces) < 2:
return []
# Simple clustering by face encoding similarity
clusters = []
used_faces = set()
for i, face1 in enumerate(faces):
if face1[0] in used_faces:
continue
cluster_faces = [face1]
used_faces.add(face1[0])
encoding1 = pickle.loads(face1[7])
# Find similar faces
for j, face2 in enumerate(faces[i+1:], i+1):
if face2[0] in used_faces:
continue
encoding2 = pickle.loads(face2[7])
# Calculate similarity using numpy
distance = np.linalg.norm(encoding1 - encoding2)
if distance < 0.8: # Similar faces
cluster_faces.append(face2)
used_faces.add(face2[0])
# Only create cluster if it has multiple faces
if len(cluster_faces) >= 2:
cluster_data = {
'cluster_id': len(clusters),
'face_count': len(cluster_faces),
'faces': []
}
for face in cluster_faces:
cluster_data['faces'].append({
'face_id': face[0],
'image_id': face[1],
'image_path': face[2],
'location': (face[3], face[4], face[5], face[6])
})
clusters.append(cluster_data)
# Sort by face count (largest clusters first)
clusters.sort(key=lambda x: x['face_count'], reverse=True)
return clusters
except Exception as e:
print(f"Error in clustering: {e}")
return []
finally:
conn.close()
def get_face_thumbnail_base64(face: Dict) -> str:
"""Get base64 encoded thumbnail of a face"""
try:
image_path = face['image_path']
if not os.path.exists(image_path):
return ""
img = Image.open(image_path)
# Crop face region
top, right, bottom, left = face['location']
padding = 20
left = max(0, left - padding)
top = max(0, top - padding)
right = min(img.width, right + padding)
bottom = min(img.height, bottom + padding)
face_img = img.crop((left, top, right, bottom))
face_img.thumbnail((150, 150), Image.Resampling.LANCZOS)
# Convert to base64
buffer = BytesIO()
face_img.save(buffer, format='JPEG')
img_str = base64.b64encode(buffer.getvalue()).decode()
return f"data:image/jpeg;base64,{img_str}"
except Exception as e:
print(f"Error creating thumbnail: {e}")
return ""
def get_database_stats() -> Dict:
"""Get database statistics"""
if not os.path.exists(DB_PATH):
return {}
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
try:
stats = {}
c.execute("SELECT COUNT(*) FROM images")
stats['images'] = c.fetchone()[0]
c.execute("SELECT COUNT(*) FROM faces")
stats['faces'] = c.fetchone()[0]
c.execute("SELECT COUNT(*) FROM faces WHERE person_id IS NOT NULL")
stats['identified_faces'] = c.fetchone()[0]
c.execute("SELECT COUNT(*) FROM people")
stats['people'] = c.fetchone()[0]
stats['unidentified_faces'] = stats['faces'] - stats['identified_faces']
return stats
except Exception as e:
print(f"Error getting stats: {e}")
return {}
finally:
conn.close()
@app.route('/')
def index():
"""Main page"""
stats = get_database_stats()
clusters = get_face_clusters()
return render_template('index.html', stats=stats, clusters=clusters)
@app.route('/cluster/<int:cluster_id>')
def cluster_detail(cluster_id):
"""Cluster detail page"""
clusters = get_face_clusters()
if cluster_id >= len(clusters):
return "Cluster not found", 404
cluster = clusters[cluster_id]
# Add thumbnails to faces
for face in cluster['faces']:
face['thumbnail'] = get_face_thumbnail_base64(face)
face['filename'] = os.path.basename(face['image_path'])
return render_template('cluster_detail.html', cluster=cluster, cluster_id=cluster_id)
@app.route('/identify_cluster', methods=['POST'])
def identify_cluster():
"""Identify all faces in a cluster as a person"""
data = request.json
cluster_id = data.get('cluster_id')
person_name = data.get('person_name', '').strip()
if not person_name:
return jsonify({'success': False, 'error': 'Person name is required'})
try:
clusters = get_face_clusters()
if cluster_id >= len(clusters):
return jsonify({'success': False, 'error': 'Cluster not found'})
cluster = clusters[cluster_id]
# Add person to database and assign faces
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
# Add person
c.execute('INSERT OR IGNORE INTO people (name) VALUES (?)', (person_name,))
c.execute('SELECT id FROM people WHERE name = ?', (person_name,))
person_id = c.fetchone()[0]
# Assign all faces in cluster
for face in cluster['faces']:
c.execute('''UPDATE faces
SET person_id = ?, is_confirmed = 1
WHERE id = ?''',
(person_id, face['face_id']))
conn.commit()
conn.close()
return jsonify({
'success': True,
'message': f"Identified {cluster['face_count']} faces as {person_name}"
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@app.route('/people')
def people_list():
"""List all identified people"""
if not os.path.exists(DB_PATH):
return render_template('people.html', people=[])
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
try:
c.execute('''SELECT p.id, p.name, COUNT(f.id) as face_count, p.created_at
FROM people p
LEFT JOIN faces f ON p.id = f.person_id
GROUP BY p.id
ORDER BY face_count DESC''')
people = []
for row in c.fetchall():
people.append({
'id': row[0],
'name': row[1],
'face_count': row[2],
'created_at': row[3]
})
return render_template('people.html', people=people)
except Exception as e:
return render_template('people.html', people=[], error=str(e))
finally:
conn.close()
@app.route('/search')
def search():
"""Search interface"""
return render_template('search.html')
@app.route('/api/search', methods=['POST'])
def api_search():
"""Search API endpoint"""
data = request.json
people = data.get('people', [])
tags = data.get('tags', [])
try:
from punimtag_simple import SimplePunimTag
tagger = SimplePunimTag(DB_PATH)
results = tagger.simple_search(people=people if people else None,
tags=tags if tags else None)
tagger.close()
return jsonify({'success': True, 'results': results})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
if __name__ == '__main__':
# Create templates directory and basic templates
os.makedirs('templates', exist_ok=True)
# Create basic HTML templates
create_html_templates()
print("🌐 Starting PunimTag Web GUI...")
print("📊 Open http://localhost:5000 in your browser")
print("🔄 Use Ctrl+C to stop")
app.run(debug=True, host='0.0.0.0', port=5000)
def create_html_templates():
"""Create basic HTML templates"""
# Base template
base_template = '''<!DOCTYPE html>
<html>
<head>
<title>PunimTag - {% block title %}{% endblock %}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.header { border-bottom: 2px solid #007bff; padding-bottom: 10px; margin-bottom: 20px; }
.header h1 { color: #007bff; margin: 0; }
.nav { margin: 20px 0; }
.nav a { margin-right: 20px; color: #007bff; text-decoration: none; }
.nav a:hover { text-decoration: underline; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 15px; margin: 20px 0; }
.stat-card { background: #f8f9fa; padding: 15px; border-radius: 5px; text-align: center; }
.stat-card h3 { margin: 0; color: #007bff; }
.stat-card p { margin: 5px 0 0 0; font-size: 24px; font-weight: bold; }
.cluster-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; margin: 20px 0; }
.cluster-card { border: 1px solid #ddd; border-radius: 8px; padding: 15px; background: white; }
.cluster-card h3 { margin: 0 0 10px 0; color: #007bff; }
.face-thumbnails { display: flex; flex-wrap: wrap; gap: 10px; }
.face-thumb { width: 80px; height: 80px; border-radius: 5px; object-fit: cover; }
.btn { background: #007bff; color: white; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; text-decoration: none; display: inline-block; }
.btn:hover { background: #0056b3; }
.btn-success { background: #28a745; }
.btn-success:hover { background: #1e7e34; }
.alert { padding: 15px; margin: 20px 0; border-radius: 5px; }
.alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.alert-error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🏷️ PunimTag</h1>
<div class="nav">
<a href="/">Face Clusters</a>
<a href="/people">People</a>
<a href="/search">Search</a>
</div>
</div>
{% block content %}{% endblock %}
</div>
</body>
</html>'''
# Index page
index_template = '''{% extends "base.html" %}
{% block title %}Face Clusters{% endblock %}
{% block content %}
<h2>📊 Database Statistics</h2>
<div class="stats">
<div class="stat-card">
<h3>Images</h3>
<p>{{ stats.images or 0 }}</p>
</div>
<div class="stat-card">
<h3>Total Faces</h3>
<p>{{ stats.faces or 0 }}</p>
</div>
<div class="stat-card">
<h3>Identified</h3>
<p>{{ stats.identified_faces or 0 }}</p>
</div>
<div class="stat-card">
<h3>Unidentified</h3>
<p>{{ stats.unidentified_faces or 0 }}</p>
</div>
<div class="stat-card">
<h3>People</h3>
<p>{{ stats.people or 0 }}</p>
</div>
</div>
<h2>👥 Unknown Face Clusters</h2>
{% if clusters %}
<p>Click on a cluster to identify the faces:</p>
<div class="cluster-grid">
{% for cluster in clusters %}
<div class="cluster-card">
<h3>Cluster {{ loop.index }} ({{ cluster.face_count }} faces)</h3>
<div class="face-thumbnails">
{% for face in cluster.faces[:4] %}
<img src="{{ get_face_thumbnail_base64(face) }}" class="face-thumb" alt="Face">
{% endfor %}
{% if cluster.face_count > 4 %}
<div style="padding: 10px;">+{{ cluster.face_count - 4 }} more</div>
{% endif %}
</div>
<br>
<a href="/cluster/{{ cluster.cluster_id }}" class="btn">View & Identify</a>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-success">
<strong>Great!</strong> No unknown face clusters found. All faces have been identified or there are no faces to process.
</div>
<p>To get started:</p>
<ol>
<li>Add photos to the <code>photos/</code> directory</li>
<li>Run <code>python punimtag_simple.py</code> to process them</li>
<li>Return here to identify unknown faces</li>
</ol>
{% endif %}
{% endblock %}'''
# Write templates
with open('templates/base.html', 'w') as f:
f.write(base_template)
with open('templates/index.html', 'w') as f:
f.write(index_template)
print("✅ Created basic HTML templates")
-5
View File
@@ -1,5 +0,0 @@
"""
PunimTag Utils Package
This package contains utility functions and helper modules.
"""
-289
View File
@@ -1,289 +0,0 @@
#!/usr/bin/env python3
"""
Tag Manager for PunimTag
Manage tags and assign them to images
"""
import os
from punimtag import PunimTag
from datetime import datetime
from typing import List, Optional
class TagManager:
def __init__(self, db_path: str = 'punimtag.db'):
self.tagger = PunimTag(db_path=db_path)
# Predefined tag categories and suggestions
self.tag_categories = {
'location': ['home', 'work', 'vacation', 'beach', 'mountain', 'city', 'park', 'restaurant'],
'event': ['birthday', 'wedding', 'graduation', 'holiday', 'party', 'meeting', 'conference'],
'scene': ['indoor', 'outdoor', 'nature', 'urban', 'rural', 'night', 'day', 'sunset', 'sunrise'],
'activity': ['sports', 'eating', 'working', 'playing', 'traveling', 'celebration', 'relaxing'],
'mood': ['happy', 'formal', 'casual', 'candid', 'posed', 'artistic'],
'season': ['spring', 'summer', 'fall', 'winter'],
'weather': ['sunny', 'cloudy', 'rainy', 'snowy'],
'group': ['family', 'friends', 'colleagues', 'solo', 'couple', 'group']
}
def list_tags(self):
"""List all existing tags"""
c = self.tagger.conn.cursor()
c.execute('SELECT id, name, category FROM tags ORDER BY category, name')
tags = c.fetchall()
if not tags:
print("No tags found in database.")
return
print("\nExisting Tags:")
print("=" * 50)
current_category = None
for tag_id, name, category in tags:
if category != current_category:
current_category = category or "Uncategorized"
print(f"\n{current_category}:")
print(f" [{tag_id}] {name}")
def create_tag(self):
"""Interactive tag creation"""
print("\nCreate New Tag")
print("=" * 50)
# Show categories
print("\nAvailable categories:")
for i, cat in enumerate(self.tag_categories.keys(), 1):
print(f"{i}. {cat}")
print(f"{len(self.tag_categories) + 1}. Other (no category)")
# Get category
try:
choice = int(input("\nSelect category (number): "))
if 1 <= choice <= len(self.tag_categories):
category = list(self.tag_categories.keys())[choice - 1]
print(f"\nSuggested tags for {category}:")
for tag in self.tag_categories[category]:
print(f" - {tag}")
else:
category = None
except:
category = None
# Get tag name
name = input("\nEnter tag name: ").strip()
if not name:
print("Tag name cannot be empty!")
return
# Create tag
tag_id = self.tagger.add_tag(name, category)
print(f"✓ Created tag '{name}' with ID {tag_id}")
def tag_images_by_search(self):
"""Tag images found by search criteria"""
print("\nTag Images by Search")
print("=" * 50)
# Get search criteria
print("\nSearch criteria (leave blank to skip):")
# People filter
people_input = input("People (comma-separated names): ").strip()
people = [p.strip() for p in people_input.split(',')] if people_input else None
# Existing tags filter
tags_input = input("Existing tags (comma-separated): ").strip()
tags = [t.strip() for t in tags_input.split(',')] if tags_input else None
# Date range filter
date_from_input = input("Date from (YYYY-MM-DD): ").strip()
date_from = datetime.strptime(date_from_input, '%Y-%m-%d') if date_from_input else None
date_to_input = input("Date to (YYYY-MM-DD): ").strip()
date_to = datetime.strptime(date_to_input, '%Y-%m-%d') if date_to_input else None
# Search images
results = self.tagger.search_images(people, tags, date_from, date_to)
if not results:
print("\nNo images found matching criteria!")
return
print(f"\nFound {len(results)} images")
# Get tag to apply
self.list_tags()
tag_name = input("\nEnter tag name to apply: ").strip()
if not tag_name:
print("Cancelled")
return
# Get or create tag
tag_id = self.tagger.add_tag(tag_name)
# Apply tag to all results
count = 0
for img in results:
self.tagger.tag_image(img['id'], tag_id)
count += 1
print(f"✓ Applied tag '{tag_name}' to {count} images")
def tag_single_image(self):
"""Tag a single image by path"""
print("\nTag Single Image")
print("=" * 50)
# Get image path
image_path = input("Enter image path: ").strip()
if not os.path.exists(image_path):
print(f"Error: Image not found at {image_path}")
return
# Check if image is in database
c = self.tagger.conn.cursor()
c.execute('SELECT id FROM images WHERE path = ?', (image_path,))
result = c.fetchone()
if not result:
print("Image not found in database. Processing it now...")
image_id = self.tagger.process_image(image_path)
else:
image_id = result[0]
# Show current tags
c.execute('''SELECT t.name FROM tags t
JOIN image_tags it ON t.id = it.tag_id
WHERE it.image_id = ?''', (image_id,))
current_tags = [row[0] for row in c.fetchall()]
if current_tags:
print(f"\nCurrent tags: {', '.join(current_tags)}")
else:
print("\nNo tags currently assigned")
# Add tags
while True:
tag_name = input("\nEnter tag to add (or press Enter to finish): ").strip()
if not tag_name:
break
tag_id = self.tagger.add_tag(tag_name)
self.tagger.tag_image(image_id, tag_id)
print(f"✓ Added tag '{tag_name}'")
def auto_tag_suggestions(self):
"""Suggest automatic tags based on image metadata"""
print("\nAuto-Tag Suggestions")
print("=" * 50)
c = self.tagger.conn.cursor()
# Find images without tags
c.execute('''SELECT i.id, i.path, i.date_taken, i.latitude, i.longitude
FROM images i
LEFT JOIN image_tags it ON i.id = it.image_id
WHERE it.tag_id IS NULL''')
untagged = c.fetchall()
if not untagged:
print("All images are already tagged!")
return
print(f"Found {len(untagged)} untagged images")
# Auto-tag based on date
seasons = {
(3, 4, 5): 'spring',
(6, 7, 8): 'summer',
(9, 10, 11): 'fall',
(12, 1, 2): 'winter'
}
season_counts = {s: 0 for s in seasons.values()}
location_count = 0
for img_id, path, date_taken, lat, lon in untagged:
suggestions = []
# Season based on date
if date_taken:
month = datetime.strptime(date_taken, '%Y-%m-%d %H:%M:%S').month
for months, season in seasons.items():
if month in months:
tag_id = self.tagger.add_tag(season, 'season')
self.tagger.tag_image(img_id, tag_id)
season_counts[season] += 1
break
# Location-based tags
if lat and lon:
# You could integrate with a geocoding API here
# For now, just tag as "geotagged"
tag_id = self.tagger.add_tag('geotagged', 'location')
self.tagger.tag_image(img_id, tag_id)
location_count += 1
print("\nAuto-tagging complete:")
for season, count in season_counts.items():
if count > 0:
print(f" - Tagged {count} images as '{season}'")
if location_count > 0:
print(f" - Tagged {location_count} images as 'geotagged'")
def run(self):
"""Main menu loop"""
while True:
print("\n" + "=" * 50)
print("PunimTag - Tag Manager")
print("=" * 50)
print("1. List all tags")
print("2. Create new tag")
print("3. Tag images by search")
print("4. Tag single image")
print("5. Auto-tag suggestions")
print("6. Exit")
try:
choice = int(input("\nSelect option: "))
if choice == 1:
self.list_tags()
elif choice == 2:
self.create_tag()
elif choice == 3:
self.tag_images_by_search()
elif choice == 4:
self.tag_single_image()
elif choice == 5:
self.auto_tag_suggestions()
elif choice == 6:
break
else:
print("Invalid option!")
except KeyboardInterrupt:
print("\n\nInterrupted by user")
break
except Exception as e:
print(f"Error: {e}")
self.tagger.close()
print("\nGoodbye!")
def main():
manager = TagManager()
manager.run()
if __name__ == "__main__":
main()