Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import io | |
| import json | |
| import logging | |
| import pickle | |
| import sqlite3 | |
| import uuid | |
| from datetime import datetime, date, timezone | |
| from pathlib import Path | |
| from typing import Any, Optional | |
| import numpy as np | |
| from fastapi import FastAPI, File, Form, HTTPException, UploadFile | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from PIL import Image | |
| from PIL.ExifTags import TAGS | |
| from pydantic import BaseModel | |
| from fastapi.staticfiles import StaticFiles | |
| # DeepFace | |
| try: | |
| from deepface import DeepFace | |
| DEEPFACE_AVAILABLE = True | |
| except ImportError: | |
| DEEPFACE_AVAILABLE = False | |
| logging.warning("DeepFace not installed. Face recognition will be unavailable.") | |
| # CLIP | |
| try: | |
| import torch | |
| import open_clip | |
| _clip_device = "cuda" if torch.cuda.is_available() else "cpu" | |
| _clip_model, _clip_preprocess, _ = open_clip.create_model_and_transforms( | |
| "ViT-B-32", pretrained="openai" | |
| ) | |
| _clip_model = _clip_model.to(_clip_device) | |
| _clip_model.eval() | |
| _clip_tokenizer = open_clip.get_tokenizer("ViT-B-32") | |
| CLIP_AVAILABLE = True | |
| logging.getLogger("synapse").info("CLIP loaded on %s.", _clip_device) | |
| except Exception as _clip_err: | |
| CLIP_AVAILABLE = False | |
| logging.warning("CLIP not available (%s). Album grouping will be unavailable.", _clip_err) | |
| # Config & Paths | |
| BASE_DIR = Path(__file__).parent | |
| DATA_DIR = BASE_DIR / "data" | |
| PHOTOS_DIR = DATA_DIR / "photos" | |
| FACES_DIR = DATA_DIR / "faces" | |
| PROFILE_DIR = DATA_DIR / "profiles" | |
| EMBEDDINGS_FILE = DATA_DIR / "embeddings.pkl" | |
| DB_PATH = DATA_DIR / "synapse.db" | |
| DATA_DIR.mkdir(parents=True, exist_ok=True) | |
| PHOTOS_DIR.mkdir(parents=True, exist_ok=True) | |
| FACES_DIR.mkdir(parents=True, exist_ok=True) | |
| PROFILE_DIR.mkdir(parents=True, exist_ok=True) | |
| logging.basicConfig(level=logging.INFO, format="%(levelname)s | %(message)s") | |
| log = logging.getLogger("synapse") | |
| # FastAPI | |
| app = FastAPI( | |
| title="Synapse", | |
| version="0.1.0", | |
| ) | |
| app.mount("/static/photos", StaticFiles(directory=str(PHOTOS_DIR)), name="photos") | |
| app.mount("/static/faces", StaticFiles(directory=str(FACES_DIR)), name="faces") | |
| app.mount("/static/profiles", StaticFiles(directory=str(PROFILE_DIR)), name="profiles") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # Database | |
| def get_db(): | |
| conn = sqlite3.connect(DB_PATH) | |
| conn.row_factory = sqlite3.Row | |
| return conn | |
| def init_db(): | |
| with get_db() as conn: | |
| conn.executescript(""" | |
| CREATE TABLE IF NOT EXISTS people ( | |
| id TEXT PRIMARY KEY, | |
| name TEXT NOT NULL, | |
| relationship TEXT, | |
| avatar TEXT, | |
| color TEXT, | |
| initials TEXT, | |
| profile_photo TEXT, | |
| is_user INTEGER DEFAULT 0, | |
| created_at TEXT DEFAULT CURRENT_TIMESTAMP | |
| ); | |
| CREATE TABLE IF NOT EXISTS app_settings ( | |
| key TEXT PRIMARY KEY, | |
| value TEXT | |
| ); | |
| CREATE TABLE IF NOT EXISTS photos ( | |
| id TEXT PRIMARY KEY, | |
| filename TEXT NOT NULL, | |
| taken_at TEXT, | |
| month_label TEXT, | |
| year_label TEXT, | |
| clip_embedding TEXT, | |
| clip_labels TEXT, | |
| created_at TEXT DEFAULT CURRENT_TIMESTAMP | |
| ); | |
| CREATE TABLE IF NOT EXISTS photo_people ( | |
| photo_id TEXT, | |
| person_id TEXT, | |
| confidence REAL, | |
| PRIMARY KEY (photo_id, person_id) | |
| ); | |
| CREATE TABLE IF NOT EXISTS albums ( | |
| id TEXT PRIMARY KEY, | |
| label TEXT NOT NULL, | |
| emoji TEXT, | |
| color TEXT, | |
| scene TEXT, | |
| created_at TEXT DEFAULT CURRENT_TIMESTAMP | |
| ); | |
| CREATE TABLE IF NOT EXISTS album_photos ( | |
| album_id TEXT, | |
| photo_id TEXT, | |
| PRIMARY KEY (album_id, photo_id) | |
| ); | |
| CREATE TABLE IF NOT EXISTS pending_faces ( | |
| id TEXT PRIMARY KEY, | |
| photo_id TEXT NOT NULL, | |
| face_crop TEXT NOT NULL, | |
| embedding TEXT, | |
| created_at TEXT DEFAULT CURRENT_TIMESTAMP | |
| ); | |
| """) | |
| for migration in [ | |
| "ALTER TABLE people ADD COLUMN is_user INTEGER DEFAULT 0", | |
| "ALTER TABLE photos ADD COLUMN year_label TEXT", | |
| "ALTER TABLE photos ADD COLUMN clip_embedding TEXT", | |
| "ALTER TABLE photos ADD COLUMN clip_labels TEXT", | |
| ]: | |
| try: | |
| conn.execute(migration) | |
| except Exception: | |
| pass | |
| rows = conn.execute( | |
| "SELECT id, taken_at, month_label FROM photos WHERE year_label IS NULL OR year_label = ''" | |
| ).fetchall() | |
| for row in rows: | |
| year = None | |
| if row["taken_at"]: | |
| try: | |
| year = str(datetime.fromisoformat(row["taken_at"]).year) | |
| except Exception: | |
| pass | |
| if not year and row["month_label"]: | |
| parts = row["month_label"].strip().rsplit(" ", 1) | |
| if len(parts) == 2 and parts[1].isdigit(): | |
| year = parts[1] | |
| if year: | |
| conn.execute( | |
| "UPDATE photos SET year_label = ? WHERE id = ?", | |
| (year, row["id"]), | |
| ) | |
| if rows: | |
| log.info("Backfilled year_label for %d existing photos.", len(rows)) | |
| log.info("Database initialised at %s", DB_PATH) | |
| init_db() | |
| class EmbeddingStore: | |
| def __init__(self, path): | |
| self.path = path | |
| self._data = {} | |
| self._load() | |
| def _load(self): | |
| if self.path.exists(): | |
| with open(self.path, "rb") as f: | |
| self._data = pickle.load(f) | |
| log.info("Loaded embeddings for %d people.", len(self._data)) | |
| def save(self): | |
| with open(self.path, "wb") as f: | |
| pickle.dump(self._data, f) | |
| def add(self, person_id, embedding): | |
| self._data.setdefault(person_id, []).append(embedding) | |
| self.save() | |
| def get_all(self): | |
| return self._data | |
| def remove_person(self, person_id): | |
| self._data.pop(person_id, None) | |
| self.save() | |
| embedding_store = EmbeddingStore(EMBEDDINGS_FILE) | |
| # DeepFace Face Recognition | |
| RECOGNITION_MODEL = "Facenet512" | |
| RECOGNITION_THRESHOLD = 0.40 | |
| MIN_DETECTION_CONFIDENCE = 0.65 | |
| def extract_taken_at(image): | |
| """Extract DateTimeOriginal from EXIF data and return as ISO string.""" | |
| try: | |
| exif = image._getexif() | |
| if not exif: | |
| return None | |
| for tag, value in exif.items(): | |
| if TAGS.get(tag) == "DateTimeOriginal": | |
| return datetime.strptime(value, "%Y:%m:%d %H:%M:%S").isoformat() | |
| except Exception: | |
| return None | |
| return None | |
| def cosine_distance(a, b): | |
| norm_a, norm_b = np.linalg.norm(a), np.linalg.norm(b) | |
| if norm_a == 0 or norm_b == 0: | |
| return 1.0 | |
| return float(1.0 - np.dot(a, b) / (norm_a * norm_b)) | |
| def _detection_confidence(result): | |
| """ | |
| Extract the detector confidence score from a DeepFace result dict. | |
| """ | |
| region = result.get("facial_area", {}) | |
| score = region.get("confidence") or result.get("face_confidence") | |
| if score is not None: | |
| return float(score) | |
| return 1.0 | |
| def extract_embedding(image): | |
| """ | |
| Extract a single face embedding from a PIL image using DeepFace. | |
| """ | |
| if not DEEPFACE_AVAILABLE: | |
| return None | |
| try: | |
| result = DeepFace.represent( | |
| img_path=np.array(image.convert("RGB")), | |
| model_name=RECOGNITION_MODEL, | |
| enforce_detection=True, | |
| detector_backend="retinaface", | |
| ) | |
| if result: | |
| conf = _detection_confidence(result[0]) | |
| if conf < MIN_DETECTION_CONFIDENCE: | |
| log.debug("Single embedding rejected, low confidence: %.3f", conf) | |
| return None | |
| return np.array(result[0]["embedding"]) | |
| except Exception as exc: | |
| log.debug("Embedding extraction failed (no face or error): %s", exc) | |
| return None | |
| def extract_all_face_data(image): | |
| """ | |
| Detect all faces in an image and return embeddings + regions via DeepFace. | |
| """ | |
| if not DEEPFACE_AVAILABLE: | |
| return [] | |
| img_array = np.array(image.convert("RGB")) | |
| try: | |
| results = DeepFace.represent( | |
| img_path=img_array, | |
| model_name=RECOGNITION_MODEL, | |
| enforce_detection=True, | |
| detector_backend="retinaface", | |
| ) | |
| except Exception as exc: | |
| log.debug("No faces detected (or error): %s", exc) | |
| return [] | |
| valid_faces = [] | |
| for r in results: | |
| region = r.get("facial_area", {}) | |
| conf = _detection_confidence(r) | |
| if conf < MIN_DETECTION_CONFIDENCE: | |
| log.debug( | |
| "Detection skipped, low confidence %.3f at region %s", conf, region | |
| ) | |
| continue | |
| valid_faces.append({"embedding": np.array(r["embedding"]), "region": region}) | |
| log.info( | |
| "extract_all_face_data: %d raw detections -> %d valid faces", | |
| len(results), | |
| len(valid_faces), | |
| ) | |
| return valid_faces | |
| def match_embedding_to_person(embedding): | |
| best_person_id = None | |
| best_distance = float("inf") | |
| for person_id, stored_embeddings in embedding_store.get_all().items(): | |
| for stored in stored_embeddings: | |
| dist = cosine_distance(embedding, stored) | |
| if dist < best_distance: | |
| best_distance = dist | |
| best_person_id = person_id | |
| if best_distance < RECOGNITION_THRESHOLD: | |
| confidence = round(1.0 - best_distance, 4) | |
| return best_person_id, confidence | |
| return None, 0.0 | |
| def identify_faces_in_image(image): | |
| """ | |
| Detect, match, and classify all faces in image using DeepFace. | |
| """ | |
| face_data = extract_all_face_data(image) | |
| matched = [] | |
| unknown = [] | |
| img_w, img_h = image.size | |
| for fd in face_data: | |
| emb = fd["embedding"] | |
| region = fd.get("region", {}) | |
| person_id, conf = match_embedding_to_person(emb) | |
| if person_id: | |
| embedding_store.add(person_id, emb) | |
| matched.append({"person_id": person_id, "confidence": conf}) | |
| else: | |
| x = region.get("x", 0) | |
| y = region.get("y", 0) | |
| w = region.get("w", img_w) | |
| h = region.get("h", img_h) | |
| x, y = max(0, x), max(0, y) | |
| w = min(w, img_w - x) | |
| h = min(h, img_h - y) | |
| face_crop = image.crop((x, y, x + w, y + h)) | |
| unknown.append({"face_crop": face_crop, "embedding": emb}) | |
| return matched, unknown | |
| def save_face_crop(face_crop, face_id): | |
| filename = f"{face_id}.jpg" | |
| path = FACES_DIR / filename | |
| face_crop = face_crop.resize((200, 200), Image.LANCZOS) | |
| face_crop.save(path, "JPEG", quality=85) | |
| return filename | |
| # CLIP Image Embedding | |
| def encode_image_clip(image): | |
| """Encode a PIL image with CLIP and return a normalised float32 vector.""" | |
| if not CLIP_AVAILABLE: | |
| return None | |
| try: | |
| with torch.no_grad(): | |
| img_tensor = _clip_preprocess(image).unsqueeze(0).to(_clip_device) | |
| feat = _clip_model.encode_image(img_tensor) | |
| feat = feat / feat.norm(dim=-1, keepdim=True) | |
| return feat.cpu().numpy()[0].astype(np.float32) | |
| except Exception as exc: | |
| log.debug("CLIP image encoding failed: %s", exc) | |
| return None | |
| def encode_text_clip(prompts): | |
| """Return a mean-pooled, normalised CLIP text embedding for a list of prompts.""" | |
| if not CLIP_AVAILABLE: | |
| return None | |
| try: | |
| with torch.no_grad(): | |
| tokens = _clip_tokenizer(prompts).to(_clip_device) | |
| feats = _clip_model.encode_text(tokens) | |
| feats = feats / feats.norm(dim=-1, keepdim=True) | |
| mean = feats.mean(dim=0) | |
| mean = mean / mean.norm() | |
| return mean.cpu().numpy().astype(np.float32) | |
| except Exception as exc: | |
| log.debug("CLIP text encoding failed: %s", exc) | |
| return None | |
| # CLIP Multi-Label Auto-Albuming | |
| ALBUM_LABEL_PROMPTS = [ | |
| ("Food", "🍽️", "#d35400", [ | |
| "a photo of food", | |
| "a meal or dish", | |
| ]), | |
| ("Travel", "✈️", "#1abc9c", [ | |
| "a travel destination", | |
| "a tourist location or landmark", | |
| ]), | |
| ("Celebration", "🎉", "#8e44ad", [ | |
| "a celebration or party", | |
| "people celebrating an event", | |
| ]), | |
| ("Nature", "🌿", "#27ae60", [ | |
| "a nature scene", | |
| "outdoor landscape with plants or trees", | |
| ]), | |
| ("Beach", "🏖️", "#2980b9", [ | |
| "a beach or seaside", | |
| "ocean or coastal scene", | |
| ]), | |
| ("Fitness", "💪", "#e74c3c", [ | |
| "exercise or fitness activity", | |
| "people working out or doing sports", | |
| ]), | |
| ("Family", "👨👩👧", "#3498db", [ | |
| "a family photo", | |
| "parents and children together", | |
| ]), | |
| ] | |
| ALBUM_CLIP_THRESHOLD = 0.24 | |
| _album_prompt_cache = None | |
| def _get_album_prompt_embeddings(): | |
| global _album_prompt_cache | |
| if _album_prompt_cache is None: | |
| _album_prompt_cache = {} | |
| for label, _, _, prompts in ALBUM_LABEL_PROMPTS: | |
| per_prompt = [encode_text_clip([p]) for p in prompts] | |
| _album_prompt_cache[label] = per_prompt | |
| log.info("Encoded %d prompts for album '%s'.", len(prompts), label) | |
| return _album_prompt_cache | |
| def classify_image_into_albums(image_embedding): | |
| prompt_embs = _get_album_prompt_embeddings() | |
| assigned = [] | |
| for label, _, _, _ in ALBUM_LABEL_PROMPTS: | |
| per_prompt_embs = prompt_embs.get(label, []) | |
| max_sim = 0.0 | |
| for emb in per_prompt_embs: | |
| if emb is None: | |
| continue | |
| sim = float(np.dot(image_embedding, emb)) | |
| if sim > max_sim: | |
| max_sim = sim | |
| if max_sim >= ALBUM_CLIP_THRESHOLD: | |
| assigned.append(label) | |
| return assigned | |
| def cluster_photos_into_albums(): | |
| with get_db() as conn: | |
| rows = conn.execute(""" | |
| SELECT p.id, p.filename, p.month_label, p.taken_at, | |
| p.clip_embedding, | |
| GROUP_CONCAT(pp.person_id) as people | |
| FROM photos p | |
| LEFT JOIN photo_people pp ON pp.photo_id = p.id | |
| GROUP BY p.id | |
| """).fetchall() | |
| if not rows: | |
| return [] | |
| records = [dict(r) for r in rows] | |
| photo_ids = [r["id"] for r in records] | |
| clip_vectors = [] | |
| for rec in records: | |
| vec = None | |
| if rec.get("clip_embedding"): | |
| try: | |
| vec = np.array(json.loads(rec["clip_embedding"]), dtype=np.float32) | |
| except Exception: | |
| vec = None | |
| if vec is None and CLIP_AVAILABLE: | |
| photo_path = PHOTOS_DIR / rec["filename"] | |
| if photo_path.exists(): | |
| try: | |
| img = Image.open(photo_path).convert("RGB") | |
| vec = encode_image_clip(img) | |
| if vec is not None: | |
| with get_db() as conn: | |
| conn.execute( | |
| "UPDATE photos SET clip_embedding=? WHERE id=?", | |
| (json.dumps(vec.tolist()), rec["id"]), | |
| ) | |
| except Exception as exc: | |
| log.debug("Could not encode %s: %s", rec["filename"], exc) | |
| clip_vectors.append(vec) | |
| assigned_labels_per_photo = [] | |
| if CLIP_AVAILABLE: | |
| for vec in clip_vectors: | |
| if vec is None: | |
| assigned_labels_per_photo.append([]) | |
| else: | |
| assigned_labels_per_photo.append(classify_image_into_albums(vec)) | |
| else: | |
| assigned_labels_per_photo = [[] for _ in records] | |
| with get_db() as conn: | |
| existing_rows = conn.execute("SELECT id, label, emoji, color, scene FROM albums").fetchall() | |
| album_by_label = {r["label"].lower(): dict(r) for r in existing_rows} | |
| prompt_meta = { | |
| label: (emoji, color, label) | |
| for label, emoji, color, _ in ALBUM_LABEL_PROMPTS | |
| } | |
| touched_album_ids = set() | |
| with get_db() as conn: | |
| for idx, label_list in enumerate(assigned_labels_per_photo): | |
| photo_id = photo_ids[idx] | |
| for label in label_list: | |
| key = label.lower() | |
| if key not in album_by_label: | |
| emoji, color, scene = prompt_meta.get(label, ("📷", "#7f8c8d", label)) | |
| album_id = str(uuid.uuid4()) | |
| conn.execute( | |
| "INSERT OR IGNORE INTO albums (id, label, emoji, color, scene) VALUES (?,?,?,?,?)", | |
| (album_id, label, emoji, color, scene), | |
| ) | |
| album_by_label[key] = { | |
| "id": album_id, "label": label, | |
| "emoji": emoji, "color": color, "scene": scene, | |
| } | |
| log.info("Created new album '%s' (%s).", label, album_id) | |
| album_id = album_by_label[key]["id"] | |
| conn.execute( | |
| "INSERT OR IGNORE INTO album_photos (album_id, photo_id) VALUES (?,?)", | |
| (album_id, photo_id), | |
| ) | |
| touched_album_ids.add(album_id) | |
| albums = [] | |
| if touched_album_ids: | |
| with get_db() as conn: | |
| for album_id in touched_album_ids: | |
| row = conn.execute( | |
| "SELECT a.*, COUNT(ap.photo_id) as count " | |
| "FROM albums a LEFT JOIN album_photos ap ON ap.album_id = a.id " | |
| "WHERE a.id=? GROUP BY a.id", | |
| (album_id,) | |
| ).fetchone() | |
| if row: | |
| albums.append({ | |
| "id": row["id"], | |
| "label": row["label"], | |
| "emoji": row["emoji"], | |
| "color": row["color"], | |
| "count": row["count"], | |
| "scene": row["scene"], | |
| }) | |
| log.info( | |
| "CLIP multi-label classification: %d photos -> %d albums touched (CLIP=%s).", | |
| len(records), len(albums), CLIP_AVAILABLE, | |
| ) | |
| return albums | |
| # Constellation Layout | |
| def get_constellation_for_month(month, year): | |
| now = datetime.now() | |
| target_year = year or now.year | |
| try: | |
| month_dt = ( | |
| datetime.strptime(month[:3], "%b") | |
| if len(month) <= 4 | |
| else datetime.strptime(month, "%B") | |
| ) | |
| except ValueError: | |
| month_dt = now | |
| month_label_pattern = f"{month_dt.strftime('%B')} {target_year}" | |
| with get_db() as conn: | |
| month_photos = conn.execute(""" | |
| SELECT ph.id, ph.filename, | |
| GROUP_CONCAT(pp.person_id) as people_ids | |
| FROM photos ph | |
| LEFT JOIN photo_people pp ON pp.photo_id = ph.id | |
| WHERE ph.month_label = ? | |
| GROUP BY ph.id | |
| """, (month_label_pattern,)).fetchall() | |
| month_person_ids = set() | |
| for mp in month_photos: | |
| for pid in (mp["people_ids"] or "").split(","): | |
| if pid: | |
| month_person_ids.add(pid) | |
| if month_person_ids: | |
| placeholders = ",".join("?" * len(month_person_ids)) | |
| people = conn.execute(f""" | |
| SELECT p.id, p.name, p.avatar, p.color, p.initials, p.profile_photo, | |
| p.is_user, | |
| COUNT(DISTINCT pp.photo_id) as photo_count | |
| FROM people p | |
| LEFT JOIN photo_people pp ON pp.person_id = p.id | |
| WHERE (p.id IN ({placeholders}) AND p.is_user = 0) OR p.is_user = 1 | |
| GROUP BY p.id | |
| ORDER BY p.is_user DESC, p.name | |
| """, tuple(month_person_ids)).fetchall() | |
| else: | |
| people = conn.execute(""" | |
| SELECT p.id, p.name, p.avatar, p.color, p.initials, p.profile_photo, | |
| p.is_user, | |
| COUNT(DISTINCT pp.photo_id) as photo_count | |
| FROM people p | |
| LEFT JOIN photo_people pp ON pp.person_id = p.id | |
| WHERE p.is_user = 1 | |
| GROUP BY p.id | |
| """).fetchall() | |
| user_row = conn.execute( | |
| "SELECT value FROM app_settings WHERE key='user_profile_photo'" | |
| ).fetchone() | |
| user_photo = user_row["value"] if user_row else None | |
| user_person_row = conn.execute( | |
| "SELECT id FROM people WHERE is_user=1 LIMIT 1" | |
| ).fetchone() | |
| user_person_id = user_person_row["id"] if user_person_row else None | |
| user_node = None | |
| regular_people = [] | |
| for person in people: | |
| if person["is_user"]: | |
| user_node = person | |
| else: | |
| regular_people.append(person) | |
| def build_person_month_photos(person_id): | |
| result = [] | |
| for mp in month_photos: | |
| ids = (mp["people_ids"] or "").split(",") | |
| if person_id in ids: | |
| result.append({ | |
| "id": mp["id"], | |
| "filename": mp["filename"], | |
| }) | |
| return result[:8] | |
| user_month_photos = [] | |
| if user_person_id: | |
| for mp in month_photos: | |
| ids = (mp["people_ids"] or "").split(",") | |
| if user_person_id in ids: | |
| user_month_photos.append({ | |
| "id": mp["id"], | |
| "filename": mp["filename"], | |
| }) | |
| nodes = [] | |
| for person in regular_people: | |
| nodes.append({ | |
| "id": person["id"], | |
| "name": person["name"], | |
| "avatar": person["avatar"] or "👤", | |
| "color": person["color"] or "#87CEEB", | |
| "initials": person["initials"] or (person["name"][0].upper() if person["name"] else "?"), | |
| "profile_photo": person["profile_photo"], | |
| "photo_count": person["photo_count"], | |
| "is_user": False, | |
| "month_photos": build_person_month_photos(person["id"]), | |
| }) | |
| nodes.sort(key=lambda n: n["photo_count"], reverse=True) | |
| if user_node: | |
| user_data = { | |
| "id": user_node["id"], | |
| "name": user_node["name"], | |
| "avatar": user_node["avatar"], | |
| "color": "#8b7cf8", | |
| "initials": user_node["initials"] or "You", | |
| "profile_photo": user_node["profile_photo"] or user_photo, | |
| "photo_count": len(user_month_photos), | |
| "is_user": True, | |
| "month_photos": user_month_photos[:8], | |
| } | |
| else: | |
| user_data = { | |
| "id": "user", | |
| "name": "You", | |
| "avatar": "", | |
| "color": "#8b7cf8", | |
| "initials": "You", | |
| "profile_photo": user_photo, | |
| "photo_count": len(user_month_photos), | |
| "is_user": True, | |
| "month_photos": user_month_photos[:8], | |
| } | |
| return { | |
| "nodes": nodes, | |
| "user": user_data, | |
| "month_label": month_label_pattern, | |
| "total_photos": len(month_photos), | |
| } | |
| # On This Day | |
| def get_on_this_day(): | |
| today = date.today() | |
| with get_db() as conn: | |
| rows = conn.execute(""" | |
| SELECT p.*, GROUP_CONCAT(pp.person_id) as people | |
| FROM photos p | |
| LEFT JOIN photo_people pp ON pp.photo_id = p.id | |
| WHERE strftime('%m-%d', p.taken_at) = ? | |
| GROUP BY p.id | |
| ORDER BY p.taken_at DESC | |
| LIMIT 1 | |
| """, (f"{today.month:02d}-{today.day:02d}",)).fetchall() | |
| if not rows: | |
| rows = conn.execute(""" | |
| SELECT p.*, GROUP_CONCAT(pp.person_id) as people | |
| FROM photos p | |
| LEFT JOIN photo_people pp ON pp.photo_id = p.id | |
| GROUP BY p.id | |
| ORDER BY p.created_at DESC | |
| LIMIT 1 | |
| """).fetchall() | |
| if not rows: | |
| return None | |
| ph = rows[0] | |
| person_names = [] | |
| if ph["people"]: | |
| for pid in ph["people"].split(","): | |
| row = conn.execute("SELECT name FROM people WHERE id=?", (pid,)).fetchone() | |
| if row: | |
| person_names.append(row["name"]) | |
| return { | |
| "photo_id": ph["id"], | |
| "id": ph["id"], | |
| "filename": ph["filename"], | |
| "month_label": ph["month_label"], | |
| "month": ph["month_label"], | |
| "label": (ph["filename"] or "").replace(ph["id"] + "_", "").rsplit(".", 1)[0] or "A Memory", | |
| "people": person_names, | |
| "taken_at": ph["taken_at"], | |
| "formatted_date": _format_date_display(ph["taken_at"]), | |
| } | |
| # Helpers | |
| def _format_date_display(taken_at): | |
| if not taken_at: | |
| return "" | |
| try: | |
| dt = datetime.fromisoformat(taken_at) | |
| day = str(dt.day) | |
| month = dt.strftime("%B") | |
| year = str(dt.year) | |
| hour = dt.hour | |
| minute = dt.strftime("%M") | |
| period = "am" if hour < 12 else "pm" | |
| hour12 = hour % 12 or 12 | |
| return f"{day} {month} {year} {hour12}:{minute}{period}" | |
| except (ValueError, TypeError): | |
| return taken_at or "" | |
| def _format_photo_row(ph): | |
| filename = ph["filename"] or "" | |
| raw_id = ph["id"] or "" | |
| clean = filename.replace(raw_id + "_", "").rsplit(".", 1)[0] | |
| label = clean.replace("_", " ").replace("-", " ").strip() or "Photo" | |
| people_csv = ph["people"] if "people" in ph.keys() else None | |
| people_list = [p for p in people_csv.split(",") if p] if people_csv else [] | |
| taken_at = ph["taken_at"] if "taken_at" in ph.keys() else None | |
| clip_labels_raw = ph["clip_labels"] if "clip_labels" in ph.keys() else None | |
| clip_labels = [] | |
| if clip_labels_raw: | |
| try: | |
| clip_labels = json.loads(clip_labels_raw) | |
| except Exception: | |
| clip_labels = [clip_labels_raw] | |
| return { | |
| "id": raw_id, | |
| "filename": filename, | |
| "label": label, | |
| "month": ph["month_label"] or "", | |
| "month_label": ph["month_label"] or "", | |
| "year_label": ph["year_label"] if "year_label" in ph.keys() else "", | |
| "clip_labels": clip_labels, | |
| "taken_at": taken_at, | |
| "formatted_date": _format_date_display(taken_at), | |
| "people": people_list, | |
| } | |
| def _format_person_row(row): | |
| name = row["name"] or "Unknown" | |
| initials = row["initials"] or name[0].upper() | |
| avatar = row["avatar"] or "👤" | |
| color = row["color"] or "#87CEEB" | |
| return { | |
| "id": row["id"], | |
| "name": name, | |
| "relationship": row["relationship"] or "Other", | |
| "avatar": avatar, | |
| "color": color, | |
| "initials": initials, | |
| "profile_photo": row["profile_photo"] if "profile_photo" in row.keys() else None, | |
| "photoCount": row["photo_count"] if "photo_count" in row.keys() else 0, | |
| "photo_count": row["photo_count"] if "photo_count" in row.keys() else 0, | |
| "is_user": bool(row["is_user"]) if "is_user" in row.keys() else False, | |
| } | |
| # API Routes | |
| def health_check(): | |
| return { | |
| "status": "ok", | |
| "deepface": DEEPFACE_AVAILABLE, | |
| "clip": CLIP_AVAILABLE, | |
| "recognition_model": RECOGNITION_MODEL if DEEPFACE_AVAILABLE else None, | |
| "recognition_threshold": RECOGNITION_THRESHOLD, | |
| "min_detection_confidence": MIN_DETECTION_CONFIDENCE, | |
| "timestamp": datetime.now(timezone.utc).isoformat(), | |
| } | |
| # Settings | |
| def get_settings(): | |
| with get_db() as conn: | |
| rows = conn.execute("SELECT key, value FROM app_settings").fetchall() | |
| return {r["key"]: r["value"] for r in rows} | |
| async def upload_profile_photo( | |
| file: UploadFile = File(...), | |
| name: Optional[str] = Form("You"), | |
| ): | |
| image_bytes = await file.read() | |
| image = Image.open(io.BytesIO(image_bytes)).convert("RGB") | |
| filename = f"user_{uuid.uuid4()}.jpg" | |
| save_path = PROFILE_DIR / filename | |
| thumb = image.resize((300, 300), Image.LANCZOS) | |
| thumb.save(save_path, "JPEG", quality=90) | |
| user_name = (name or "You").strip() or "You" | |
| with get_db() as conn: | |
| conn.execute( | |
| "INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)", | |
| ("user_profile_photo", filename), | |
| ) | |
| existing = conn.execute( | |
| "SELECT id FROM people WHERE is_user=1 LIMIT 1" | |
| ).fetchone() | |
| if existing: | |
| user_id = existing["id"] | |
| with get_db() as conn: | |
| conn.execute( | |
| "UPDATE people SET name=?, profile_photo=?, initials=? WHERE id=?", | |
| (user_name, filename, user_name[0].upper(), user_id), | |
| ) | |
| else: | |
| user_id = str(uuid.uuid4()) | |
| initials = user_name[0].upper() | |
| with get_db() as conn: | |
| conn.execute( | |
| "INSERT INTO people " | |
| "(id, name, relationship, avatar, color, initials, profile_photo, is_user) " | |
| "VALUES (?,?,?,?,?,?,?,1)", | |
| (user_id, user_name, "Self", "🧑", "#8b7cf8", initials, filename), | |
| ) | |
| embedding = extract_embedding(image) | |
| if embedding is not None: | |
| embedding_store.add(user_id, embedding) | |
| log.info("User face enrolled for person %s", user_id) | |
| return { | |
| "profile_photo": filename, | |
| "url": f"/static/profiles/{filename}", | |
| "user_id": user_id, | |
| "user_name": user_name, | |
| } | |
| # People | |
| class PersonCreate(BaseModel): | |
| name: str | |
| relationship: Optional[str] = None | |
| avatar: Optional[str] = None | |
| color: Optional[str] = None | |
| initials: Optional[str] = None | |
| def create_person(body: PersonCreate): | |
| person_id = str(uuid.uuid4()) | |
| initials = body.initials or (body.name[0].upper() if body.name else "?") | |
| with get_db() as conn: | |
| conn.execute( | |
| "INSERT INTO people (id, name, relationship, avatar, color, initials) VALUES (?,?,?,?,?,?)", | |
| (person_id, body.name, body.relationship, body.avatar, body.color, initials), | |
| ) | |
| return { | |
| "id": person_id, | |
| "name": body.name, | |
| "relationship": body.relationship or "Other", | |
| "avatar": body.avatar or "👤", | |
| "color": body.color or "#87CEEB", | |
| "initials": initials, | |
| "profile_photo": None, | |
| "photoCount": 0, | |
| "photo_count": 0, | |
| "is_user": False, | |
| } | |
| def list_people(): | |
| with get_db() as conn: | |
| rows = conn.execute(""" | |
| SELECT p.*, COUNT(DISTINCT pp.photo_id) as photo_count | |
| FROM people p | |
| LEFT JOIN photo_people pp ON pp.person_id = p.id | |
| GROUP BY p.id | |
| ORDER BY p.name | |
| """).fetchall() | |
| return [_format_person_row(r) for r in rows] | |
| def update_person(person_id: str, body: dict): | |
| with get_db() as conn: | |
| row = conn.execute("SELECT id FROM people WHERE id=?", (person_id,)).fetchone() | |
| if not row: | |
| raise HTTPException(404, "Person not found.") | |
| allowed = {"name", "relationship", "color", "avatar"} | |
| updates = {k: v for k, v in body.items() if k in allowed} | |
| if not updates: | |
| raise HTTPException(422, "No valid fields to update.") | |
| if "name" in updates and updates["name"]: | |
| updates["initials"] = updates["name"][0].upper() | |
| set_clause = ", ".join(f"{k}=?" for k in updates) | |
| with get_db() as conn: | |
| conn.execute(f"UPDATE people SET {set_clause} WHERE id=?", (*updates.values(), person_id)) | |
| with get_db() as conn: | |
| updated = conn.execute( | |
| "SELECT p.*, COUNT(DISTINCT pp.photo_id) as photo_count " | |
| "FROM people p LEFT JOIN photo_people pp ON pp.person_id = p.id " | |
| "WHERE p.id=? GROUP BY p.id", | |
| (person_id,) | |
| ).fetchone() | |
| return _format_person_row(updated) | |
| async def update_person_profile_photo(person_id: str, file: UploadFile = File(...)): | |
| with get_db() as conn: | |
| row = conn.execute("SELECT id, profile_photo FROM people WHERE id=?", (person_id,)).fetchone() | |
| if not row: | |
| raise HTTPException(404, f"Person {person_id} not found.") | |
| image_bytes = await file.read() | |
| image = Image.open(io.BytesIO(image_bytes)).convert("RGB") | |
| old_fn = row["profile_photo"] | |
| if old_fn: | |
| try: | |
| (PROFILE_DIR / old_fn).unlink(missing_ok=True) | |
| except Exception: | |
| pass | |
| filename = f"{person_id}_profile.jpg" | |
| save_path = PROFILE_DIR / filename | |
| thumb = image.resize((300, 300), Image.LANCZOS) | |
| thumb.save(save_path, "JPEG", quality=90) | |
| with get_db() as conn: | |
| conn.execute("UPDATE people SET profile_photo=? WHERE id=?", (filename, person_id)) | |
| embedding = extract_embedding(image) | |
| if embedding is not None: | |
| embedding_store.add(person_id, embedding) | |
| with get_db() as conn: | |
| updated = conn.execute( | |
| "SELECT p.*, COUNT(DISTINCT pp.photo_id) as photo_count " | |
| "FROM people p LEFT JOIN photo_people pp ON pp.person_id = p.id " | |
| "WHERE p.id=? GROUP BY p.id", | |
| (person_id,) | |
| ).fetchone() | |
| return { | |
| "updated": True, | |
| "person_id": person_id, | |
| "profile_photo": filename, | |
| "url": f"/static/profiles/{filename}", | |
| "person": _format_person_row(updated) if updated else None, | |
| } | |
| def delete_person(person_id: str): | |
| embedding_store.remove_person(person_id) | |
| with get_db() as conn: | |
| conn.execute("DELETE FROM photo_people WHERE person_id=?", (person_id,)) | |
| conn.execute("DELETE FROM people WHERE id=?", (person_id,)) | |
| return {"deleted": person_id} | |
| # Face Enrollment | |
| async def enroll_face(person_id: str, file: UploadFile = File(...)): | |
| with get_db() as conn: | |
| row = conn.execute("SELECT id FROM people WHERE id=?", (person_id,)).fetchone() | |
| if not row: | |
| raise HTTPException(404, f"Person {person_id} not found.") | |
| image_bytes = await file.read() | |
| image = Image.open(io.BytesIO(image_bytes)) | |
| embedding = extract_embedding(image) | |
| if embedding is None: | |
| raise HTTPException(422, "No face detected. Please use a clear, front-facing photo.") | |
| embedding_store.add(person_id, embedding) | |
| filename = f"{person_id}_profile.jpg" | |
| save_path = PROFILE_DIR / filename | |
| thumb = image.convert("RGB").resize((300, 300), Image.LANCZOS) | |
| thumb.save(save_path, "JPEG", quality=90) | |
| with get_db() as conn: | |
| conn.execute("UPDATE people SET profile_photo=? WHERE id=?", (filename, person_id)) | |
| return { | |
| "enrolled": True, | |
| "person_id": person_id, | |
| "profile_photo": filename, | |
| "total_references": len(embedding_store.get_all().get(person_id, [])), | |
| } | |
| # Pending Faces | |
| def list_pending_faces(): | |
| with get_db() as conn: | |
| rows = conn.execute( | |
| "SELECT id, photo_id, face_crop FROM pending_faces ORDER BY created_at DESC" | |
| ).fetchall() | |
| return [ | |
| { | |
| "id": r["id"], | |
| "photo_id": r["photo_id"], | |
| "face_url": f"/static/faces/{r['face_crop']}", | |
| } | |
| for r in rows | |
| ] | |
| class ConfirmFaceBody(BaseModel): | |
| person_id: Optional[str] = None | |
| name: Optional[str] = None | |
| relationship: Optional[str] = None | |
| def confirm_face(face_id: str, body: ConfirmFaceBody): | |
| with get_db() as conn: | |
| row = conn.execute("SELECT * FROM pending_faces WHERE id=?", (face_id,)).fetchone() | |
| if not row: | |
| raise HTTPException(404, "Pending face not found.") | |
| embedding = np.array(json.loads(row["embedding"])) if row["embedding"] else None | |
| if not body.person_id: | |
| if not body.name: | |
| raise HTTPException(422, "Provide either person_id or a name for the new person.") | |
| person_id = str(uuid.uuid4()) | |
| initials = body.name[0].upper() | |
| colors = ["#87CEEB", "#DDA0DD", "#98FB98", "#FFB6C1", "#F0E68C", "#AED6F1", "#A9DFBF"] | |
| color = colors[len(body.name) % len(colors)] | |
| face_fn = row["face_crop"] | |
| profile_fn = f"{person_id}_profile.jpg" | |
| import shutil | |
| shutil.copy2(FACES_DIR / face_fn, PROFILE_DIR / profile_fn) | |
| with get_db() as conn: | |
| conn.execute( | |
| "INSERT INTO people " | |
| "(id, name, relationship, avatar, color, initials, profile_photo) " | |
| "VALUES (?,?,?,?,?,?,?)", | |
| (person_id, body.name, body.relationship or "Other", "👤", color, initials, profile_fn), | |
| ) | |
| else: | |
| person_id = body.person_id | |
| profile_fn = None | |
| if embedding is not None: | |
| embedding_store.add(person_id, embedding) | |
| with get_db() as conn: | |
| conn.execute( | |
| "INSERT OR IGNORE INTO photo_people (photo_id, person_id, confidence) VALUES (?,?,?)", | |
| (row["photo_id"], person_id, 0.95), | |
| ) | |
| conn.execute("DELETE FROM pending_faces WHERE id=?", (face_id,)) | |
| with get_db() as conn: | |
| person_row = conn.execute( | |
| "SELECT p.*, COUNT(DISTINCT pp.photo_id) as photo_count " | |
| "FROM people p LEFT JOIN photo_people pp ON pp.person_id = p.id " | |
| "WHERE p.id=? GROUP BY p.id", | |
| (person_id,) | |
| ).fetchone() | |
| return { | |
| "confirmed": True, | |
| "person_id": person_id, | |
| "new_person": body.person_id is None, | |
| "person": _format_person_row(person_row) if person_row else None, | |
| } | |
| def dismiss_pending_face(face_id: str): | |
| with get_db() as conn: | |
| conn.execute("DELETE FROM pending_faces WHERE id=?", (face_id,)) | |
| return {"dismissed": face_id} | |
| # Photos | |
| def list_photos(person_id: Optional[str] = None, limit: int = 200): | |
| with get_db() as conn: | |
| if person_id: | |
| rows = conn.execute(""" | |
| SELECT p.*, GROUP_CONCAT(pp.person_id) as people | |
| FROM photos p | |
| JOIN photo_people pp ON pp.photo_id = p.id | |
| WHERE pp.person_id = ? | |
| GROUP BY p.id | |
| ORDER BY p.taken_at DESC | |
| LIMIT ? | |
| """, (person_id, limit)).fetchall() | |
| else: | |
| rows = conn.execute(""" | |
| SELECT p.*, GROUP_CONCAT(pp.person_id) as people | |
| FROM photos p | |
| LEFT JOIN photo_people pp ON pp.photo_id = p.id | |
| GROUP BY p.id | |
| ORDER BY p.taken_at DESC | |
| LIMIT ? | |
| """, (limit,)).fetchall() | |
| return [_format_photo_row(r) for r in rows] | |
| def list_available_years(): | |
| with get_db() as conn: | |
| rows = conn.execute(""" | |
| SELECT DISTINCT strftime('%Y', taken_at) AS year | |
| FROM photos | |
| WHERE taken_at IS NOT NULL | |
| ORDER BY year DESC | |
| """).fetchall() | |
| years = [r["year"] for r in rows if r["year"]] | |
| return {"years": years} | |
| def get_photo(photo_id: str): | |
| with get_db() as conn: | |
| row = conn.execute(""" | |
| SELECT p.*, GROUP_CONCAT(pp.person_id) as people | |
| FROM photos p | |
| LEFT JOIN photo_people pp ON pp.photo_id = p.id | |
| WHERE p.id = ? | |
| GROUP BY p.id | |
| """, (photo_id,)).fetchone() | |
| if not row: | |
| raise HTTPException(404, "Photo not found.") | |
| return _format_photo_row(row) | |
| # Analyze photos | |
| async def analyze_photo( | |
| file: UploadFile = File(...), | |
| taken_at: Optional[str] = Form(None), | |
| month_label: Optional[str] = Form(None), | |
| ): | |
| """ | |
| 1. Save photo to disk. | |
| 2. CLIP image embedding (normalised). | |
| 3. CLIP album pre-classification (stored for reference). | |
| 4. DeepFace face detection and recognition. | |
| 5. Persist to DB. | |
| 6. Trigger full CLIP album re-clustering. | |
| """ | |
| image_bytes = await file.read() | |
| photo_id = str(uuid.uuid4()) | |
| filename = f"{photo_id}_{file.filename}" | |
| save_path = PHOTOS_DIR / filename | |
| with open(save_path, "wb") as fh: | |
| fh.write(image_bytes) | |
| _raw_image = Image.open(io.BytesIO(image_bytes)) | |
| exif_taken_at = extract_taken_at(_raw_image) | |
| if not taken_at and exif_taken_at: | |
| taken_at = exif_taken_at | |
| log.info("EXIF DateTimeOriginal extracted: %s", taken_at) | |
| image = _raw_image.convert("RGB") | |
| # 1. CLIP image embedding | |
| clip_vec = encode_image_clip(image) | |
| clip_json = json.dumps(clip_vec.tolist()) if clip_vec is not None else None | |
| # 2. CLIP album labels for this photo | |
| clip_labels: list[str] = [] | |
| if clip_vec is not None: | |
| clip_labels = classify_image_into_albums(clip_vec) | |
| clip_labels_json = json.dumps(clip_labels) | |
| # 3. DeepFace face detection and recognition | |
| matched, unknown_faces = identify_faces_in_image(image) | |
| person_ids = [m["person_id"] for m in matched if m["person_id"]] | |
| ref_dt = None | |
| if taken_at: | |
| try: | |
| ref_dt = datetime.fromisoformat(taken_at) | |
| except (ValueError, TypeError): | |
| pass | |
| if ref_dt is None: | |
| ref_dt = datetime.now() | |
| month_label_str = ref_dt.strftime("%B %Y") | |
| year_label = str(ref_dt.year) | |
| # 4. Persist | |
| with get_db() as conn: | |
| conn.execute(""" | |
| INSERT INTO photos | |
| (id, filename, taken_at, month_label, year_label, clip_embedding, clip_labels) | |
| VALUES (?,?,?,?,?,?,?) | |
| """, ( | |
| photo_id, filename, taken_at, month_label_str, year_label, | |
| clip_json, clip_labels_json, | |
| )) | |
| for m in matched: | |
| if m["person_id"]: | |
| conn.execute( | |
| "INSERT OR IGNORE INTO photo_people (photo_id, person_id, confidence) VALUES (?,?,?)", | |
| (photo_id, m["person_id"], m["confidence"]), | |
| ) | |
| # 5. Save unknown faces as pending | |
| pending_face_records: list[dict] = [] | |
| for uf in unknown_faces: | |
| face_id = str(uuid.uuid4()) | |
| face_fn = save_face_crop(uf["face_crop"], face_id) | |
| emb_json = json.dumps(uf["embedding"].tolist()) | |
| with get_db() as conn: | |
| conn.execute( | |
| "INSERT INTO pending_faces (id, photo_id, face_crop, embedding) VALUES (?,?,?,?)", | |
| (face_id, photo_id, face_fn, emb_json), | |
| ) | |
| pending_face_records.append({ | |
| "id": face_id, | |
| "photo_id": photo_id, | |
| "face_url": f"/static/faces/{face_fn}", | |
| }) | |
| # 6. Re-run CLIP multi-label album assignment | |
| try: | |
| cluster_photos_into_albums() | |
| except Exception as e: | |
| log.warning("Album assignment failed after photo import: %s", e) | |
| clean_label = ( | |
| (file.filename or "").rsplit(".", 1)[0].replace("_", " ").replace("-", " ").strip() | |
| or "Photo" | |
| ) | |
| return { | |
| "photo_id": photo_id, | |
| "filename": filename, | |
| "label": clean_label, | |
| "month": month_label_str, | |
| "year": year_label, | |
| "clip_labels": clip_labels, | |
| "formatted_date": _format_date_display(taken_at), | |
| "people": person_ids, | |
| "pending_faces": pending_face_records, | |
| } | |
| # Albums | |
| def trigger_clustering(): | |
| albums = cluster_photos_into_albums() | |
| return {"albums_created": len(albums), "albums": albums} | |
| def list_albums(): | |
| with get_db() as conn: | |
| rows = conn.execute(""" | |
| SELECT a.*, COUNT(ap.photo_id) as count | |
| FROM albums a | |
| LEFT JOIN album_photos ap ON ap.album_id = a.id | |
| GROUP BY a.id | |
| ORDER BY count DESC | |
| """).fetchall() | |
| return [dict(r) for r in rows] | |
| def get_album_photos(album_id: str): | |
| with get_db() as conn: | |
| rows = conn.execute(""" | |
| SELECT p.*, GROUP_CONCAT(pp.person_id) as people | |
| FROM photos p | |
| JOIN album_photos ap ON ap.photo_id = p.id | |
| LEFT JOIN photo_people pp ON pp.photo_id = p.id | |
| WHERE ap.album_id = ? | |
| GROUP BY p.id | |
| """, (album_id,)).fetchall() | |
| return [_format_photo_row(r) for r in rows] | |
| # Constellation Layout | |
| def constellation(month: str, year: Optional[int] = None): | |
| return get_constellation_for_month(month, year) | |
| # On This Day | |
| def on_this_day(): | |
| result = get_on_this_day() | |
| if not result: | |
| raise HTTPException(404, "No memories found yet. Import some photos first!") | |
| return result |