Pushing code to migrate git
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
PunimTag Source Package
|
||||
|
||||
This package contains all the source code for the PunimTag application.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "PunimTag Team"
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
PunimTag Backend Package
|
||||
|
||||
This package contains all backend-related code including Flask app, database operations,
|
||||
and face recognition functionality.
|
||||
"""
|
||||
+4415
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,308 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,744 @@
|
||||
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()
|
||||
@@ -0,0 +1,462 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,431 @@
|
||||
#!/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")
|
||||
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
PunimTag Utils Package
|
||||
|
||||
This package contains utility functions and helper modules.
|
||||
"""
|
||||
@@ -0,0 +1,289 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user