diff --git a/.dockerignore b/.dockerignore index 89fe7008049..018f26f7bb1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -48,6 +48,8 @@ vite/* secrets/* # Local testing @ildyria public/uploads-bck/* +public/uploads/* +*.sql # Node node_modules/ @@ -56,8 +58,12 @@ npm-debug.log # Mapping for database and config used by docker compose lychee/* +# Python +ai-vision-service/* + # Laravel /storage/logs/* +/storage/tmp/* /storage/framework/cache/* /storage/framework/sessions/* /storage/framework/views/* diff --git a/.github/workflows/python_ai_vision.yml b/.github/workflows/python_ai_vision_face_recognition.yml similarity index 95% rename from .github/workflows/python_ai_vision.yml rename to .github/workflows/python_ai_vision_face_recognition.yml index 826493c1154..57b56c608dd 100644 --- a/.github/workflows/python_ai_vision.yml +++ b/.github/workflows/python_ai_vision_face_recognition.yml @@ -1,4 +1,4 @@ -name: Python AI Vision Service +name: Python AI Vision Service for face recognition on: push: @@ -6,10 +6,10 @@ on: - master - assisted-vision paths: - - 'ai-vision-service/**' + - 'ai-vision-service/face-recognition/**' pull_request: paths: - - 'ai-vision-service/**' + - 'ai-vision-service/face-recognition/**' workflow_dispatch: # Declare default permissions as read only. @@ -22,7 +22,7 @@ concurrency: defaults: run: - working-directory: ai-vision-service + working-directory: ai-vision-service/face-recognition jobs: # --------------------------------------------------------------------------- diff --git a/ai-vision-service/Dockerfile b/ai-vision-service/face-recognition/Dockerfile similarity index 100% rename from ai-vision-service/Dockerfile rename to ai-vision-service/face-recognition/Dockerfile diff --git a/ai-vision-service/README.md b/ai-vision-service/face-recognition/README.md similarity index 100% rename from ai-vision-service/README.md rename to ai-vision-service/face-recognition/README.md diff --git a/ai-vision-service/app/__init__.py b/ai-vision-service/face-recognition/app/__init__.py similarity index 100% rename from ai-vision-service/app/__init__.py rename to ai-vision-service/face-recognition/app/__init__.py diff --git a/ai-vision-service/app/api/__init__.py b/ai-vision-service/face-recognition/app/api/__init__.py similarity index 100% rename from ai-vision-service/app/api/__init__.py rename to ai-vision-service/face-recognition/app/api/__init__.py diff --git a/ai-vision-service/app/api/dependencies.py b/ai-vision-service/face-recognition/app/api/dependencies.py similarity index 100% rename from ai-vision-service/app/api/dependencies.py rename to ai-vision-service/face-recognition/app/api/dependencies.py diff --git a/ai-vision-service/app/api/routes.py b/ai-vision-service/face-recognition/app/api/routes.py similarity index 81% rename from ai-vision-service/app/api/routes.py rename to ai-vision-service/face-recognition/app/api/routes.py index eeb528bc88b..929d2543fc3 100644 --- a/ai-vision-service/app/api/routes.py +++ b/ai-vision-service/face-recognition/app/api/routes.py @@ -19,6 +19,10 @@ from app.api.dependencies import get_detector, get_store, require_api_key from app.api.schemas import ( + ClusterFaceResult, + ClusterResponse, + DeleteEmbeddingsRequest, + DeleteEmbeddingsResponse, DetectCallbackPayload, DetectCallbackResponse, DetectRequest, @@ -29,6 +33,7 @@ MatchResult, SuggestionResult, ) +from app.clustering.clusterer import FaceClusterer from app.config import AppSettings, get_settings from app.detection.cropper import generate_crop @@ -124,6 +129,63 @@ async def match( return MatchResponse(matches=[MatchResult(lychee_face_id=face_id, confidence=conf) for face_id, conf in matches]) +# --------------------------------------------------------------------------- +# DELETE /embeddings +# --------------------------------------------------------------------------- + + +@router.delete("/embeddings") +async def delete_embeddings( + body: DeleteEmbeddingsRequest, + request: Request, + _: None = Depends(require_api_key), +) -> DeleteEmbeddingsResponse: + """Delete face embeddings by their Lychee Face IDs. + + Called by Lychee when dismissed faces are permanently removed or when + a photo is deleted. + """ + store: EmbeddingStore = get_store(request) + deleted = store.delete_many(body.face_ids) + return DeleteEmbeddingsResponse(deleted=deleted) + + +# --------------------------------------------------------------------------- +# POST /cluster +# --------------------------------------------------------------------------- + + +@router.post("/cluster") +async def cluster( + request: Request, + settings: AppSettings = Depends(get_settings), + _: None = Depends(require_api_key), +) -> ClusterResponse: + """Run DBSCAN clustering over all stored face embeddings. + + Reads every embedding from the store, clusters them, and returns the + per-face cluster assignments. The PHP layer uses these to set + ``faces.cluster_label`` for the cluster-review UI. + """ + store: EmbeddingStore = get_store(request) + all_embeddings = store.get_all() + + if not all_embeddings: + return ClusterResponse(total_faces=0, num_clusters=0, assignments=[]) + + clusterer = FaceClusterer(eps=settings.cluster_eps) + results = clusterer.cluster(all_embeddings) + + assignments = [ClusterFaceResult(lychee_face_id=fid, cluster_label=label) for fid, label in results] + distinct_labels = {label for _, label in results if label != -1} + + return ClusterResponse( + total_faces=len(results), + num_clusters=len(distinct_labels), + assignments=assignments, + ) + + # --------------------------------------------------------------------------- # GET /health # --------------------------------------------------------------------------- diff --git a/ai-vision-service/app/api/schemas.py b/ai-vision-service/face-recognition/app/api/schemas.py similarity index 79% rename from ai-vision-service/app/api/schemas.py rename to ai-vision-service/face-recognition/app/api/schemas.py index b00c9166167..67734b649eb 100644 --- a/ai-vision-service/app/api/schemas.py +++ b/ai-vision-service/face-recognition/app/api/schemas.py @@ -142,6 +142,53 @@ class MatchResponse(BaseModel): """Top-N matches above ``VISION_FACE_MATCH_THRESHOLD``, ordered by descending confidence.""" +# --------------------------------------------------------------------------- +# DELETE /embeddings - Lychee -> Python +# --------------------------------------------------------------------------- + + +class DeleteEmbeddingsRequest(BaseModel): + """Body sent by Lychee to delete face embeddings from the store.""" + + face_ids: list[str] = Field(min_length=1) + """List of Lychee ``Face.id`` values whose embeddings should be removed.""" + + +class DeleteEmbeddingsResponse(BaseModel): + """Response body for ``DELETE /embeddings``.""" + + deleted: int + """Number of embeddings actually removed (IDs not found are silently skipped).""" + + +# --------------------------------------------------------------------------- +# POST /cluster - Lychee -> Python +# --------------------------------------------------------------------------- + + +class ClusterFaceResult(BaseModel): + """One face's cluster assignment.""" + + lychee_face_id: str + """Lychee ``Face.id``.""" + + cluster_label: int + """DBSCAN cluster label. ``-1`` = noise / unassigned.""" + + +class ClusterResponse(BaseModel): + """Response body for ``POST /cluster``.""" + + total_faces: int + """Total number of embeddings that were clustered.""" + + num_clusters: int + """Number of distinct clusters found (excluding noise).""" + + assignments: list[ClusterFaceResult] + """Per-face cluster label assignments.""" + + # --------------------------------------------------------------------------- # /health # --------------------------------------------------------------------------- diff --git a/ai-vision-service/app/clustering/__init__.py b/ai-vision-service/face-recognition/app/clustering/__init__.py similarity index 100% rename from ai-vision-service/app/clustering/__init__.py rename to ai-vision-service/face-recognition/app/clustering/__init__.py diff --git a/ai-vision-service/app/clustering/clusterer.py b/ai-vision-service/face-recognition/app/clustering/clusterer.py similarity index 87% rename from ai-vision-service/app/clustering/clusterer.py rename to ai-vision-service/face-recognition/app/clustering/clusterer.py index cfe815b7e10..2b3847808c5 100644 --- a/ai-vision-service/app/clustering/clusterer.py +++ b/ai-vision-service/face-recognition/app/clustering/clusterer.py @@ -7,7 +7,12 @@ from __future__ import annotations +import logging + import numpy as np +from sklearn.cluster import DBSCAN + +logger = logging.getLogger(__name__) class FaceClusterer: @@ -50,8 +55,6 @@ def cluster(self, face_embeddings: list[tuple[str, list[float]]]) -> list[tuple[ if not face_embeddings: return [] - from sklearn.cluster import DBSCAN - ids = [fid for fid, _ in face_embeddings] vectors = np.array([emb for _, emb in face_embeddings], dtype=np.float32) @@ -63,7 +66,14 @@ def cluster(self, face_embeddings: list[tuple[str, list[float]]]) -> list[tuple[ norms = np.where(norms == 0, 1.0, norms) vectors = vectors / norms + logger.info( + "Start clustering %d face embeddings with DBSCAN (eps=%.2f, min_samples=%d)", + len(face_embeddings), + self._eps, + self._min_samples, + ) db = DBSCAN(eps=self._eps, min_samples=self._min_samples, metric="cosine") labels: list[int] = db.fit_predict(vectors).tolist() + logger.info("Done clustering %d face embeddings with DBSCAN", len(face_embeddings)) return list(zip(ids, labels, strict=True)) diff --git a/ai-vision-service/app/config.py b/ai-vision-service/face-recognition/app/config.py similarity index 89% rename from ai-vision-service/app/config.py rename to ai-vision-service/face-recognition/app/config.py index e32d55d48ec..0241601cdc1 100644 --- a/ai-vision-service/app/config.py +++ b/ai-vision-service/face-recognition/app/config.py @@ -83,6 +83,16 @@ class AppSettings(BaseSettings): log_level: str = "info" """Uvicorn/application log level.""" + # --- Clustering --- + cluster_eps: float = 0.6 + """DBSCAN epsilon (max cosine distance) for face clustering. + Lower values produce tighter, more homogeneous clusters.""" + + # --- Quality filtering --- + blur_threshold: float = 100.0 + """Laplacian variance threshold for blur detection. + Face crops with a variance below this value are discarded before embedding.""" + model_config = SettingsConfigDict( env_prefix="VISION_FACE_", # Support .env files in development but never require them in production. diff --git a/ai-vision-service/app/detection/__init__.py b/ai-vision-service/face-recognition/app/detection/__init__.py similarity index 100% rename from ai-vision-service/app/detection/__init__.py rename to ai-vision-service/face-recognition/app/detection/__init__.py diff --git a/ai-vision-service/app/detection/cropper.py b/ai-vision-service/face-recognition/app/detection/cropper.py similarity index 100% rename from ai-vision-service/app/detection/cropper.py rename to ai-vision-service/face-recognition/app/detection/cropper.py diff --git a/ai-vision-service/app/detection/detector.py b/ai-vision-service/face-recognition/app/detection/detector.py similarity index 87% rename from ai-vision-service/app/detection/detector.py rename to ai-vision-service/face-recognition/app/detection/detector.py index b52ec889b9a..f390b3dbf14 100644 --- a/ai-vision-service/app/detection/detector.py +++ b/ai-vision-service/face-recognition/app/detection/detector.py @@ -45,10 +45,12 @@ def __init__( self, model_name: str = "buffalo_l", detection_threshold: float = 0.5, + blur_threshold: float = 100.0, model_root: str = "/root/.insightface", ) -> None: self._model_name = model_name self._detection_threshold = detection_threshold + self._blur_threshold = blur_threshold self._model_root = model_root self._app: Any = None # insightface.app.FaceAnalysis - untyped library self._lock = threading.Lock() @@ -137,6 +139,8 @@ def detect_bytes(self, image_bytes: bytes) -> list[DetectedFace]: def _detect_array(self, img: Any) -> list[DetectedFace]: """Run detection on a BGR numpy array (internal).""" + import cv2 + if self._app is None: raise RuntimeError("FaceDetector not loaded - call load() first.") @@ -155,6 +159,20 @@ def _detect_array(self, img: Any) -> list[DetectedFace]: bbox: Any = face.bbox # [x1, y1, x2, y2] absolute pixels x1, y1, x2, y2 = float(bbox[0]), float(bbox[1]), float(bbox[2]), float(bbox[3]) + # Blur filter: compute Laplacian variance on the face crop region. + # A value of 0.0 disables the filter (all faces pass). + if self._blur_threshold > 0.0: + px1 = max(0, int(x1)) + py1 = max(0, int(y1)) + px2 = min(w, int(x2)) + py2 = min(h, int(y2)) + if px2 > px1 and py2 > py1: + crop_region = img[py1:py2, px1:px2] + gray = cv2.cvtColor(crop_region, cv2.COLOR_BGR2GRAY) + variance: float = float(cv2.Laplacian(gray, cv2.CV_64F).var()) + if variance < self._blur_threshold: + continue + # Normalise to [0, 1] and clamp fx = max(0.0, min(1.0, x1 / w)) fy = max(0.0, min(1.0, y1 / h)) diff --git a/ai-vision-service/app/embeddings/__init__.py b/ai-vision-service/face-recognition/app/embeddings/__init__.py similarity index 100% rename from ai-vision-service/app/embeddings/__init__.py rename to ai-vision-service/face-recognition/app/embeddings/__init__.py diff --git a/ai-vision-service/app/embeddings/factory.py b/ai-vision-service/face-recognition/app/embeddings/factory.py similarity index 100% rename from ai-vision-service/app/embeddings/factory.py rename to ai-vision-service/face-recognition/app/embeddings/factory.py diff --git a/ai-vision-service/app/embeddings/pgvector_store.py b/ai-vision-service/face-recognition/app/embeddings/pgvector_store.py similarity index 83% rename from ai-vision-service/app/embeddings/pgvector_store.py rename to ai-vision-service/face-recognition/app/embeddings/pgvector_store.py index e1dad9fa624..6f30410080f 100644 --- a/ai-vision-service/app/embeddings/pgvector_store.py +++ b/ai-vision-service/face-recognition/app/embeddings/pgvector_store.py @@ -101,6 +101,31 @@ def similarity_search( rows: list[Any] = cur.fetchall() return [(row[0], float(row[1])) for row in rows] + def delete_many(self, lychee_face_ids: list[str]) -> int: + """Remove multiple embeddings by Lychee Face ID.""" + if not lychee_face_ids: + return 0 + with self._lock: + conn = self._get_conn() + with conn.cursor() as cur: + # Use ANY(%s) with a list parameter for batch delete + cur.execute( + "DELETE FROM face_embeddings WHERE lychee_face_id = ANY(%s)", + (lychee_face_ids,), + ) + deleted: int = cur.rowcount + conn.commit() + return deleted + + def get_all(self) -> list[tuple[str, list[float]]]: + """Return all stored embeddings as (face_id, embedding) pairs.""" + with self._lock: + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute("SELECT lychee_face_id, embedding FROM face_embeddings") + rows: list[Any] = cur.fetchall() + return [(row[0], [float(v) for v in row[1]]) for row in rows] + def count(self) -> int: """Return the total number of stored embeddings.""" with self._lock: diff --git a/ai-vision-service/app/embeddings/sqlite_store.py b/ai-vision-service/face-recognition/app/embeddings/sqlite_store.py similarity index 78% rename from ai-vision-service/app/embeddings/sqlite_store.py rename to ai-vision-service/face-recognition/app/embeddings/sqlite_store.py index 5d79c1a7fae..503f1365523 100644 --- a/ai-vision-service/app/embeddings/sqlite_store.py +++ b/ai-vision-service/face-recognition/app/embeddings/sqlite_store.py @@ -115,6 +115,48 @@ def similarity_search( finally: conn.close() + def delete_many(self, lychee_face_ids: list[str]) -> int: + """Remove multiple embeddings by Lychee Face ID.""" + if not lychee_face_ids: + return 0 + deleted = 0 + with self._lock: + conn = self._connect() + try: + for fid in lychee_face_ids: + row = conn.execute( + "SELECT vec_rowid FROM face_meta WHERE lychee_face_id = ?", + [fid], + ).fetchone() + if row is not None: + conn.execute("DELETE FROM vec_faces WHERE rowid = ?", [row[0]]) + conn.execute("DELETE FROM face_meta WHERE lychee_face_id = ?", [fid]) + deleted += 1 + conn.commit() + finally: + conn.close() + return deleted + + def get_all(self) -> list[tuple[str, list[float]]]: + """Return all stored embeddings as (face_id, embedding) pairs.""" + conn = self._connect() + try: + rows = conn.execute( + """ + SELECT m.lychee_face_id, v.face_embedding + FROM face_meta m + JOIN vec_faces v ON v.rowid = m.vec_rowid + """, + ).fetchall() + results: list[tuple[str, list[float]]] = [] + for lychee_face_id, blob in rows: + count = len(blob) // 4 # float32 = 4 bytes + embedding = list(struct.unpack(f"{count}f", blob)) + results.append((lychee_face_id, embedding)) + return results + finally: + conn.close() + def count(self) -> int: """Return the number of stored embeddings.""" conn = self._connect() diff --git a/ai-vision-service/app/embeddings/store.py b/ai-vision-service/face-recognition/app/embeddings/store.py similarity index 74% rename from ai-vision-service/app/embeddings/store.py rename to ai-vision-service/face-recognition/app/embeddings/store.py index a4ee52f8091..2bb425e2754 100644 --- a/ai-vision-service/app/embeddings/store.py +++ b/ai-vision-service/face-recognition/app/embeddings/store.py @@ -58,6 +58,27 @@ def similarity_search( """ ... + def delete_many(self, lychee_face_ids: list[str]) -> int: + """Remove multiple embeddings by Lychee Face ID. + + Args: + lychee_face_ids: List of Lychee ``Face.id`` strings to remove. + + Returns: + Number of embeddings actually deleted (IDs not found are silently + skipped). + """ + ... + + def get_all(self) -> list[tuple[str, list[float]]]: + """Return all stored embeddings. + + Returns: + List of ``(lychee_face_id, embedding)`` pairs. Used by the + clustering endpoint to read the full dataset into memory. + """ + ... + def count(self) -> int: """Return the total number of stored embeddings.""" ... diff --git a/ai-vision-service/app/main.py b/ai-vision-service/face-recognition/app/main.py similarity index 98% rename from ai-vision-service/app/main.py rename to ai-vision-service/face-recognition/app/main.py index 80a84a94f7a..ebabe197799 100644 --- a/ai-vision-service/app/main.py +++ b/ai-vision-service/face-recognition/app/main.py @@ -48,6 +48,7 @@ async def _default_lifespan(app: FastAPI) -> AsyncGenerator[None]: detector = FaceDetector( model_name=settings.model_name, detection_threshold=settings.detection_threshold, + blur_threshold=settings.blur_threshold, ) detector.load() logger.info("InsightFace model '%s' loaded successfully", settings.model_name) diff --git a/ai-vision-service/app/matching/__init__.py b/ai-vision-service/face-recognition/app/matching/__init__.py similarity index 100% rename from ai-vision-service/app/matching/__init__.py rename to ai-vision-service/face-recognition/app/matching/__init__.py diff --git a/ai-vision-service/app/matching/matcher.py b/ai-vision-service/face-recognition/app/matching/matcher.py similarity index 100% rename from ai-vision-service/app/matching/matcher.py rename to ai-vision-service/face-recognition/app/matching/matcher.py diff --git a/ai-vision-service/pyproject.toml b/ai-vision-service/face-recognition/pyproject.toml similarity index 100% rename from ai-vision-service/pyproject.toml rename to ai-vision-service/face-recognition/pyproject.toml diff --git a/ai-vision-service/tests/__init__.py b/ai-vision-service/face-recognition/tests/__init__.py similarity index 100% rename from ai-vision-service/tests/__init__.py rename to ai-vision-service/face-recognition/tests/__init__.py diff --git a/ai-vision-service/tests/conftest.py b/ai-vision-service/face-recognition/tests/conftest.py similarity index 100% rename from ai-vision-service/tests/conftest.py rename to ai-vision-service/face-recognition/tests/conftest.py diff --git a/ai-vision-service/tests/test_api.py b/ai-vision-service/face-recognition/tests/test_api.py similarity index 100% rename from ai-vision-service/tests/test_api.py rename to ai-vision-service/face-recognition/tests/test_api.py diff --git a/ai-vision-service/tests/test_clustering.py b/ai-vision-service/face-recognition/tests/test_clustering.py similarity index 100% rename from ai-vision-service/tests/test_clustering.py rename to ai-vision-service/face-recognition/tests/test_clustering.py diff --git a/ai-vision-service/tests/test_cropper.py b/ai-vision-service/face-recognition/tests/test_cropper.py similarity index 100% rename from ai-vision-service/tests/test_cropper.py rename to ai-vision-service/face-recognition/tests/test_cropper.py diff --git a/ai-vision-service/tests/test_detection.py b/ai-vision-service/face-recognition/tests/test_detection.py similarity index 100% rename from ai-vision-service/tests/test_detection.py rename to ai-vision-service/face-recognition/tests/test_detection.py diff --git a/ai-vision-service/tests/test_embeddings.py b/ai-vision-service/face-recognition/tests/test_embeddings.py similarity index 100% rename from ai-vision-service/tests/test_embeddings.py rename to ai-vision-service/face-recognition/tests/test_embeddings.py diff --git a/ai-vision-service/tests/test_matching.py b/ai-vision-service/face-recognition/tests/test_matching.py similarity index 100% rename from ai-vision-service/tests/test_matching.py rename to ai-vision-service/face-recognition/tests/test_matching.py diff --git a/ai-vision-service/tests/test_smoke.py b/ai-vision-service/face-recognition/tests/test_smoke.py similarity index 100% rename from ai-vision-service/tests/test_smoke.py rename to ai-vision-service/face-recognition/tests/test_smoke.py diff --git a/ai-vision-service/uv.lock b/ai-vision-service/face-recognition/uv.lock similarity index 100% rename from ai-vision-service/uv.lock rename to ai-vision-service/face-recognition/uv.lock diff --git a/app/Http/Controllers/Admin/Maintenance/RunFaceClustering.php b/app/Http/Controllers/Admin/Maintenance/RunFaceClustering.php new file mode 100644 index 00000000000..9588c559219 --- /dev/null +++ b/app/Http/Controllers/Admin/Maintenance/RunFaceClustering.php @@ -0,0 +1,55 @@ +json(['status' => 'error', 'message' => 'AI Vision service not configured.'], 503); + } + + try { + $response = Http::withHeaders(['X-API-Key' => $api_key]) + ->post($service_url . '/cluster'); + + if ($response->successful()) { + return response()->json(['status' => 'dispatched'], 200); + } + + Log::warning('RunFaceClustering: /cluster returned HTTP ' . $response->status() . '.'); + + return response()->json(['status' => 'error', 'message' => 'Clustering service returned HTTP ' . $response->status()], 503); + } catch (\Exception $e) { + Log::warning('RunFaceClustering: request failed: ' . $e->getMessage()); + + return response()->json(['status' => 'error', 'message' => $e->getMessage()], 503); + } + } +} diff --git a/app/Http/Controllers/AiVision/FaceClusterController.php b/app/Http/Controllers/AiVision/FaceClusterController.php new file mode 100644 index 00000000000..5906962a243 --- /dev/null +++ b/app/Http/Controllers/AiVision/FaceClusterController.php @@ -0,0 +1,89 @@ +query('page', '1'); + $totals = Face::query() + ->whereNotNull('cluster_label') + ->where('cluster_label', '>', -1) + ->whereNull('person_id') + ->where('is_dismissed', '=', false) + ->selectRaw('cluster_label, COUNT(*) as face_count') + ->groupBy('cluster_label') + ->orderBy('face_count', 'desc') + ->get(); + $total = $totals->count(); + $paginated = $totals->forPage($page, self::PER_PAGE); + $items = $paginated->map(function (Face $row): ClusterPreviewResource { + $cluster_label = (int) $row->cluster_label; + $face_count = (int) $row->face_count; // @phpstan-ignore property.notFound (see line 34) + $samples = Face::query() + ->where('cluster_label', '=', $cluster_label) + ->whereNull('person_id') + ->where('is_dismissed', '=', false) + ->whereNotNull('crop_token') + ->orderByDesc('confidence') + ->limit(self::SAMPLE_SIZE) + ->pluck('crop_token') + ->map(static fn ($tok) => 'uploads/faces/' . substr($tok, 0, 2) . '/' . substr($tok, 2, 2) . '/' . $tok . '.jpg') + ->all(); + + return new ClusterPreviewResource($cluster_label, $face_count, $samples); + }); + + return new LengthAwarePaginator($items, $total, self::PER_PAGE, $page, ['path' => $request->url()]); + } + + public function assign(ClusterAssignRequest $request, int $label): array + { + if ($request->personId() !== null) { + $person_id = $request->personId(); + } else { + $is_searchable_default = app(ConfigManager::class)->getValueAsBool('ai_vision_face_person_is_searchable_default'); + $person = new Person(); + $person->name = (string) $request->newPersonName(); + $person->is_searchable = $is_searchable_default; + $person->save(); + $person_id = $person->id; + } + $count = Face::where('cluster_label', '=', $label) + ->whereNull('person_id') + ->where('is_dismissed', '=', false) + ->update(['person_id' => $person_id]); + + return ['assigned_count' => $count]; + } + + public function dismiss(ClusterDismissRequest $request, int $label): array + { + $count = Face::where('cluster_label', '=', $label) + ->whereNull('person_id') + ->where('is_dismissed', '=', false) + ->update(['is_dismissed' => true]); + + return ['dismissed_count' => $count]; + } +} diff --git a/app/Http/Controllers/AiVision/FaceController.php b/app/Http/Controllers/AiVision/FaceController.php index 2535fceb809..ff828284d16 100644 --- a/app/Http/Controllers/AiVision/FaceController.php +++ b/app/Http/Controllers/AiVision/FaceController.php @@ -12,6 +12,7 @@ use App\Http\Requests\Face\DestroyDismissedFacesRequest; use App\Http\Requests\Face\ToggleDismissedRequest; use App\Http\Resources\Models\FaceResource; +use App\Jobs\DeleteFaceEmbeddingsJob; use App\Models\Face; use App\Models\Person; use App\Repositories\ConfigManager; @@ -78,6 +79,7 @@ public function toggleDismissed(ToggleDismissedRequest $request, string $id): Fa public function destroyDismissed(DestroyDismissedFacesRequest $_request): array { $dismissed_faces = Face::where('is_dismissed', '=', true)->get(); + $face_ids = $dismissed_faces->pluck('id')->all(); $count = 0; foreach ($dismissed_faces as $face) { @@ -91,6 +93,10 @@ public function destroyDismissed(DestroyDismissedFacesRequest $_request): array $count++; } + if ($face_ids !== []) { + DeleteFaceEmbeddingsJob::dispatch($face_ids); + } + return ['deleted_count' => $count]; } } diff --git a/app/Http/Controllers/AiVision/FaceDetectionController.php b/app/Http/Controllers/AiVision/FaceDetectionController.php index 519e8606d24..00662541309 100644 --- a/app/Http/Controllers/AiVision/FaceDetectionController.php +++ b/app/Http/Controllers/AiVision/FaceDetectionController.php @@ -10,6 +10,7 @@ use App\Enum\FaceScanStatus; use App\Http\Requests\Face\BulkScanRequest; +use App\Http\Requests\Face\ClusterResultsRequest; use App\Http\Requests\Face\FaceDetectionResultsRequest; use App\Http\Requests\Face\ScanPhotosRequest; use App\Jobs\DispatchFaceScanJob; @@ -146,6 +147,40 @@ public function bulkScan(BulkScanRequest $request): \Illuminate\Http\Response return response()->noContent(202); } + /** + * Receive face clustering results callback from the Python service. + * Authentication is exclusively via X-API-Key header; no user session required. + * + * POST /FaceDetection/cluster-results + * + * @return \Illuminate\Http\Response + */ + public function clusterResults(ClusterResultsRequest $request): \Illuminate\Http\Response + { + DB::transaction(function () use ($request): void { + // Reset all existing cluster labels so stale assignments are removed. + Face::whereNotNull('cluster_label')->update(['cluster_label' => null]); + + // Apply new cluster labels in bulk. + foreach ($request->labels() as $item) { + Face::where('id', '=', $item['face_id']) + ->update(['cluster_label' => $item['cluster_label']]); + } + + // Upsert cross-cluster suggestions. + foreach ($request->suggestions() as $sug) { + FaceSuggestion::updateOrCreate( + ['face_id' => $sug['face_id'], 'suggested_face_id' => $sug['suggested_face_id']], + ['confidence' => $sug['confidence']], + ); + } + }); + + Log::info('FaceDetectionController::clusterResults — updated ' . count($request->labels()) . ' cluster labels.'); + + return response()->noContent(202); + } + /** * Process incoming face detection results for a photo. * IoU-matches new faces against old faces to preserve person assignments. diff --git a/app/Http/Controllers/AiVision/PersonPhotosController.php b/app/Http/Controllers/AiVision/PersonPhotosController.php index bc85a13c1b0..5db5c3ff274 100644 --- a/app/Http/Controllers/AiVision/PersonPhotosController.php +++ b/app/Http/Controllers/AiVision/PersonPhotosController.php @@ -9,8 +9,10 @@ namespace App\Http\Controllers\AiVision; use App\Http\Requests\Person\ListPersonsRequest; +use App\Http\Resources\Models\PhotoResource; use App\Models\Person; use App\Models\Photo; +use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; @@ -29,9 +31,11 @@ class PersonPhotosController extends Controller * - Authenticated user: photos they own + photos in public albums * - Guest: photos in public albums only. * - * @return \Illuminate\Pagination\LengthAwarePaginator<\stdClass> + * Returns the standard Laravel paginator JSON shape: {data, links, meta}. + * + * @return LengthAwarePaginator */ - public function index(ListPersonsRequest $_request, string $id) + public function index(ListPersonsRequest $_request, string $id): LengthAwarePaginator { $user = Auth::user(); $person = Person::findOrFail($id); @@ -42,9 +46,11 @@ public function index(ListPersonsRequest $_request, string $id) } $query = Photo::query() - ->select(['photos.id', 'photos.title']) + ->select('photos.*') ->join('faces', 'faces.photo_id', '=', 'photos.id') ->where('faces.person_id', '=', $id) + ->with(['size_variants', 'tags', 'palette', 'statistics', 'rating', + 'faces.person', 'faces.suggestions.suggestedFace.person']) ->orderBy('photos.taken_at', 'desc'); // Access control: restrict to photos accessible to the current user @@ -66,6 +72,13 @@ public function index(ListPersonsRequest $_request, string $id) }); } - return $query->distinct()->paginate(50); + /** @var LengthAwarePaginator */ + return $query->distinct()->paginate(50)->through( + fn (Photo $photo) => new PhotoResource( + photo: $photo, + album_id: null, + should_downgrade_size_variants: false, + ) + ); } } diff --git a/app/Http/Requests/Face/ClusterAssignRequest.php b/app/Http/Requests/Face/ClusterAssignRequest.php new file mode 100644 index 00000000000..c82612fbd78 --- /dev/null +++ b/app/Http/Requests/Face/ClusterAssignRequest.php @@ -0,0 +1,49 @@ + ['nullable', 'string', 'exists:persons,id'], + 'new_person_name' => ['nullable', 'string', 'max:255'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->person_id = $values['person_id'] ?? null; + $this->new_person_name = $values['new_person_name'] ?? null; + } + + public function personId(): ?string + { + return $this->person_id; + } + + public function newPersonName(): ?string + { + return $this->new_person_name; + } +} diff --git a/app/Http/Requests/Face/ClusterDismissRequest.php b/app/Http/Requests/Face/ClusterDismissRequest.php new file mode 100644 index 00000000000..289892541d1 --- /dev/null +++ b/app/Http/Requests/Face/ClusterDismissRequest.php @@ -0,0 +1,31 @@ + */ + private array $labels = []; + + /** @var array */ + private array $suggestions = []; + + public function authorize(): bool + { + $expected_key = config('features.ai-vision.face-api-key', ''); + $provided_key = $this->header('X-API-Key', ''); + + return $expected_key !== '' && $provided_key === $expected_key; + } + + public function rules(): array + { + return [ + 'labels' => 'required|array', + 'labels.*.face_id' => 'required|string', + 'labels.*.cluster_label' => 'required|integer', + 'suggestions' => 'sometimes|array', + 'suggestions.*.face_id' => 'required|string|exists:faces,id', + 'suggestions.*.suggested_face_id' => 'required|string|exists:faces,id', + 'suggestions.*.confidence' => 'required|numeric', + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->labels = $values['labels'] ?? []; + $this->suggestions = $values['suggestions'] ?? []; + } + + /** + * @return array + */ + public function labels(): array + { + return $this->labels; + } + + /** + * @return array + */ + public function suggestions(): array + { + return $this->suggestions; + } +} diff --git a/app/Http/Resources/Models/ClusterPreviewResource.php b/app/Http/Resources/Models/ClusterPreviewResource.php new file mode 100644 index 00000000000..7ac1cb6046c --- /dev/null +++ b/app/Http/Resources/Models/ClusterPreviewResource.php @@ -0,0 +1,32 @@ + Up to 5 sample crop URLs for preview thumbnails. */ + public array $sample_crop_urls; + + public function __construct(int $cluster_label, int $face_count, array $sample_crop_urls) + { + $this->cluster_label = $cluster_label; + $this->face_count = $face_count; + $this->sample_crop_urls = $sample_crop_urls; + } +} diff --git a/app/Http/Resources/Models/FaceResource.php b/app/Http/Resources/Models/FaceResource.php index 63c8f7c8542..d1d84c241fa 100644 --- a/app/Http/Resources/Models/FaceResource.php +++ b/app/Http/Resources/Models/FaceResource.php @@ -26,6 +26,7 @@ class FaceResource extends Data public float $confidence; public bool $is_dismissed; public ?string $crop_url; + public ?string $person_name; /** @var FaceSuggestionResource[] */ public array $suggestions; @@ -41,6 +42,7 @@ public function __construct(Face $face) $this->confidence = $face->confidence; $this->is_dismissed = $face->is_dismissed; $this->crop_url = $face->crop_url; + $this->person_name = $face->person?->name; $this->suggestions = $face->suggestions ->map(fn (FaceSuggestion $s) => new FaceSuggestionResource($s)) ->values() diff --git a/app/Http/Resources/Models/PersonResource.php b/app/Http/Resources/Models/PersonResource.php index c194ac1e0ce..88ac6f628ec 100644 --- a/app/Http/Resources/Models/PersonResource.php +++ b/app/Http/Resources/Models/PersonResource.php @@ -19,6 +19,7 @@ class PersonResource extends Data public string $name; public ?int $user_id; public bool $is_searchable; + public ?string $representative_face_id; public int $face_count; public int $photo_count; public ?string $representative_crop_url; @@ -29,10 +30,28 @@ public function __construct(Person $person) $this->name = $person->name; $this->user_id = $person->user_id; $this->is_searchable = $person->is_searchable; + $this->representative_face_id = $person->representative_face_id; $this->face_count = $person->faces()->count(); $this->photo_count = $person->faces()->distinct('photo_id')->count('photo_id'); - $crop_token = $person->faces()->whereNotNull('crop_token')->value('crop_token'); + // Representative crop: prefer the explicitly pinned face's crop, + // fall back to the highest-confidence non-dismissed face with a crop. + $crop_token = null; + if ($person->representative_face_id !== null) { + $crop_token = $person->faces() + ->where('id', '=', $person->representative_face_id) + ->whereNotNull('crop_token') + ->value('crop_token'); + } + + if ($crop_token === null) { + $crop_token = $person->faces() + ->where('is_dismissed', '=', false) + ->whereNotNull('crop_token') + ->orderByDesc('confidence') + ->value('crop_token'); + } + if ($crop_token !== null) { $this->representative_crop_url = 'uploads/faces/' . substr($crop_token, 0, 2) . '/' . substr($crop_token, 2, 2) . '/' . $crop_token . '.jpg'; } else { diff --git a/app/Http/Resources/Models/PhotoResource.php b/app/Http/Resources/Models/PhotoResource.php index ac60406d527..947f0825b11 100644 --- a/app/Http/Resources/Models/PhotoResource.php +++ b/app/Http/Resources/Models/PhotoResource.php @@ -8,10 +8,12 @@ namespace App\Http\Resources\Models; +use App\Enum\FacePermissionMode; use App\Enum\LicenseType; use App\Http\Resources\Models\Utils\PreComputedPhotoData; use App\Http\Resources\Models\Utils\PreformattedPhotoData; use App\Http\Resources\Models\Utils\TimelineData; +use App\Models\Face; use App\Models\Photo; use App\Policies\PhotoPolicy; use Illuminate\Support\Carbon; @@ -53,6 +55,9 @@ class PhotoResource extends Data public ?PhotoStatisticsResource $statistics = null; public ?PhotoRatingResource $rating = null; + /** @var FaceResource[] */ + public array $faces = []; + public int $hidden_face_count = 0; public function __construct(Photo $photo, ?string $album_id, bool $should_downgrade_size_variants) { @@ -95,6 +100,60 @@ public function __construct(Photo $photo, ?string $album_id, bool $should_downgr request()->configs(), ); } + + $this->buildFaceData($photo); + } + + /** + * Populate faces[] and hidden_face_count from the photo's detected faces. + * + * Only runs when ai_vision_face_enabled is on and the viewer has permission + * to see face overlays per the ai_vision_face_permission_mode matrix. + * Non-searchable persons are hidden from viewers who are not the linked user; + * they are counted in hidden_face_count instead. + */ + private function buildFaceData(Photo $photo): void + { + if (!request()->configs()->getValueAsBool('ai_vision_enabled') || + !request()->configs()->getValueAsBool('ai_vision_face_enabled')) { + return; + } + + $user = Auth::user(); + $is_admin = $user?->may_administrate === true; + + // Check per-mode overlay visibility (View face overlays row in permission matrix). + $mode = request()->configs()->getValueAsEnum('ai_vision_face_permission_mode', FacePermissionMode::class) ?? FacePermissionMode::RESTRICTED; + $can_view_overlays = $is_admin || match ($mode) { + FacePermissionMode::PUBLIC => true, + FacePermissionMode::PRIVATE => $user !== null, + FacePermissionMode::PRIVACY_PRESERVING, + FacePermissionMode::RESTRICTED => $user !== null && $photo->owner_id === $user->id, + }; + + if (!$can_view_overlays) { + return; + } + + foreach ($photo->faces as $face) { + /** @var Face $face */ + if ($face->is_dismissed) { + continue; + } + + // Unassigned face or searchable person: always include. + if ($face->person_id === null || ($face->person !== null && $face->person->is_searchable)) { + $this->faces[] = new FaceResource($face); + continue; + } + + // Non-searchable person: visible only to the linked user or admin. + if ($is_admin || ($user !== null && $face->person?->user_id === $user->id)) { + $this->faces[] = new FaceResource($face); + } else { + $this->hidden_face_count++; + } + } } /** diff --git a/app/Jobs/DeleteFaceEmbeddingsJob.php b/app/Jobs/DeleteFaceEmbeddingsJob.php new file mode 100644 index 00000000000..de89cca9be4 --- /dev/null +++ b/app/Jobs/DeleteFaceEmbeddingsJob.php @@ -0,0 +1,68 @@ + */ + public array $face_ids; + + /** + * @param list $face_ids + */ + public function __construct(array $face_ids) + { + $this->face_ids = $face_ids; + } + + public function handle(): void + { + if ($this->face_ids === []) { + return; + } + + $service_url = config('features.ai-vision.face-url', ''); + $api_key = config('features.ai-vision.face-api-key', ''); + + if ($service_url === '') { + Log::warning('DeleteFaceEmbeddingsJob: AI Vision service not configured.'); + + return; + } + + try { + $response = Http::withHeaders(['X-API-Key' => $api_key]) + ->delete($service_url . '/embeddings', ['face_ids' => $this->face_ids]); + + $ok = $response->successful(); + if (!$ok) { + Log::warning('DeleteFaceEmbeddingsJob: /embeddings DELETE returned HTTP ' . $response->status() . '.', ['face_ids' => $this->face_ids]); + } + } catch (\Exception $e) { + Log::warning('DeleteFaceEmbeddingsJob: request failed: ' . $e->getMessage(), ['face_ids' => $this->face_ids]); + } + } +} diff --git a/app/Models/Face.php b/app/Models/Face.php index 92b56df178b..663c23f3e3c 100644 --- a/app/Models/Face.php +++ b/app/Models/Face.php @@ -31,6 +31,7 @@ * @property float $confidence * @property string|null $crop_token * @property bool $is_dismissed + * @property int|null $cluster_label * @property Carbon $created_at * @property Carbon $updated_at * @property Photo $photo @@ -71,32 +72,36 @@ class Face extends Model 'confidence', 'crop_token', 'is_dismissed', + 'cluster_label', ]; /** - * @var array + * @var list */ - protected $casts = [ - 'created_at' => 'datetime', - 'updated_at' => 'datetime', - 'x' => 'float', - 'y' => 'float', - 'width' => 'float', - 'height' => 'float', - 'confidence' => 'float', - 'is_dismissed' => 'boolean', + protected $appends = [ + 'crop_url', ]; /** - * @var list + * @return array */ - protected $appends = ['crop_url']; + protected function casts(): array + { + return [ + 'x' => 'float', + 'y' => 'float', + 'width' => 'float', + 'height' => 'float', + 'confidence' => 'float', + 'is_dismissed' => 'boolean', + 'cluster_label' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } /** - * Get the crop URL derived from the crop_token. - * Path format: uploads/faces/{tok[0:2]}/{tok[2:4]}/{tok}.jpg. - * - * @return string|null + * Compute the crop URL from the crop_token. */ public function getCropUrlAttribute(): ?string { diff --git a/app/Models/Person.php b/app/Models/Person.php index 4a250515a7e..b03657fa42c 100644 --- a/app/Models/Person.php +++ b/app/Models/Person.php @@ -25,10 +25,12 @@ * @property string $name * @property int|null $user_id * @property bool $is_searchable + * @property string|null $representative_face_id * @property Carbon $created_at * @property Carbon $updated_at * @property User|null $user * @property Collection $faces + * @property Face|null $representativeFace */ class Person extends Model { @@ -59,17 +61,22 @@ class Person extends Model 'name', 'user_id', 'is_searchable', + 'representative_face_id', ]; /** - * @var array + * @return array */ - protected $casts = [ - 'created_at' => 'datetime', - 'updated_at' => 'datetime', - 'is_searchable' => 'boolean', - 'user_id' => 'integer', - ]; + protected function casts(): array + { + return [ + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'is_searchable' => 'boolean', + 'user_id' => 'integer', + 'representative_face_id' => 'string', + ]; + } /** * Return the user linked to this person. @@ -92,8 +99,16 @@ public function faces(): HasMany } /** - * Scope to only return searchable persons. + * Return the representative face for this person (nullable). * + * @return BelongsTo + */ + public function representativeFace(): BelongsTo + { + return $this->belongsTo(Face::class, 'representative_face_id', 'id'); + } + + /** * @param \Illuminate\Database\Eloquent\Builder $query * * @return \Illuminate\Database\Eloquent\Builder diff --git a/app/Observers/PhotoObserver.php b/app/Observers/PhotoObserver.php new file mode 100644 index 00000000000..1fa7ab31beb --- /dev/null +++ b/app/Observers/PhotoObserver.php @@ -0,0 +1,34 @@ +faces()->pluck('id')->all(); + if ($face_ids !== []) { + DeleteFaceEmbeddingsJob::dispatch($face_ids); + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2bed6d15ed2..7abf866a099 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -27,6 +27,8 @@ use App\Metadata\Versions\Remote\GitCommits; use App\Metadata\Versions\Remote\GitTags; use App\Models\Configs; +use App\Models\Photo; +use App\Observers\PhotoObserver; use App\Policies\AlbumQueryPolicy; use App\Policies\PhotoQueryPolicy; use App\Policies\SettingsPolicy; @@ -111,6 +113,7 @@ public function boot() $this->registerHttpAndResponseConfiguration(); $this->registerStreamFilters(); $this->registerOctaneSettings(); + Photo::observe(PhotoObserver::class); } /** diff --git a/app/Repositories/PhotoRepository.php b/app/Repositories/PhotoRepository.php index 5855b0fbeb8..a082cb1e21f 100644 --- a/app/Repositories/PhotoRepository.php +++ b/app/Repositories/PhotoRepository.php @@ -58,7 +58,7 @@ public function getPhotosForAlbumPaginated( ->join(PA::PHOTO_ALBUM, PA::PHOTO_ID, '=', 'photos.id') ->where(PA::ALBUM_ID, '=', $album_id) ->select('photos.*') - ->with(['size_variants', 'tags', 'palette', 'statistics', 'rating']); + ->with(['size_variants', 'tags', 'palette', 'statistics', 'rating', 'faces.person', 'faces.suggestions.suggestedFace.person']); // Apply tag filtering if tag_ids provided and not empty if ($tag_ids !== null && count($tag_ids) > 0) { diff --git a/database/factories/FaceFactory.php b/database/factories/FaceFactory.php new file mode 100644 index 00000000000..447816d9e9f --- /dev/null +++ b/database/factories/FaceFactory.php @@ -0,0 +1,72 @@ + + */ +class FaceFactory extends Factory +{ + protected $model = Face::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'photo_id' => Photo::factory(), + 'person_id' => null, + 'x' => fake()->randomFloat(4, 0.0, 0.8), + 'y' => fake()->randomFloat(4, 0.0, 0.8), + 'width' => fake()->randomFloat(4, 0.05, 0.2), + 'height' => fake()->randomFloat(4, 0.05, 0.2), + 'confidence' => fake()->randomFloat(4, 0.5, 1.0), + 'crop_token' => Str::random(24), + 'is_dismissed' => false, + 'cluster_label' => null, + ]; + } + + public function for_photo(Photo $photo): self + { + return $this->state(fn () => ['photo_id' => $photo->id]); + } + + public function for_person(Person $person): self + { + return $this->state(fn () => ['person_id' => $person->id]); + } + + public function dismissed(): self + { + return $this->state(fn () => ['is_dismissed' => true]); + } + + public function without_crop(): self + { + return $this->state(fn () => ['crop_token' => null]); + } + + public function with_cluster(int $label): self + { + return $this->state(fn () => ['cluster_label' => $label]); + } + + public function with_confidence(float $confidence): self + { + return $this->state(fn () => ['confidence' => $confidence]); + } +} diff --git a/database/factories/PersonFactory.php b/database/factories/PersonFactory.php new file mode 100644 index 00000000000..f77c136968c --- /dev/null +++ b/database/factories/PersonFactory.php @@ -0,0 +1,49 @@ + + */ +class PersonFactory extends Factory +{ + protected $model = Person::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'user_id' => null, + 'is_searchable' => true, + 'representative_face_id' => null, + ]; + } + + public function not_searchable(): self + { + return $this->state(fn () => ['is_searchable' => false]); + } + + public function linked_to(User $user): self + { + return $this->state(fn () => ['user_id' => $user->id]); + } + + public function with_name(string $name): self + { + return $this->state(fn () => ['name' => $name]); + } +} diff --git a/database/migrations/2026_03_21_000005_add_representative_face_id_to_persons.php b/database/migrations/2026_03_21_000005_add_representative_face_id_to_persons.php new file mode 100644 index 00000000000..cdc30c3d820 --- /dev/null +++ b/database/migrations/2026_03_21_000005_add_representative_face_id_to_persons.php @@ -0,0 +1,42 @@ +char('representative_face_id', 24)->nullable()->after('is_searchable'); + $table->foreign('representative_face_id') + ->references('id') + ->on('faces') + ->onDelete('set null'); + }); + } + + public function down(): void + { + Schema::table('persons', function (Blueprint $table): void { + $table->dropForeign(['representative_face_id']); + $table->dropColumn('representative_face_id'); + }); + } +}; diff --git a/database/migrations/2026_03_21_000006_add_cluster_label_to_faces.php b/database/migrations/2026_03_21_000006_add_cluster_label_to_faces.php new file mode 100644 index 00000000000..79d87e305ea --- /dev/null +++ b/database/migrations/2026_03_21_000006_add_cluster_label_to_faces.php @@ -0,0 +1,41 @@ +integer('cluster_label')->nullable()->after('is_dismissed'); + $table->index(['cluster_label', 'person_id', 'is_dismissed'], 'faces_cluster_label_person_id_is_dismissed_index'); + }); + } + + public function down(): void + { + Schema::table('faces', function (Blueprint $table): void { + $table->dropIndex('faces_cluster_label_person_id_is_dismissed_index'); + $table->dropColumn('cluster_label'); + }); + } +}; diff --git a/docker-compose.minimal.yaml b/docker-compose.minimal.yaml index 7949e3dec2d..c596580d32a 100644 --- a/docker-compose.minimal.yaml +++ b/docker-compose.minimal.yaml @@ -46,6 +46,8 @@ x-common-env: &common-env SESSION_DRIVER: "${SESSION_DRIVER:-file}" SESSION_LIFETIME: "${SESSION_LIFETIME:-120}" QUEUE_CONNECTION: "${QUEUE_CONNECTION:-database}" + AI_VISION_FACE_API_KEY: "${AI_VISION_API_KEY:-changeme}" + AI_VISION_FACE_URL: "http://ai_vision:8000" services: lychee_api: @@ -122,6 +124,36 @@ services: retries: 10 start_period: 10s + ai_vision: + build: + context: ./ai-vision-service + container_name: lychee-ai-vision + restart: unless-stopped + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + environment: + VISION_FACE_LYCHEE_API_URL: "http://lychee_api:8000" + VISION_FACE_API_KEY: "${AI_VISION_API_KEY:-changeme}" + VISION_FACE_PHOTOS_PATH: "/data/photos" + VISION_FACE_STORAGE_PATH: "/data/embeddings" + VISION_FACE_WORKERS: "${AI_VISION_WORKERS:-1}" + volumes: + - ./lychee/uploads:/data/photos:ro + - ai_vision_embeddings:/data/embeddings + networks: + - lychee + depends_on: + lychee_api: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + networks: lychee: @@ -129,3 +161,6 @@ volumes: mysql: name: lychee_prod_mysql driver: local + ai_vision_embeddings: + name: lychee_ai_vision_embeddings + driver: local diff --git a/docker-compose.yaml b/docker-compose.yaml index 0b9eab201da..00081383206 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -26,10 +26,10 @@ x-base-lychee-setup: &base-lychee-setup # There are also other images available which uses nginx as a base instead of FrankenPHP: # - ghcr.io/lycheeorg/lychee:latest-legacy # - ghcr.io/lycheeorg/lychee:edge-legacy - image: ghcr.io/lycheeorg/lychee:latest + # image: ghcr.io/lycheeorg/lychee:latest # The following lines are for development purposes only. - # image: lychee-frankenphp:latest + image: lychee-frankenphp # image: lychee-legacy:latest # build: # context: ./app @@ -219,7 +219,7 @@ x-common-env: &common-env # # For improved reactivity of Lychee we recommend to use a worker. # You can use either 'database' or 'redis' as queue driver (but in the later you need to enable redis). - # QUEUE_CONNECTION: "${QUEUE_CONNECTION:-database}" + QUEUE_CONNECTION: "${QUEUE_CONNECTION:-database}" # Logging Stuff. # LOG_CHANNEL: "${LOG_CHANNEL:-stack}" @@ -404,6 +404,12 @@ x-common-env: &common-env # PAYPAL_CLIENT_ID= # PAYPAL_SECRET= + ################################################################### + # Facial recognition (requires SE) # + ################################################################### + AI_VISION_FACE_API_KEY: "${AI_VISION_API_KEY:-changeme}" + AI_VISION_FACE_URL: "http://ai_vision:8000" + services: ########################################################################################## # Lychee API Service and frontend. @@ -503,6 +509,22 @@ services: - cache:/data:rw restart: on-failure:5 + phpmyadmin: + image: phpmyadmin + restart: always + ports: + - 8080:80 + environment: + - PMA_HOST=lychee_db + - PMA_PORT=3306 + depends_on: + lychee_db: + condition: service_healthy + networks: + - lychee + profiles: + - phpmyadmin + lychee_db: image: mariadb:10 security_opt: @@ -549,6 +571,42 @@ services: retries: 10 start_period: 10s + ai_vision: + expose: + - "${APP_PORT_AI_FACE:-8001}" + ports: + - "${APP_PORT_AI_FACE:-8001}:8000" + # build: + # context: ./ai-vision-service + # container_name: lychee-ai-vision + image: lychee-ai-vision + restart: unless-stopped + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + environment: + VISION_FACE_LYCHEE_API_URL: "http://lychee_api:8000" + VISION_FACE_API_KEY: "${AI_VISION_API_KEY:-changeme}" + VISION_FACE_PHOTOS_PATH: "/data/photos" + VISION_FACE_STORAGE_PATH: "/data/embeddings" + VISION_FACE_WORKERS: "${AI_VISION_WORKERS:-1}" + volumes: + - ./lychee/uploads:/data/photos:ro + - ai_vision_embeddings:/data/embeddings + networks: + - lychee + # depends_on: + # lychee_api: + # condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + networks: lychee: @@ -559,3 +617,6 @@ volumes: cache: name: lychee_prod_redis driver: local + ai_vision_embeddings: + name: lychee_ai_vision_embeddings + driver: local diff --git a/docs/specs/2-how-to/configure-facial-recognition.md b/docs/specs/2-how-to/configure-facial-recognition.md new file mode 100644 index 00000000000..e7c63e73cf6 --- /dev/null +++ b/docs/specs/2-how-to/configure-facial-recognition.md @@ -0,0 +1,230 @@ +# How-To: Configure Facial Recognition (AI Vision) + +**Author:** Lychee Team +**Last Updated:** 2026-03-22 +**Feature:** 030-ai-vision-service +**Related:** [Feature 030 Spec](../4-architecture/features/030-ai-vision-service/spec.md) + +## Overview + +Lychee's facial recognition feature is powered by a sidecar Python service (`ai-vision-service`). When enabled, Lychee detects faces in photos, groups them into Person profiles, and lets users claim their own profile. This guide covers: + +1. [Prerequisites](#prerequisites) +2. [Docker Compose setup](#docker-compose-setup) +3. [Shared volume configuration](#shared-volume-configuration) +4. [Environment variables](#environment-variables) +5. [Enabling the feature in Lychee admin](#enabling-the-feature-in-lychee-admin) +6. [Permission modes](#permission-modes) +7. [Running a bulk scan](#running-a-bulk-scan) +8. [Service health check](#service-health-check) +9. [Troubleshooting](#troubleshooting) + +--- + +## Prerequisites + +- Docker and Docker Compose v2 +- A working Lychee deployment (see [docker-compose.minimal.yaml](../../../docker-compose.minimal.yaml)) +- A **Supporter Edition (SE)** licence — AI Vision is an SE-only feature + +--- + +## Docker Compose Setup + +Add the `ai_vision` service to your `docker-compose.yaml`. The complete minimal example is in [docker-compose.minimal.yaml](../../../docker-compose.minimal.yaml). The key stanza: + +```yaml +services: + lychee_api: + # ... existing config ... + volumes: + - ./lychee/uploads:/app/public/uploads # Lychee upload directory + + ai_vision: + build: + context: ./ai-vision-service # Build from source, OR use a pre-built image + container_name: lychee-ai-vision + restart: unless-stopped + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + environment: + VISION_FACE_LYCHEE_API_URL: "http://lychee_api:8000" + VISION_FACE_API_KEY: "${AI_VISION_API_KEY}" + VISION_FACE_PHOTOS_PATH: "/data/photos" + VISION_FACE_STORAGE_PATH: "/data/embeddings" + volumes: + - ./lychee/uploads:/data/photos:ro # Shared read-only photos volume + - ai_vision_embeddings:/data/embeddings # Persistent embeddings store + networks: + - lychee + depends_on: + lychee_api: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + +volumes: + ai_vision_embeddings: + name: lychee_ai_vision_embeddings + driver: local +``` + +--- + +## Shared Volume Configuration + +The AI Vision service reads photo files directly from the filesystem — no HTTP file transfer. This requires both containers to mount the same upload directory: + +| Container | Mount path | Mode | +|---|---|---| +| `lychee_api` | `/app/public/uploads` | read/write | +| `ai_vision` | `/data/photos` | read-only | +| Host bind mount | `./lychee/uploads` | (source for both) | + +**Critical:** The host path `./lychee/uploads` must be identical for both mounts. If you use an absolute path or a named volume for `lychee_api`'s uploads, apply the same source to `ai_vision`. + +--- + +## Environment Variables + +### Lychee API (`lychee_api` / `lychee_worker`) + +Add these to `x-common-env` or the service's `environment` block: + +| Variable | Description | Example | +|---|---|---| +| `AI_VISION_FACE_URL` | Internal URL of the AI Vision service | `http://lychee-ai-vision:8000` | +| `AI_VISION_FACE_API_KEY` | Shared secret used in both directions: Lychee sends it on scan requests to Python; Python sends it on callback responses to Lychee | `changeme-strong-random-value` | + +> Generate strong secrets with: `openssl rand -hex 32` + +### AI Vision Service (`ai_vision`) + +| Variable | Description | Default | +|---|---|---| +| `VISION_FACE_LYCHEE_API_URL` | Base URL of the Lychee API (for callbacks) | — | +| `VISION_FACE_API_KEY` | Must match `AI_VISION_FACE_API_KEY` in Lychee | — | +| `VISION_FACE_PHOTOS_PATH` | Path where photos are mounted inside the container | `/data/photos` | +| `VISION_FACE_STORAGE_PATH` | Path for persisting face embeddings | `/data/embeddings` | + +--- + +## Enabling the Feature in Lychee Admin + +After starting the containers, enable the feature in **Admin → Settings → AI Vision**: + +1. **AI Vision enabled** — master toggle; set to `On`. +2. **Facial recognition enabled** — sub-toggle; set to `On`. +3. Configure optional settings (permission mode, batch size, etc.). + +These settings are only visible on Supporter Edition instances. + +--- + +## Permission Modes + +`ai_vision_face_permission_mode` controls who can view People, face overlays, and perform face management. Choose the mode that matches your deployment scenario. + +| Mode | Best for | +|---|---| +| `public` | Community/open galleries where anyone can browse people | +| `private` | Personal or team galleries — all features require login | +| `privacy-preserving` | Multi-user deployments — users only see their own content | +| `restricted` | High-privacy or admin-controlled deployments | + +**Permission matrix:** + +| Operation | `public` | `private` | `privacy-preserving` | `restricted` | +|---|---|---|---|---| +| View People page | Guest | Logged in | Owner + admin | Admin only | +| View face overlays | Album access | Logged in | Owner + admin | Owner + admin | +| Create / edit Person | Logged in | Logged in | Owner + admin | Admin only | +| Assign face | Logged in | Logged in | Owner + admin | Admin only | +| Trigger scan | Logged in | Logged in | Owner + admin | Owner + admin | +| Claim person (selfie) | Logged in | Logged in | Logged in | Logged in | +| Merge persons | Logged in | Logged in | Owner + admin | Admin only | + +> **Default:** `restricted` — the most conservative option. + +--- + +## Running a Bulk Scan + +After setup, scan your existing photo library for faces: + +**Via the admin UI:** +1. Navigate to **Admin → Maintenance**. +2. Find the **Bulk Face Scan** card and click **Scan all unscanned photos**. + +**Via CLI:** +```bash +php artisan lychee:scan-faces +``` + +Scanning runs asynchronously through the queue. Ensure the `lychee_worker` container is running. Progress is visible in the queue job history. + +--- + +## Service Health Check + +The AI Vision service exposes a `/health` endpoint: + +```bash +# Inside the lychee network, from another container: +curl http://lychee-ai-vision:8000/health + +# From the host (if you expose the port): +curl http://localhost:/health +``` + +A healthy response: +```json +{"status": "ok", "version": "x.y.z"} +``` + +Docker will also report the container's health status — wait for `healthy` before triggering scans: + +```bash +docker compose ps +``` + +--- + +## Troubleshooting + +### AI Vision endpoints return 403 + +- Confirm the Lychee instance is a **Supporter Edition** licence. +- Check that `ai_vision_enabled = 1` and `ai_vision_face_enabled = 1` in admin settings. + +### Photos are not scanned / `face_scan_status` stays `pending` + +1. Verify the `lychee_worker` container is running (`docker compose ps`). +2. Confirm `QUEUE_CONNECTION` is not `sync` in the Lychee worker environment. +3. Check the AI Vision service health endpoint. +4. Review `lychee_worker` logs: `docker compose logs lychee-worker`. + +### AI Vision service cannot find photos + +- Compare volume mounts: the host `./lychee/uploads` path must be the same in both the `lychee_api` and `ai_vision` volume definitions. +- Verify `VISION_FACE_PHOTOS_PATH` inside the container matches the volume mount destination. + +### API key mismatch errors (401 from AI Vision / Lychee) + +- `AI_VISION_FACE_API_KEY` (Lychee) must equal `VISION_FACE_API_KEY` (Python service). The same key is used in both directions. +- Restart both containers after changing the secret. + +### Selfie claim returns "no match found" + +- Lower `ai_vision_face_selfie_confidence_threshold` (default `0.8`) in admin settings to accept less-certain matches. +- Ensure the photo library has been fully scanned first. + +--- + +*Last updated: 2026-03-22* diff --git a/docs/specs/3-reference/database-schema.md b/docs/specs/3-reference/database-schema.md index 5b24201c441..3306c4253a2 100644 --- a/docs/specs/3-reference/database-schema.md +++ b/docs/specs/3-reference/database-schema.md @@ -125,6 +125,51 @@ Color palette information extracted from photos. **Relationships:** - Belongs to `Photo` +#### Face +A detected face bounding box within a specific photo. + +**Key Fields:** +- `id`: Primary key (24-char random string) +- `photo_id`: Foreign key to Photo (cascade delete) +- `person_id`: Foreign key to Person (nullable; null on delete) +- `x`, `y`, `width`, `height`: Bounding box as relative floats (0.0–1.0) +- `confidence`: Detection confidence score (0.0–1.0) +- `crop_token`: Opaque token for serving cropped face thumbnails +- `is_dismissed`: Whether the face has been dismissed/ignored by an operator + +**Relationships:** +- Belongs to `Photo` +- Belongs to `Person` (nullable) +- Has many `FaceSuggestion` (as the source face) + +#### FaceSuggestion +Pairs of faces ranked by embedding similarity (used to suggest identities for unknown faces). + +**Key Fields:** +- `face_id`: Foreign key to Face (cascade delete) — the unidentified face +- `suggested_face_id`: Foreign key to Face (cascade delete) — an already-assigned face of a known Person +- `confidence`: Similarity score (0.0–1.0) + +**Unique Constraint:** Composite unique index on (`face_id`, `suggested_face_id`) + +**Relationships:** +- Both keys belong to `Face` + +#### Person +An identified individual who appears across one or more photos. + +**Key Fields:** +- `id`: Primary key (24-char random string) +- `name`: Display name (max 255 chars, required) +- `user_id`: Foreign key to User (nullable, unique — one Person per User claim; null on delete) +- `is_searchable`: When false, face overlays for this Person are hidden from non-owners (privacy) + +**Unique Constraint:** `user_id` is unique (one User → at most one claimed Person) + +**Relationships:** +- Optionally belongs to `User` (the claimed user) +- Has many `Face` + #### PhotoRating User ratings for photos on a 1-5 star scale. @@ -229,11 +274,13 @@ This dual approach allows Lychee to provide: ### Many-to-Many - **Photos-Albums**: Many-to-many through `photo_album` pivot table (photos can belong to multiple albums) - **Tags**: Many-to-many with photos through `photos_tags` pivot table +- **Face Suggestions**: Many-to-many between Face records through `face_suggestions` pivot table (ranked by similarity confidence) ### One-to-Many - **Photos**: Owned by one user (but can belong to multiple albums) - **Size Variants**: Multiple variants per photo - **Access Permissions**: Multiple permissions per album +- **Faces**: Multiple detected faces per photo; multiple faces per Person ## Database Optimization @@ -256,6 +303,34 @@ Eager loading enforced with `Model::shouldBeStrict()`, which throws an exception - [Tag System](../4-architecture/tag-system.md) - Tag architecture and operations - [Image Processing](image-processing.md) - Size variant generation and processing pipeline +## AI Vision Schema Additions (Feature 030) + +### New Tables + +| Table | Migration | Purpose | +|---|---|---| +| `persons` | `2026_03_21_000001_create_persons_table` | Identified individuals (name, user link, searchability) | +| `faces` | `2026_03_21_000002_create_faces_table` | Bounding boxes detected by AI Vision service | +| `face_suggestions` | `2026_03_21_000002_create_faces_table` | Ranked similarity pairs for identity suggestions | + +### Column Added to `photos` + +| Column | Type | Default | Purpose | +|---|---|---|---| +| `face_scan_status` | string(16), nullable | `null` | Tracks face detection lifecycle: `null` (not queued), `pending`, `scanned`, `failed` | + +### New Config Keys (AI Vision category, `level = 1` / SE only) + +| Key | Default | Description | +|---|---|---| +| `ai_vision_enabled` | `0` | Master toggle for the AI Vision subsystem | +| `ai_vision_face_enabled` | `0` | Enable facial recognition (requires master toggle) | +| `ai_vision_face_permission_mode` | `restricted` | Access control mode: `public`, `private`, `privacy-preserving`, `restricted` | +| `ai_vision_face_selfie_confidence_threshold` | `0.8` | Minimum confidence for selfie-based person claim | +| `ai_vision_face_person_is_searchable_default` | `1` | Default `is_searchable` for new Person records | +| `ai_vision_face_allow_user_claim` | `1` | Allow non-admin users to claim a Person profile | +| `ai_vision_face_scan_batch_size` | `200` | Photo IDs per bulk-scan job chunk | + --- -*Last updated: January 21, 2026* +*Last updated: March 22, 2026* diff --git a/docs/specs/4-architecture/features/030-ai-vision-service/plan.md b/docs/specs/4-architecture/features/030-ai-vision-service/plan.md index 7262ecbc1e2..959947e669b 100644 --- a/docs/specs/4-architecture/features/030-ai-vision-service/plan.md +++ b/docs/specs/4-architecture/features/030-ai-vision-service/plan.md @@ -251,7 +251,47 @@ After each increment, verify: - _Commands:_ `php artisan test --filter=PersonPhotos`, `make phpstan` - _Exit:_ Paginated photos returned; access control respected. -### Phase 3: Frontend (Vue3/TypeScript) +### I20 – Clustering Endpoint: Python `POST /cluster` + PHP Ingestion & Trigger (≥90 min) + +- _Goal:_ Wire up the existing `FaceClusterer` (DBSCAN) to a FastAPI REST endpoint and complete the round-trip: Python runs clustering, posts suggestion pairs to Lychee, Lychee bulk-upserts `face_suggestions`. Admin can trigger clustering via a Maintenance API action. +- _Preconditions:_ I2 complete (`FaceClusterer` and `EmbeddingStore` implemented); I10 complete (`face_suggestions` table exists, PHP ingestion pipeline in place); I11 complete (Maintenance endpoint pattern established). +- _Steps:_ + 1. **Python** — Add `ClusterResponse` Pydantic schema (`{clusters: int, suggestions_generated: int}`) to `app/api/schemas.py`. Add `VISION_FACE_CLUSTER_EPS` env var to `AppSettings` (default `0.6`). + 2. **Python** — Extend `app/clustering/clusterer.py` with `run_cluster_and_notify(store: EmbeddingStore, lychee_url: str, api_key: str) -> ClusterResponse`: reads all embeddings from store, runs DBSCAN, produces (a) a `labels` list — `[{face_id: str, cluster_label: int}]` for every non-noise face; (b) a `suggestions` list — `(face_id, suggested_face_id, confidence)` pairs for every intra-cluster pair (cosine similarity as confidence). POSTs `{labels: [...], suggestions: [...]}` to `{lychee_url}/api/v2/FaceDetection/cluster-results` with `X-API-Key` header. *(Q-030-49)* + 3. **Python** — Add `POST /cluster` route to `app/api/routes.py` (X-API-Key auth); calls `run_cluster_and_notify()`; returns `ClusterResponse`. Add unit + integration tests in `tests/test_clustering.py` (mock httpx POST to Lychee). + 4. **PHP** — Implement `POST /api/v2/FaceDetection/cluster-results` endpoint in `FaceDetectionController` (or a new `FaceClusterResultsController`): auth via X-API-Key, validate body `{suggestions: [{face_id, suggested_face_id, confidence}]}`, bulk-upsert `face_suggestions` rows (upsert on `(face_id, suggested_face_id)`, update `confidence`), return `{updated_count: N}`. Register route. + 5. **PHP** — Add `POST /api/v2/Maintenance::runFaceClustering` Maintenance endpoint (admin-only, follows existing check/do pattern): calls Python service `POST /cluster` via HTTP, returns 202 Accepted. Register route. + 6. Write PHP feature tests for cluster-results ingestion (success, invalid API key, malformed body) and for the Maintenance trigger (success, service unavailable 503). +- _Commands:_ `uv run pytest tests/test_clustering.py`, `php artisan test --filter=FaceCluster`, `make phpstan` +- _Exit:_ `POST /cluster` on Python service triggers DBSCAN and POSTs pairs to Lychee; `POST /FaceDetection/cluster-results` bulk-upserts face_suggestions; Maintenance trigger returns 202; all tests green. + +### I21 – Embedding Sync on Deletion + Blur Threshold Filtering (≈60 min) + +- _Goal:_ Prevent stale embeddings from corrupting clustering/suggestions after face hard-deletes; discard blurry faces before they ever reach Lychee. +- _Preconditions:_ I2 complete (EmbeddingStore implemented); I9 complete (`destroyDismissed` action exists); Photo model cascade delete exists. +- _Steps:_ + 1. **Python** — Add `VISION_FACE_BLUR_THRESHOLD` (float, default `100.0`) to `AppSettings` in `app/config.py`. In `app/detection/detector.py`, after cropping each detected face region, compute its Laplacian variance using OpenCV/NumPy (`cv2.Laplacian(crop, cv2.CV_64F).var()`); exclude faces whose variance is below `VISION_FACE_BLUR_THRESHOLD`. Also add `VISION_FACE_CLUSTER_EPS` to `AppSettings` (default `0.6`) if not already present from I20. + 2. **Python** — Add `DELETE /embeddings` route to `app/api/routes.py` (X-API-Key auth): accepts `{face_ids: [str]}`, calls `EmbeddingStore.delete_many(face_ids)`, returns `{deleted_count: int}`. Add `delete_many()` method to the `EmbeddingStore` protocol and both implementations (`SQLiteStore`, `PgVectorStore`). IDs not found are silently ignored. + 3. **Python** — Add tests: `tests/test_detection.py` — blurry face below threshold not returned; sharp face above threshold returned. `tests/test_api.py` — `DELETE /embeddings` removes embeddings and returns count. + 4. **PHP** — Create `DeleteFaceEmbeddingsJob` (queued): accepts `array $faceIds`, calls Python `DELETE /embeddings` via HTTP with `X-API-Key`; logs warning on failure, never throws. Dispatch this job **after** `destroyDismissed` deletes Face records (FR-030-14, S-030-28). + 5. **PHP** — Add `Face` model observer or hook into `Photo` cascade: after a Photo delete triggers Face cascade deletes, collect deleted face IDs and dispatch `DeleteFaceEmbeddingsJob` (S-030-29). + 6. **PHP** — Write feature tests: `DELETE /Face/dismissed` → embeddings deleted (job dispatched with correct IDs); Photo delete → embeddings deleted; Python unavailable → Lychee deletion still succeeds, warning logged. +- _Commands:_ `uv run pytest tests/test_detection.py tests/test_api.py`, `php artisan test --filter=FaceEmbeddingSync`, `make phpstan` +- _Exit:_ Blurry faces never reach Lychee; dismissed/cascade-deleted Face embeddings are removed from the store; service unavailability does not block Lychee-side deletions. + +### I22 – Cluster Review UI: Browse & Bulk-Name/Dismiss Clusters (≥90 min) + +- _Goal:_ Give authorized users a dedicated page to review DBSCAN-produced face clusters (visually similar unassigned faces) and resolve them in bulk — either creating a Person and assigning all faces in one action, or dismissing the whole cluster as false-positives. +- _Preconditions:_ I20 complete (`face_suggestions` populated by clustering); I9 complete (dismiss action exists); I7 complete (Person create + face assign available). +- _Steps:_ + 1. **PHP** — Add migration for `cluster_label INT NULL` column + composite index `(cluster_label, person_id, is_dismissed)` on `faces` (DO-030-07, Q-030-49). Implement `GET /api/v2/FaceDetection/clusters` (API-030-18): `SELECT cluster_label, COUNT(*) as size FROM faces WHERE cluster_label IS NOT NULL AND person_id IS NULL AND is_dismissed = false GROUP BY cluster_label ORDER BY cluster_label LIMIT/OFFSET`; load preview faces per cluster; return `{cluster_id: int, size: int, faces: FaceResource[]}`. `cluster_id` = integer `cluster_label` value. Respect `ai_vision_face_permission_mode` visibility rules. + 2. **PHP** — Implement `POST /api/v2/FaceDetection/clusters/{cluster_id}/assign` (API-030-19): resolve cluster faces, create Person if `new_person_name` provided, bulk-update `face.person_id` for all faces in cluster, emit `face.cluster_assigned` telemetry. Return `{person_id, assigned_count}`. + 3. **PHP** — Implement `POST /api/v2/FaceDetection/clusters/{cluster_id}/dismiss` (API-030-20): bulk-set `is_dismissed = true` for all faces in cluster, emit `face.cluster_dismissed` telemetry. Return `{dismissed_count}`. + 4. Write feature tests: list clusters (unassigned faces grouped, assigned faces excluded), assign cluster (new person + faces linked), dismiss cluster (all faces dismissed). Test permission mode enforcement. + 5. **Frontend** — Create `FaceClusters.vue` page at `/people/clusters`. Paginated grid of cluster cards: first 5 face-crop thumbnails + "+N more" overflow badge, cluster size badge, name input, “Create Person & Assign All” button, “Dismiss” button. “Run Cluster” button in page header calls `POST /Maintenance::runFaceClustering` then refreshes. Empty state when no clusters exist. + 6. Add route `/people/clusters` to Vue Router; add “Clusters” navigation link under People in sidebar. +- _Commands:_ `php artisan test --filter=FaceClusterReviewTest`, `make phpstan`, `npm run check` +- _Exit:_ Clusters page shows unassigned face groups; bulk-assign creates Person and links all faces; bulk-dismiss marks all is_dismissed; “Run Cluster” triggers fresh clustering; all tests green. ### I13 – Frontend: People Page (≈90 min) @@ -381,6 +421,12 @@ After each increment, verify: | S-030-24 | I9 | Face dismissed (is_dismissed toggle) | | S-030-25 | I9 | Admin hard-deletes all dismissed faces + crop files | | S-030-26 | I11 | Admin resets stuck-pending photos via Maintenance endpoint | +| S-030-27 | I20 | Admin triggers clustering; suggestion pairs ingested into face_suggestions | +| S-030-28 | I21 | Admin hard-deletes dismissed faces; embeddings deleted from Python store | +| S-030-29 | I21 | Photo deleted; Face cascade-deleted; embeddings deleted from Python store | +| S-030-30 | I21 | Blurry face below VISION_FACE_BLUR_THRESHOLD excluded from detection callback | +| S-030-31 | I22 | Cluster Review page: admin names cluster → Person created, all faces assigned | +| S-030-32 | I22 | Cluster Review page: admin dismisses cluster → all faces marked is_dismissed | ## Analysis Gate @@ -388,7 +434,7 @@ _To be completed after spec, plan, and tasks align and before implementation beg ## Exit Criteria -- [ ] All 19 increments (I1–I19) complete with passing tests. +- [ ] All 22 increments (I1–I22) complete with passing tests. - [ ] PHPStan 0 errors. - [ ] php-cs-fixer clean. - [ ] npm run check / npm run format clean. @@ -401,9 +447,7 @@ _To be completed after spec, plan, and tasks align and before implementation beg ## Follow-ups / Backlog -- Auto-clustering UI — Python service clusters via `POST /cluster` (DBSCAN offline batch); dedicated cluster-review/confirm workflow is a future enhancement beyond manual assignment. *(Q-030-30 resolved)* - Face recognition accuracy tuning and confidence threshold configuration (admin UI for `VISION_FACE_DETECTION_THRESHOLD` / `VISION_FACE_MATCH_THRESHOLD`). - Notifications when a user is tagged in a new photo. - Performance optimisation for large Person/Face datasets (materialized views, caching face counts). - GPU acceleration for the Python service (optional CUDA/ROCm support in Dockerfile). -- Cluster review UI — surface DBSCAN group results for bulk confirmation by admin. diff --git a/docs/specs/4-architecture/features/030-ai-vision-service/spec.md b/docs/specs/4-architecture/features/030-ai-vision-service/spec.md index 02b8522c9fb..49e7a469e60 100644 --- a/docs/specs/4-architecture/features/030-ai-vision-service/spec.md +++ b/docs/specs/4-architecture/features/030-ai-vision-service/spec.md @@ -42,7 +42,7 @@ Affected modules: **Models** (new Person, Face), **Migrations** (new tables + pi | ID | Requirement | Success path | Validation path | Failure path | Telemetry & traces | Source | |----|-------------|--------------|-----------------|--------------|---------------------|--------| | FR-030-01 | System shall store Person records with a name, optional User link (1-1), and a searchability flag. | Person created with name; optionally linked to a User; `is_searchable` defaults to the value of `ai_vision_face_person_is_searchable_default` config (default `true`). | Name must be non-empty string ≤255 chars; user_id must reference existing User and be unique across Person table (1-1). | Return 422 with validation errors. | `person.created`, `person.updated` | Owner directive | -| FR-030-02 | System shall store Face records linking a detected face region in a Photo to an optional Person, including a server-side crop thumbnail path and a dismissal flag. *(Resolved Q-030-09: server-side crop; Q-030-16: is_dismissed; Q-030-25: crop storage path; Q-030-34: nginx-direct hash path)* | Face created with photo_id, bounding box (x, y, width, height as percentages), confidence score, `crop_token` (random high-entropy token, stored on Face model); crop file stored at `uploads/faces/{token[0:2]}/{token[2:4]}/{token}.jpg` and served directly by nginx (path is unguessable, no app-level auth required); person_id nullable (unassigned); `is_dismissed` defaults to `false`. | photo_id must exist; bounding box values 0.0–1.0; confidence 0.0–1.0. | Return 422; reject invalid photo_id with 404. | `face.created` | Owner directive, Q-030-09, Q-030-16, Q-030-25 | +| FR-030-02 | System shall store Face records linking a detected face region in a Photo to an optional Person, including a server-side crop thumbnail path and a dismissal flag. The Python service shall filter detections by both confidence and sharpness before sending results to Lychee. *(Resolved Q-030-09: server-side crop; Q-030-16: is_dismissed; Q-030-25: crop storage path; Q-030-34: nginx-direct hash path)* | Face created with photo_id, bounding box (x, y, width, height as percentages), confidence score, `crop_token` (random high-entropy token, stored on Face model); crop file stored at `uploads/faces/{token[0:2]}/{token[2:4]}/{token}.jpg` and served directly by nginx (path is unguessable, no app-level auth required); person_id nullable (unassigned); `is_dismissed` defaults to `false`. Python service filters detections by two thresholds before callback: (a) `VISION_FACE_DETECTION_THRESHOLD` (confidence; default 0.5) — faces below excluded; (b) `VISION_FACE_BLUR_THRESHOLD` (Laplacian variance sharpness score; default `100.0`) — faces whose crop has a Laplacian variance below this value are excluded as too blurry to be usable for recognition. | photo_id must exist; bounding box values 0.0–1.0; confidence 0.0–1.0. | Return 422; reject invalid photo_id with 404. | `face.created` | Owner directive, Q-030-09, Q-030-16, Q-030-25 | | FR-030-03 | A Person can appear in multiple Photos (many-to-many through Face). The system shall provide an endpoint to list all photos containing a given Person. | GET endpoint returns paginated photos where at least one Face with matching person_id exists. | person_id must exist; pagination params validated. | 404 if Person not found; empty result set if no faces assigned. | — | Owner directive | | FR-030-04 | A Photo can contain multiple Persons. The system shall return all identified Persons when viewing a Photo's details. For non-searchable Persons, face overlays are hidden entirely for unauthorized viewers; a `hidden_face_count` integer is included instead. *(Resolved Q-030-10: hide overlay + count indicator)* | Photo detail response includes `faces` array (only searchable/authorized faces) + `hidden_face_count` (integer, count of suppressed non-searchable faces). | — | Graceful empty array if no faces detected; hidden_face_count = 0 if none suppressed. | — | Owner directive, Q-030-10 | | FR-030-05 | Users can link their account to a Person (1-1) via direct claim, and unlink via unclaim. Admins can link/unlink any Person-User pair, overriding user claims. Only one Person per User, one User per Person. *(Resolved Q-030-06: self-identification + admin override; Q-030-21: unclaim endpoint)* | User claims a Person; `person.user_id` set; old claim (if any) cleared. Admin can force-link/unlink any pair. User unclaims via `DELETE /api/v2/Person/{id}/claim`; sets `person.user_id = null`. | user_id unique on persons table; User must exist. Non-admin claim: 409 if Person already claimed by another User; 403 if `ai_vision_face_allow_user_claim` is `false`. Admin claim: overrides existing link; bypasses `ai_vision_face_allow_user_claim`. Unclaim: only linked User or admin. | 409 if Person already claimed (non-admin); 403 if `ai_vision_face_allow_user_claim` is `false` (non-admin); 403 if unclaim caller is not linked User or admin; 422 for validation errors. | `person.claimed`, `person.unclaimed` | Owner directive, Q-030-06, Q-030-21 | @@ -53,6 +53,9 @@ Affected modules: **Models** (new Person, Face), **Migrations** (new tables + pi | FR-030-10 | Users can manually assign/reassign an unassigned Face to a Person, or create a new Person from a Face. Python service provides cluster suggestions for grouping similar faces. *(Resolved Q-030-03: auto-cluster with manual confirmation)* | Face's person_id updated; new Person created if requested. Cluster suggestions displayed as similarity scores in assignment UI. | Face must exist; target Person (if specified) must exist. | 404/422 for invalid references. | `face.assigned` | Owner directive, Q-030-03 | | FR-030-11 | Users can merge two Person records (combining all their Face associations). *(Resolved Q-030-22: merge direction — URL {id} = target kept; body source_person_id = source destroyed)* | All Faces of source Person reassigned to target Person (`{id}`); source Person deleted. | Both Persons must exist; user must have edit permission per `ai_vision_face_permission_mode`; body must supply `source_person_id`. | 404 if either Person not found; 403 if unauthorized. | `person.merged` | Owner directive, Q-030-22 | | FR-030-12 | Users can upload a selfie photo to claim a Person via face matching. Selfie sent to Python service's dedicated `POST /match` endpoint; image discarded immediately after matching. *(Resolved Q-030-06: selfie-upload claim; Q-030-11: discard after match; Q-030-12: dedicated /match endpoint; Q-030-13: lychee_face_id returned by /match)* | User uploads selfie → Python service `POST /match` returns top-N matches with `lychee_face_id` + confidence → if best match above `ai_vision_face_selfie_confidence_threshold`, Lychee resolves `lychee_face_id → Face → person_id` and links Person to User (same 1-1 rules as FR-030-05). Selfie image deleted after response. | Selfie must contain exactly one detectable face; confidence threshold configurable. | 422 if no face detected in selfie; 404 if no matching Person found; 409 if matched Person already claimed by another User. | `person.selfie_claim_requested`, `person.selfie_claim_matched` | Owner directive, Q-030-06, Q-030-11, Q-030-12, Q-030-13 | +| FR-030-14 | When Face records are hard-deleted from Lychee (either via admin bulk-dismissed-delete or via cascade when a Photo is deleted), the corresponding embeddings shall also be removed from the Python service's embedding store to prevent stale data from polluting future clustering and suggestion results. | After hard-deleting Face records, Lychee calls Python `DELETE /embeddings` with `{face_ids: [str]}`. Python removes those embeddings from `EmbeddingStore`. The call is best-effort (fire-and-forget via queued job): Lychee proceeds with the deletion regardless of whether the Python service responds. Failures are logged as warnings. Dispatch is triggered at **two explicit call-sites** (no Face model observer): (1) `destroyDismissed` action — collect dismissed face IDs before `Face::where('is_dismissed', true)->delete()`, then dispatch `DeleteFaceEmbeddingsJob`; (2) `PhotoObserver::deleting` — collect `$photo->faces()->pluck('id')` before cascade delete, then dispatch batch job. *(Q-030-52)* | face_ids must be a non-empty list of strings. | If the Python service is unavailable, log warning and continue — do not fail or roll back the Lychee-side deletion. | `face.embeddings_deleted` (with `count`) | Owner directive, Q-030-52 | +| FR-030-15 | System shall provide a Cluster Review page where authorized users can browse face clusters produced by DBSCAN (faces grouped by visual similarity, not yet assigned to a Person) and bulk-name or dismiss an entire cluster in one action. | `GET /api/v2/FaceDetection/clusters` returns a paginated list of clusters; each cluster contains a `cluster_id` (integer, equal to `faces.cluster_label`, stable between clustering runs), a list of `FaceResource` items (crop_url, confidence, photo_id), and a `size` count. The page renders face-crop grids grouped by cluster. User can: (a) type a name and click “Create Person & assign all” — creates a new Person and bulk-assigns every face in the cluster to it via `POST /api/v2/FaceDetection/clusters/{cluster_id}/assign`; (b) click “Dismiss cluster” — marks every face `is_dismissed = true` via `POST /api/v2/FaceDetection/clusters/{cluster_id}/dismiss`. | Cluster review page respects `ai_vision_face_permission_mode` (same visibility rules as People page). `cluster_id` must be a valid integer `cluster_label` value with at least one qualifying face. | 404 if `cluster_id` not found; 403 if unauthorized. | `face.cluster_assigned` (with `cluster_id`, `person_id`, `face_count`), `face.cluster_dismissed` (with `cluster_id`, `face_count`) | Owner directive, Q-030-49 | +| FR-030-13 | Admin can trigger offline DBSCAN face clustering across all stored embeddings to generate cross-photo face suggestion pairs and persist cluster labels on Face records. The Python service exposes `POST /cluster`; Lychee exposes `POST /FaceDetection/cluster-results` to ingest the results. *(Q-030-49)* | Admin calls `POST /api/v2/Maintenance::runFaceClustering` (admin-only) → Lychee calls Python service `POST /cluster` (X-API-Key auth) → Python reads all stored embeddings from `EmbeddingStore`, runs DBSCAN (eps from `VISION_FACE_CLUSTER_EPS` env var, default configurable), generates: (a) `{face_id: str, cluster_label: int}[]` where label = DBSCAN integer label (noise faces omitted); (b) `(face_id, suggested_face_id, confidence)` pairs for every intra-cluster pair using cosine similarity → POSTs both to `POST /api/v2/FaceDetection/cluster-results` (X-API-Key auth) → Lychee: bulk-updates `faces.cluster_label` (all previously clustered faces first reset to NULL, then new labels written); bulk-upserts `face_suggestions` rows (unique on `(face_id, suggested_face_id)`); returns `{faces_labeled: N, suggestions_updated: M}`. Maintenance trigger returns 202 Accepted; clustering runs asynchronously on the Python side. | Python endpoint: X-API-Key required. PHP cluster-results endpoint: X-API-Key required; body: `{labels: [{face_id: str, cluster_label: int}], suggestions: [{face_id: str, suggested_face_id: str, confidence: float}]}`. Both arrays optional (empty array = no-op for that field). Maintenance trigger: admin-only session auth. | 503 if Python service unavailable; 401 for invalid API key; no-op if no embeddings stored (returns `{faces_labeled: 0, suggestions_updated: 0}`). | `face.clustering_triggered`, `face.cluster_labels_written` (with `faces_labeled`), `face.cluster_suggestions_ingested` (with `suggestions_updated`) | Owner directive, Q-030-49 | ## Non-Functional Requirements @@ -80,6 +83,7 @@ Affected modules: **Models** (new Person, Face), **Migrations** (new tables + pi | NFR-030-08 | Python service accesses photos via shared Docker volume (filesystem path). Deployment must document volume mount configuration. *(Resolved Q-030-07)* | Performance; no auth complexity for file access. | Python service reads photos from shared path; integration test confirms file access. | Docker volume configuration in docker-compose. | Owner directive, Q-030-07 | | NFR-030-09 | AI Vision (facial recognition and People management) is a **Supporter Edition (SE)** feature. All AI Vision config keys are stored with `level = 1`; the admin settings UI hides them on non-SE instances. Face detection and People management endpoints return 403 on non-SE instances. | Licensing; feature differentiation. | Non-SE instance: settings page does not expose the AI Vision category; scan/people endpoints return 403. | License-level check middleware (existing SE gate); config `level` column. | Owner directive | +| NFR-030-10 | All `ai_vision_face_*` functionality is implicitly gated on `ai_vision_enabled = 1`. Any code path that checks `ai_vision_face_enabled` must first confirm `ai_vision_enabled = 1`. When `ai_vision_enabled = 0`, all AI Vision endpoints (face detection, People management, cluster review, selfie claim) return 503 and all AI Vision UI elements are hidden, regardless of the value of `ai_vision_face_enabled`. *(Q-030-51)* | Correctness; global kill-switch. | Feature tests confirm: `ai_vision_enabled=0` + `ai_vision_face_enabled=1` → all face endpoints inactive. | Compound gate check in FaceDetection and People controllers; `ai_vision_enabled` evaluated before `ai_vision_face_enabled` in every guard. | Owner directive, Q-030-51 | > **Cross-user reassignment *(Q-030-42)*:** The "Assign face" row governs reassignment of faces regardless of who previously assigned them. In `public`/`private` modes any user meeting the mode's write threshold may reassign; in `privacy-preserving`/`restricted` only the photo owner or admin may do so. @@ -228,6 +232,39 @@ Read via `config('features.ai-vision.face-url')` and `config('features.ai-vision +------------------------------------------+ ``` +### Cluster Review Page + +``` ++------------------------------------------------------------------+ +| Lychee > People > Clusters [Run Cluster] | ++------------------------------------------------------------------+ +| Showing 24 clusters of unassigned faces | ++------------------------------------------------------------------+ +| | +| Cluster #1 (12 faces) | +| +------+ +------+ +------+ +------+ +------+ [+7 more] | +| | | | | | | | | | | | +| | crop | | crop | | crop | | crop | | crop | | +| | | | | | | | | | | | +| +------+ +------+ +------+ +------+ +------+ | +| [ _______________ ] [Create Person & Assign All] [Dismiss] | +| | +| Cluster #2 (7 faces) | +| +------+ +------+ +------+ +------+ +------+ [+2 more] | +| | | | | | | | | | | | +| | crop | | crop | | crop | | crop | | crop | | +| | | | | | | | | | | | +| +------+ +------+ +------+ +------+ +------+ | +| [ _______________ ] [Create Person & Assign All] [Dismiss] | +| | +| Cluster #3 (3 faces) · · · | +| | +| [Load more clusters] | ++------------------------------------------------------------------+ +``` + +*Note: "Run Cluster" button triggers `POST /api/v2/Maintenance::runFaceClustering` then refreshes the page. Clusters with faces already assigned to a Person are not shown.* + ## Branch & Scenario Matrix | Scenario ID | Description / Expected outcome | @@ -255,6 +292,12 @@ Read via `config('features.ai-vision.face-url')` and `config('features.ai-vision | S-030-21 | User uploads selfie with no detectable face → 422 error | | S-030-22 | User uploads selfie but no matching Person found → 404, user informed | | S-030-23 | Photo uploaded with `ai_vision_face_enabled` → auto-scan job dispatched | +| S-030-27 | Admin triggers face clustering → Python runs DBSCAN across all stored embeddings → suggestion pairs bulk-upserted into `face_suggestions` → FaceAssignmentModal shows updated suggestions | +| S-030-28 | Admin hard-deletes all dismissed faces → Lychee deletes Face records → Python `DELETE /embeddings` called with their IDs → embeddings removed from store | +| S-030-29 | Photo deleted → Face records cascade-deleted → Python `DELETE /embeddings` called → stale embeddings removed | +| S-030-30 | Face detected in photo with sharpness below `VISION_FACE_BLUR_THRESHOLD` → face excluded from detection callback; not stored in Lychee or embedding store | +| S-030-31 | Admin opens Cluster Review page → clusters of visually similar unassigned faces displayed → admin names a cluster → new Person created and all faces in cluster assigned | +| S-030-32 | Admin dismisses an entire cluster from Cluster Review page → all faces in cluster marked `is_dismissed = true` | ## Test Strategy @@ -271,12 +314,14 @@ Read via `config('features.ai-vision.face-url')` and `config('features.ai-vision | ID | Description | Modules | |----|-------------|---------| -| DO-030-01 | `Person` — id, name, user_id (nullable, unique), is_searchable (boolean, default true), timestamps | Models, API, UI | -| DO-030-02 | `Face` — id, photo_id (FK→photos), person_id (nullable FK→persons), x, y, width, height (float 0.0–1.0), confidence (float 0.0–1.0), is_dismissed (boolean, default false), crop_token (random high-entropy token, nullable; file stored at `uploads/faces/{token[0:2]}/{token[2:4]}/{token}.jpg` and served directly by nginx — path is unguessable, no app-level auth required), timestamps | Models, API, UI | -| DO-030-03 | `PersonResource` — Spatie Data resource for Person API responses | Resources | +| DO-030-01 | `Person` — id, name, user_id (nullable, unique), is_searchable (boolean, default true), representative_face_id (nullable FK→faces ON DELETE SET NULL — explicit override for the representative crop; if NULL, PersonResource falls back to highest-confidence non-dismissed face), timestamps | Models, API, UI | +| DO-030-02 | `Face` — id, photo_id (FK→photos), person_id (nullable FK→persons), x, y, width, height (float 0.0–1.0), confidence (float 0.0–1.0), is_dismissed (boolean, default false), crop_token (random high-entropy token, nullable; file stored at `uploads/faces/{token[0:2]}/{token[2:4]}/{token}.jpg` and served directly by nginx — path is unguessable, no app-level auth required), cluster_label (nullable INT; DBSCAN cluster assignment written by `POST /FaceDetection/cluster-results`; -1 = DBSCAN noise/outlier stored as NULL; NULL = not yet clustered), timestamps | Models, API, UI | +| DO-030-03 | `PersonResource` — Spatie Data resource for Person API responses. Fields: `id`, `name`, `user_id` (nullable), `is_searchable` (boolean), `face_count` (int), `photo_count` (int), `representative_face_id` (nullable string — echoes `persons.representative_face_id`), `representative_crop_url` (nullable string — computed: if `representative_face_id` is set and the referenced Face has a `crop_token`, use that face's crop URL; otherwise `SELECT crop_token FROM faces WHERE person_id = ? AND is_dismissed = false AND crop_token IS NOT NULL ORDER BY confidence DESC LIMIT 1`; null if no qualifying face exists). PATCH /Person/{id} accepts optional `representative_face_id` (string\|null) to explicitly set or clear the override. *(Q-030-50)* | Resources | | DO-030-04 | `FaceResource` — Spatie Data resource for Face API responses (included in PhotoResource). Fields exposed: `id` (Face ID), `photo_id`, `person_id` (nullable), `x`, `y`, `width`, `height` (float 0.0–1.0), `confidence` (float 0.0–1.0), `is_dismissed` (boolean), `crop_url` (computed: `uploads/faces/{token[0:2]}/{token[2:4]}/{token}.jpg`; null if no crop). Embedded `suggestions[]` array — each item: `suggested_face_id`, `crop_url` (suggested face's crop or null), `person_name` (nullable, resolved via LEFT JOIN on persons), `confidence` (float 0.0–1.0). Suggestions always embedded (not lazy-loaded) since they are pre-computed and stored — no N+1 risk. *(Q-030-46)* | Resources | | DO-030-05 | `FaceSuggestion` — face_id (FK→faces), suggested_face_id (FK→faces), confidence (float 0.0–1.0); pre-computed similar-face suggestions stored from Python scan callback. Both FKs point to `faces`; the assignment modal JOINs at read time to resolve `suggested_face_id → person_id` (supports unassigned suggestions). Unique constraint on `(face_id, suggested_face_id)`. *(Q-030-33)* | Models, API, UI | | DO-030-06 | `photos` table addendum — adds nullable `face_scan_status VARCHAR(16)` column; PHP `ScanStatus` Enum cast. Values: `null` (never scanned), `pending`, `completed`, `failed`. Type chosen for MySQL/PostgreSQL/SQLite portability. *(Q-030-38)* | Models, Migrations | +| DO-030-07 | `faces` table addendum — adds nullable `cluster_label INT` column (DBSCAN output label; NULL = not yet clustered or noise). Composite index `(cluster_label, person_id, is_dismissed)` on `faces` enables O(index-scan) `GROUP BY cluster_label` pagination for API-030-18. *(Q-030-49)* | Models, Migrations | +| DO-030-08 | `persons` table addendum — adds `representative_face_id` nullable FK→`faces` ON DELETE SET NULL. Separate migration required (cannot be included in the original `persons` migration because `faces` does not yet exist at that point — circular-FK dependency). *(Q-030-50)* | Models, Migrations | ### API Routes / Services @@ -297,9 +342,12 @@ Read via `config('features.ai-vision.face-url')` and `config('features.ai-vision | API-030-13 | POST /api/v2/Person/claim-by-selfie | Upload selfie photo, Python service matches against embeddings, link matching Person to current User | Multipart form with image file; **throttle: 5 requests/minute per user** *(Q-030-44)* | | API-030-14 | PATCH /api/v2/Face/{id} | Toggle `is_dismissed` flag (dismiss/undismiss a false-positive face) | Auth: photo owner or admin | | API-030-15 | DELETE /api/v2/Person/{id}/claim | Remove the User link from a Person (unclaim) | Auth: linked User or admin | -| API-030-16 | DELETE /api/v2/Face/dismissed | Admin: hard-delete all dismissed faces (is_dismissed = true) and their crop files | Admin-only *(Q-030-43)* | +| API-030-16 | DELETE /api/v2/Face/dismissed | Admin: hard-delete all dismissed faces (is_dismissed = true), their crop files, and their embeddings (FR-030-14: calls Python `DELETE /embeddings` asynchronously) | Admin-only *(Q-030-43)* | | API-030-17 | GET /api/v2/Maintenance::resetStuckFaces | Admin: check — returns count of photos stuck in `pending` longer than `older_than_minutes` (default 60). Follows existing check/do Maintenance pattern. | Admin-only *(Q-030-48)* | | API-030-17b | POST /api/v2/Maintenance::resetStuckFaces | Admin: do — reset all `face_scan_status = 'pending'` records older than threshold back to `null`. Body: optional `older_than_minutes` (integer, default 60). | Admin-only *(Q-030-48)* | +| API-030-18 | GET /api/v2/FaceDetection/clusters | List face clusters using `SELECT cluster_label, COUNT(*) as size FROM faces WHERE cluster_label IS NOT NULL AND person_id IS NULL AND is_dismissed = false GROUP BY cluster_label ORDER BY cluster_label LIMIT/OFFSET`. Paginated; each cluster: `{cluster_id: int, size: int, faces: FaceResource[]}` (faces loaded via separate WHERE cluster_label = ? query, limited to first N for preview). Composite index `(cluster_label, person_id, is_dismissed)` ensures O(index-scan). Respects `ai_vision_face_permission_mode` visibility rules. | Auth per permission mode | +| API-030-19 | POST /api/v2/FaceDetection/clusters/{cluster_id}/assign | Bulk-assign all qualifying faces (`cluster_label = cluster_id AND person_id IS NULL AND is_dismissed = false`) to a Person (existing `person_id` or new `new_person_name`). Creates Person if `new_person_name` provided. `cluster_id` is the integer `cluster_label` value. | Body: `person_id` or `new_person_name` | +| API-030-20 | POST /api/v2/FaceDetection/clusters/{cluster_id}/dismiss | Bulk-dismiss all qualifying faces (`cluster_label = cluster_id AND person_id IS NULL AND is_dismissed = false`) by setting `is_dismissed = true`. `cluster_id` is the integer `cluster_label` value. | Auth: photo owner or admin | ### CLI Commands / Flags @@ -654,6 +702,18 @@ Lychee resolves `lychee_face_id → Face → person_id` to identify the matching **Health Check:** `GET /health` → `{"status": "ok"}` +**Embedding Deletion (Lychee → Python):** `DELETE /embeddings` *(FR-030-14)* +```json +{ + "face_ids": ["face_abc123", "face_def456"] +} +``` +Response (200): +```json +{ "deleted_count": 2 } +``` +Lychee dispatches this call as a fire-and-forget queued job after hard-deleting Face records (dismissed bulk-delete or Photo cascade). The Python service removes the listed embeddings from `EmbeddingStore`; IDs not found are silently ignored. Endpoint authenticated via `X-API-Key`. + ### Python Service Technical Specification #### Technology Stack @@ -961,7 +1021,9 @@ CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers ${VI | `VISION_FACE_API_KEY` | Yes | — | Shared API key used in both directions: validates inbound `X-API-Key` from Lychee scan requests; also sent as `X-API-Key` on callbacks to Lychee. Must match `AI_VISION_FACE_API_KEY` on the Lychee side. | | `VISION_FACE_MODEL_NAME` | No | `buffalo_l` | InsightFace model pack name | | `VISION_FACE_DETECTION_THRESHOLD` | No | `0.5` | Bounding box confidence filter — faces below threshold excluded from callback *(Q-030-31)* | +| `VISION_FACE_BLUR_THRESHOLD` | No | `100.0` | Laplacian variance sharpness filter — faces whose crop Laplacian variance falls below this value are excluded as too blurry for reliable recognition (FR-030-02). Set to `0.0` to disable blur filtering. | | `VISION_FACE_MATCH_THRESHOLD` | No | `0.5` | Similarity score cutoff for suggestions and selfie match results *(Q-030-31)* | +| `VISION_FACE_CLUSTER_EPS` | No | `0.6` | DBSCAN epsilon (maximum cosine distance between embeddings to be considered the same cluster) used by `POST /cluster` (FR-030-13). Lower values → tighter clusters. | | `VISION_FACE_RESCAN_IOU_THRESHOLD` | No | `0.5` | IoU threshold for bounding-box matching on re-scan (preserves `person_id`) *(Q-030-35)* | | `VISION_FACE_MAX_FACES_PER_PHOTO` | No | `10` | Maximum faces included in the callback payload (top-N by confidence; rest dropped) *(Q-030-39)* | | `VISION_FACE_THREAD_POOL_SIZE` | No | `1` | CPU-bound inference thread pool size (`asyncio.run_in_executor`) *(Q-030-26)* | diff --git a/docs/specs/4-architecture/features/030-ai-vision-service/tasks.md b/docs/specs/4-architecture/features/030-ai-vision-service/tasks.md index 9d9f3fc5759..7d3cc355935 100644 --- a/docs/specs/4-architecture/features/030-ai-vision-service/tasks.md +++ b/docs/specs/4-architecture/features/030-ai-vision-service/tasks.md @@ -77,7 +77,7 @@ _Last updated: 2026-03-21_ ### I4 – Database Migrations - [x] T-030-10 – Create `persons` table migration (FR-030-01, S-030-01). - _Intent:_ Migration with columns: id (string PK), name (varchar 255), user_id (nullable unsigned int, unique, FK→users ON DELETE SET NULL), is_searchable (boolean default true), timestamps. Index on user_id. + _Intent:_ Migration with columns: id (string PK), name (varchar 255), user_id (nullable unsigned int, unique, FK→users ON DELETE SET NULL), is_searchable (boolean default true), timestamps. Index on user_id. **Does not include `representative_face_id`** — that FK references the `faces` table which does not yet exist; it is added in a separate addendum migration (T-030-53, DO-030-08) after `faces` is created, to avoid a circular FK dependency. _Verification commands:_ - `php artisan test` _Notes:_ Use string PK consistent with Photo/Album models. @@ -93,15 +93,21 @@ _Last updated: 2026-03-21_ _Verification commands:_ - `php artisan test` +- [x] T-030-53 – Add `representative_face_id` column migration to `persons` table (DO-030-08, Q-030-50). + _Intent:_ New migration (runs **after** the `faces` table migration from T-030-11): `ALTER TABLE persons ADD COLUMN representative_face_id VARCHAR(?) NULL`. Add FK constraint: `representative_face_id` → `faces.id` ON DELETE SET NULL. This resolves the circular-FK dependency (persons→faces and faces→persons both require the other table to exist first). Run tests to confirm migration applies cleanly on SQLite. + _Verification commands:_ + - `php artisan test` + - `make phpstan` + ### I5 – Eloquent Models & Relationships -- [ ] T-030-13 – Write unit tests for Person model relationships (FR-030-01, FR-030-03, S-030-17). +- [x] T-030-13 – Write unit tests for Person model relationships (FR-030-01, FR-030-03, S-030-17). _Intent:_ Test Person→User (belongsTo), Person→Faces (hasMany), Person→Photos (derived through Face). Test cascade: Photo delete → Face cascade delete. Test Person delete → Face.person_id set to null. _Verification commands:_ - `php artisan test --filter=PersonModelTest` - `make phpstan` -- [ ] T-030-14 – Write unit tests for Face model relationships (FR-030-02, FR-030-04). +- [x] T-030-14 – Write unit tests for Face model relationships (FR-030-02, FR-030-04). _Intent:_ Test Face→Photo (belongsTo), Face→Person (belongsTo nullable). Test bounding box validation (0.0–1.0 range). Test `crop_url` accessor (computed from `crop_token`). _Verification commands:_ - `php artisan test --filter=FaceModelTest` @@ -129,14 +135,14 @@ _Last updated: 2026-03-21_ ### I6 – Spatie Data Resources - [x] T-030-18 – Create PersonResource and FaceResource (DO-030-03, DO-030-04, Q-030-46). - _Intent:_ PersonResource: `id`, `name`, `user_id`, `is_searchable`, `face_count` (int), `photo_count` (int), `representative_crop_url`. FaceResource per DO-030-04: `id` (Face ID), `photo_id`, `person_id` (nullable), `x`/`y`/`width`/`height` (float 0.0–1.0), `confidence`, `is_dismissed`, `crop_url` (computed from `crop_token`: `uploads/faces/{tok[0:2]}/{tok[2:4]}/{tok}.jpg`; null if no crop). Embedded `suggestions[]` array — each item: `suggested_face_id`, `crop_url` (suggested face's own crop or null), `person_name` (nullable, LEFT JOIN), `confidence`. Suggestions always included (pre-computed from `face_suggestions` table, no N+1 risk). Include FaceResource array in PhotoResource with `hidden_face_count` (int, count of suppressed non-searchable faces — Q-030-10). + _Intent:_ PersonResource: `id`, `name`, `user_id`, `is_searchable`, `face_count` (int), `photo_count` (int), `representative_face_id` (nullable string), `representative_crop_url` (nullable string — computed: if `representative_face_id` is set and the referenced Face has a `crop_token`, use that face's crop URL; otherwise `SELECT crop_token FROM faces WHERE person_id = ? AND is_dismissed = false AND crop_token IS NOT NULL ORDER BY confidence DESC LIMIT 1`; null if no qualifying face). FaceResource per DO-030-04: `id` (Face ID), `photo_id`, `person_id` (nullable), `x`/`y`/`width`/`height` (float 0.0–1.0), `confidence`, `is_dismissed`, `crop_url` (computed from `crop_token`: `uploads/faces/{tok[0:2]}/{tok[2:4]}/{tok}.jpg`; null if no crop). Embedded `suggestions[]` array — each item: `suggested_face_id`, `crop_url` (suggested face's own crop or null), `person_name` (nullable, LEFT JOIN), `confidence`. Suggestions always included (pre-computed from `face_suggestions` table, no N+1 risk). Include FaceResource array in PhotoResource with `hidden_face_count` (int, count of suppressed non-searchable faces — Q-030-10). _Verification commands:_ - `make phpstan` _Notes:_ Follow existing Spatie Data patterns in app/Http/Resources/. ### I7 – Person CRUD Endpoints -- [ ] T-030-19 – Write feature tests for Person CRUD and non-searchable filtering (FR-030-01, FR-030-06, S-030-05, S-030-15, S-030-18). +- [x] T-030-19 – Write feature tests for Person CRUD and non-searchable filtering (FR-030-01, FR-030-06, S-030-05, S-030-15, S-030-18). _Intent:_ Tests for: list persons (paginated), get person, create person, update person (name, is_searchable), delete person (face.person_id nullified). Non-searchable person hidden from non-admin non-linked users. Admin sees all. Test both `open` and `restricted` permission modes (Q-030-08 resolved). Verify hidden_face_count in photo detail response. _Verification commands:_ - `php artisan test --filter=PeopleControllerTest` @@ -150,41 +156,41 @@ _Last updated: 2026-03-21_ ### I8 – Person Claim, Admin Override, Merge & Selfie Claim -- [ ] T-030-21 – Write feature tests for Person claim, admin override, and merge (FR-030-05, FR-030-11, S-030-04, S-030-13, S-030-16, S-030-19). +- [x] T-030-21 – Write feature tests for Person claim, admin override, and merge (FR-030-05, FR-030-11, S-030-04, S-030-13, S-030-16, S-030-19). _Intent:_ Tests: claim person (success, sets user_id), claim already-claimed (409), admin force-claim (overrides existing link), unclaim. Merge: faces reassigned from source to target, source deleted, face count updated. Test both permission modes. _Verification commands:_ - `php artisan test --filter=PersonClaimTest` - `php artisan test --filter=PersonMergeTest` - `make phpstan` -- [x] T-030-22 – Implement claim (user + admin override) and merge actions (API-030-06, API-030-07). - _Intent:_ ClaimPerson action: set person.user_id to Auth::id(), enforce uniqueness for non-admin. Admin claim: override existing link (clear previous user's claim, set new). MergePerson action: reassign Face records, delete source Person. Register routes. +- [x] T-030-22 – Implement claim (user + admin override), unclaim, and merge actions (API-030-06, API-030-07, API-030-15). + _Intent:_ ClaimPerson action: set person.user_id to Auth::id(), enforce uniqueness for non-admin. Admin claim: override existing link (clear previous user's claim, set new). UnclaimPerson action: `DELETE /api/v2/Person/{id}/claim` — sets `person.user_id = null`; only linked User or admin can unclaim (FR-030-05). MergePerson action: reassign Face records, delete source Person. Register all three routes. _Verification commands:_ - `php artisan test --filter=PersonClaimTest` - `php artisan test --filter=PersonMergeTest` - `make phpstan` -- [ ] T-030-23 – Write feature tests for selfie-upload claim (FR-030-12, S-030-20, S-030-21, S-030-22). +- [x] T-030-23 – Write feature tests for selfie-upload claim (FR-030-12, S-030-20, S-030-21, S-030-22). _Intent:_ Tests: upload selfie → Python service returns match → Person linked (success); selfie with no face detected (422); no matching Person (404); matched Person already claimed by another user (409). Verify selfie image discarded after match (Q-030-11 resolved). _Verification commands:_ - `php artisan test --filter=SelfieClaimTest` - `make phpstan` - [x] T-030-24 – Implement SelfieClaimController (API-030-13). - _Intent:_ POST /Person/claim-by-selfie: accepts multipart image upload, sends to Python service `POST /match` (Q-030-12 resolved: dedicated endpoint), receives matching person_id + confidence, validates confidence ≥ `ai_vision_face_selfie_confidence_threshold`, links Person to User (same 1-1 rules), deletes temp selfie. Register route. + _Intent:_ POST /Person/claim-by-selfie: accepts multipart image upload, sends to Python service `POST /match` (Q-030-12 resolved: dedicated endpoint), receives matching person_id + confidence, validates confidence ≥ `ai_vision_face_selfie_confidence_threshold`, links Person to User (same 1-1 rules), deletes temp selfie. Apply Laravel `throttle:5,1` middleware to this route (5 requests/minute per user — Q-030-44). Register route. _Verification commands:_ - `php artisan test --filter=SelfieClaimTest` - `make phpstan` ### I9 – Face Assignment, Dismiss & Cleanup Endpoints -- [ ] T-030-25 – Write feature tests for face assignment (FR-030-10, S-030-02, S-030-03). +- [x] T-030-25 – Write feature tests for face assignment (FR-030-10, S-030-02, S-030-03). _Intent:_ Tests: assign face to existing person, assign face creating new person, reassign face to different person. Test all four `ai_vision_face_permission_mode` values. _Verification commands:_ - `php artisan test --filter=FaceAssignmentTest` - `make phpstan` -- [ ] T-030-26 – Write feature tests for face dismiss/undismiss and admin bulk delete (API-030-14, API-030-16, S-030-24, S-030-25, Q-030-47). +- [x] T-030-26 – Write feature tests for face dismiss/undismiss and admin bulk delete (API-030-14, API-030-16, S-030-24, S-030-25, Q-030-47). _Intent:_ Tests: `PATCH /api/v2/Face/{id}` toggles `is_dismissed`; photo owner can dismiss, non-owner gets 403; admin can always dismiss. `DELETE /api/v2/Face/dismissed` hard-deletes all `is_dismissed = true` faces, removes crop files, returns count. Emit `face.dismissed` / `face.undismissed` / `face.bulk_deleted` telemetry events (TE-030-10/11/12). _Verification commands:_ - `php artisan test --filter=FaceDismissTest` @@ -199,13 +205,13 @@ _Last updated: 2026-03-21_ ### I10 – Scan Trigger & Result Ingestion Endpoints -- [ ] T-030-27 – Write feature tests for scan trigger and result ingestion (FR-030-07, FR-030-08, S-030-01, S-030-07, S-030-08, S-030-14, S-030-23). +- [x] T-030-27 – Write feature tests for scan trigger and result ingestion (FR-030-07, FR-030-08, S-030-01, S-030-07, S-030-08, S-030-14, S-030-23). _Intent:_ Tests: trigger scan for photo (202), trigger scan for album, receive results (Face records created with crop_token), re-scan replaces old faces (old crops deleted), invalid photo_id (404), auto-scan on upload when enabled. Test both permission modes for scan trigger. _Verification commands:_ - `php artisan test --filter=FaceDetectionTest` - `make phpstan` -- [ ] T-030-28 – Write feature test for service unavailability (FR-030-08, NFR-030-03, S-030-09). +- [x] T-030-28 – Write feature test for service unavailability (FR-030-08, NFR-030-03, S-030-09). _Intent:_ Test: scan trigger when Python service is unreachable returns 503; all other Lychee endpoints continue to work. _Verification commands:_ - `php artisan test --filter=FaceDetectionServiceUnavailableTest` @@ -219,7 +225,7 @@ _Last updated: 2026-03-21_ ### I11 – Bulk Scan Commands & Maintenance Endpoints -- [ ] T-030-30 – Write feature tests for `lychee:scan-faces` command (FR-030-09, S-030-06, CLI-030-01, CLI-030-02). +- [x] T-030-30 – Write feature tests for `lychee:scan-faces` command (FR-030-09, S-030-06, CLI-030-01, CLI-030-02). _Intent:_ Tests: command enqueues photos where `face_scan_status IS NULL` (not failed/completed), `--album` filter works (non-recursive — only direct photos in album, Q-030-41), already-scanned photos skipped. _Verification commands:_ - `php artisan test --filter=ScanFacesCommandTest` @@ -237,7 +243,7 @@ _Last updated: 2026-03-21_ - `php artisan test --filter=RescanFailedFacesCommandTest` - `make phpstan` -- [ ] T-030-31c – Write feature tests and implement Maintenance::resetStuckFaces endpoints (API-030-17, API-030-17b, Q-030-48, S-030-26). +- [x] T-030-31c – Write feature tests and implement Maintenance::resetStuckFaces endpoints (API-030-17, API-030-17b, Q-030-48, S-030-26). _Intent:_ `GET /api/v2/Maintenance::resetStuckFaces`: admin-only check — returns `{count: N}` for photos stuck in `pending` longer than `older_than_minutes` (default 60). `POST /api/v2/Maintenance::resetStuckFaces`: admin-only do — resets those records to `null` and returns `{reset_count: N}`. Body: optional `older_than_minutes` (integer). Follows existing `Maintenance::cleaning` / `Maintenance::jobs` check/do pattern. Register in `api_v2.php`. _Verification commands:_ - `php artisan test --filter=MaintenanceResetStuckFacesTest` @@ -245,7 +251,7 @@ _Last updated: 2026-03-21_ ### I12 – Person Photos Endpoint -- [ ] T-030-32 – Write feature test for Person photos listing (FR-030-03, S-030-12, API-030-08). +- [x] T-030-32 – Write feature test for Person photos listing (FR-030-03, S-030-12, API-030-08). _Intent:_ Tests: get paginated photos for person, respects album access control (user without album access doesn't see photo), empty result for person with no faces. _Verification commands:_ - `php artisan test --filter=PersonPhotosTest` @@ -261,7 +267,7 @@ _Last updated: 2026-03-21_ ### I13 – Frontend: People Page -- [ ] T-030-34 – Create People.vue, PeopleService.ts, and PersonCard.vue (UI-030-01). +- [x] T-030-34 – Create People.vue, PeopleService.ts, and PersonCard.vue (UI-030-01). _Intent:_ People page at /people route. Grid of PersonCard components (server-side face crop thumbnail from crop_url, name, photo count). PeopleService: getPeople(), getPerson(), etc. Empty state when no persons exist. Service unavailable state (toast notification). Navigation link in sidebar. _Verification commands:_ - `npm run check` @@ -269,7 +275,7 @@ _Last updated: 2026-03-21_ ### I14 – Frontend: Person Detail Page -- [ ] T-030-35 – Create PersonDetail.vue (UI-030-02). +- [x] T-030-35 – Create PersonDetail.vue (UI-030-02). _Intent:_ Person detail at /people/:id. Person info header (name, counts, linked user, searchability badge). Paginated photo grid (reuse existing layout components). Action buttons: Edit, Toggle searchable, Merge, Delete. Route registration. _Verification commands:_ - `npm run check` @@ -277,7 +283,7 @@ _Last updated: 2026-03-21_ ### I15 – Frontend: Face Overlays on Photo Detail -- [ ] T-030-36 – Create FaceOverlay.vue and integrate into photo detail (UI-030-03). +- [x] T-030-36 – Create FaceOverlay.vue and integrate into photo detail (UI-030-03). _Intent:_ Positioned div overlays on photo using bounding box percentages (x, y, width, height as CSS left/top/width/height %). Name label per overlay. "Unknown" for unassigned faces. Non-searchable faces: overlays hidden entirely; display "{N} face(s) hidden for privacy" message when `hidden_face_count > 0` (Q-030-10 resolved). Click unassigned → open assignment modal. Responsive scaling with image container. _Verification commands:_ - `npm run check` @@ -285,7 +291,7 @@ _Last updated: 2026-03-21_ ### I16 – Frontend: Face Assignment Modal -- [ ] T-030-37 – Create FaceAssignmentModal.vue (UI-030-04). +- [x] T-030-37 – Create FaceAssignmentModal.vue (UI-030-04). _Intent:_ Modal triggered by clicking unassigned face overlay. Face crop preview (from crop_url), confidence display. PrimeVue Dropdown to select existing person (with filter). Text input for new person name. Calls FaceService.assign() on confirm. Refreshes face overlays after success. _Verification commands:_ - `npm run check` @@ -293,7 +299,7 @@ _Last updated: 2026-03-21_ ### I17 – Frontend: Scan Trigger UI -- [ ] T-030-38 – Add scan trigger buttons to photo/album context menus and admin page (UI-030-05, UI-030-06). +- [x] T-030-38 – Add scan trigger buttons to photo/album context menus and admin page (UI-030-05, UI-030-06). _Intent:_ "Scan for faces" in photo context menu (calls FaceDetectionService.scan). "Scan album" in album context menu. "Bulk scan all photos" in admin Maintenance page. Progress toast during scanning. Graceful handling when service unavailable. _Verification commands:_ - `npm run check` @@ -301,7 +307,7 @@ _Last updated: 2026-03-21_ ### I18 – Frontend: Selfie Upload Claim -- [ ] T-030-39 – Create SelfieClaimModal.vue and integrate into user profile (UI-030-07, S-030-20, S-030-21, S-030-22). +- [x] T-030-39 – Create SelfieClaimModal.vue and integrate into user profile (UI-030-07, S-030-20, S-030-21, S-030-22). _Intent:_ Modal with file upload area (drag & drop or click) for selfie image. Sends to API-030-13. Displays matching Person result (face crop, name, confidence score). Confirm button links Person to User. Error states: no face detected, no match found, already claimed. "Find me in photos" button on user profile page triggers modal. _Verification commands:_ - `npm run check` @@ -311,27 +317,83 @@ _Last updated: 2026-03-21_ ### I19 – Documentation & Quality Gate -- [ ] T-030-40 – Update knowledge-map.md with Person/Face models and service integration. +- [x] T-030-40 – Update knowledge-map.md with Person/Face models and service integration. _Intent:_ Add Person, Face to Domain Layer models. Add Python face-recognition service to Dependencies. Add inter-service communication to Architectural Patterns. Add shared Docker volume architecture. _Verification commands:_ - Review documentation for accuracy. -- [ ] T-030-41 – Update database-schema.md with persons and faces tables. +- [x] T-030-41 – Update database-schema.md with persons and faces tables. _Intent:_ Add table definitions (including `crop_token` on faces, `face_suggestions` table, `face_scan_status` on photos), relationships, indexes, and constraints. _Verification commands:_ - Review documentation for accuracy. -- [ ] T-030-42 – Create configure-facial-recognition.md how-to guide. +- [x] T-030-42 – Create configure-facial-recognition.md how-to guide. _Intent:_ Docker setup instructions, shared volume configuration, environment variables, permission modes (open/restricted), service health check, troubleshooting. _Verification commands:_ - Review documentation for accuracy. -- [ ] T-030-43 – Run full quality gate and update roadmap. - _Intent:_ Run all quality gates across all three codebases. All green. Update roadmap status to Complete. +### I20 – Clustering Endpoint: Python `POST /cluster` + PHP Ingestion & Trigger + +- [x] T-030-43 – Add `cluster_label` column migration to `faces` table (DO-030-07, Q-030-49). + _Intent:_ New migration: `ALTER TABLE faces ADD COLUMN cluster_label INT NULL`. Add composite index `(cluster_label, person_id, is_dismissed)` on `faces` to support O(index-scan) `GROUP BY cluster_label` paging in API-030-18. This migration is a prerequisite for T-030-44 (Python cluster ingestion) and T-030-50 (cluster-review API). Run tests to confirm migration applies cleanly on SQLite. _Verification commands:_ - - Python: `cd ai-vision-service && uv run ruff format --check && uv run ruff check && uv run ty check && uv run pytest --cov=app` - - PHP: `vendor/bin/php-cs-fixer fix && php artisan test && make phpstan` - - Frontend: `npm run format && npm run check` + - `php artisan test` + - `make phpstan` + +- [x] T-030-44 – Implement Python `POST /cluster` endpoint and wire `FaceClusterer` (FR-030-13, S-030-27, Q-030-49). + _Intent:_ Add `ClusterResponse` Pydantic schema (`{clusters: int, faces_labeled: int, suggestions_generated: int}`) to `app/api/schemas.py`. Add `VISION_FACE_CLUSTER_EPS` (float, default `0.6`) to `AppSettings`. Extend `app/clustering/clusterer.py` with `run_cluster_and_notify(store, lychee_url, api_key)`: read all embeddings, run DBSCAN, produce (a) `labels` list — `[{face_id: str, cluster_label: int}]` for every non-noise face (noise faces skipped); (b) `suggestions` list — `(face_id, suggested_face_id, confidence)` pairs for every intra-cluster pair (cosine similarity); POST `{labels: [...], suggestions: [...]}` to `{lychee_url}/api/v2/FaceDetection/cluster-results` with `X-API-Key`. Add `POST /cluster` route to `app/api/routes.py` (X-API-Key auth dependency) that calls `run_cluster_and_notify()` and returns `ClusterResponse`. Add unit + integration tests in `tests/test_clustering.py` (mock httpx POST to Lychee). + _Verification commands:_ + - `cd ai-vision-service && uv run pytest tests/test_clustering.py` + - `uv run ty check` + - `uv run ruff check` + +- [x] T-030-45 – Write feature tests and implement PHP `POST /FaceDetection/cluster-results` ingestion endpoint (FR-030-13, Q-030-49). + _Intent:_ New action `clusterResults` on `FaceDetectionController`: auth via X-API-Key header; validate body `{labels: [{face_id: str, cluster_label: int}], suggestions: [{face_id: str, suggested_face_id: str, confidence: float}]}` (both arrays optional — empty = no-op for that field). Processing: (1) if `labels` non-empty — first reset all `faces.cluster_label` to NULL (full re-cluster run), then bulk `UPDATE faces SET cluster_label = ? WHERE id = ?` for each label entry; (2) if `suggestions` non-empty — bulk-upsert `face_suggestions` rows on `(face_id, suggested_face_id)` updating `confidence`. Return `{faces_labeled: N, suggestions_updated: M}`. Feature tests: labels bulk-update `faces.cluster_label` correctly; suggestions upserted; both arrays in same request; invalid API key (401); malformed body (422); unknown face_id (422). Register route in `api_v2.php`. + _Verification commands:_ + - `php artisan test --filter=FaceClusterResultsTest` + - `make phpstan` + +- [x] T-030-46 – Write feature tests and implement PHP `POST /Maintenance::runFaceClustering` admin trigger (FR-030-13). + _Intent:_ Admin-only Maintenance endpoint following the existing check/do pattern. `POST /api/v2/Maintenance::runFaceClustering`: calls Python service `POST /cluster` via HTTP with `X-API-Key`; returns 202 Accepted on success; returns 503 if Python service is unreachable (NFR-030-03). Feature tests: admin triggers clustering (202), non-admin gets 403, service unavailable (503). Register route in `api_v2.php`. + _Verification commands:_ + - `php artisan test --filter=MaintenanceFaceClusteringTest` + - `make phpstan` + +### I21 – Embedding Sync on Deletion + Blur Threshold Filtering + +- [x] T-030-47 – Python: `VISION_FACE_BLUR_THRESHOLD` filter in detector (FR-030-02, S-030-30). + _Intent:_ Add `VISION_FACE_BLUR_THRESHOLD` (float, default `100.0`) to `AppSettings` in `app/config.py`. In `app/detection/detector.py`, after detecting each face and cropping the bounding-box region, compute its Laplacian variance (`cv2.Laplacian(crop_region, cv2.CV_64F).var()`); discard any face whose variance is below the threshold before adding it to the results list. A value of `0.0` disables the filter (all faces pass). Update `tests/test_detection.py`: verify a synthetic blurry patch (Gaussian blur, variance << threshold) is excluded; verify a sharp patch is retained. + _Verification commands:_ + - `cd ai-vision-service && uv run pytest tests/test_detection.py` + - `uv run ty check` + - `uv run ruff check` + +- [x] T-030-48 – Python: `DELETE /embeddings` endpoint (FR-030-14, S-030-28, S-030-29). + _Intent:_ Add `delete_many(face_ids: list[str]) -> int` to the `EmbeddingStore` protocol in `app/embeddings/store.py` and implement it in both `SQLiteStore` and `PgVectorStore` (ignores unknown IDs silently; returns count of rows actually deleted). Add `DELETE /embeddings` route in `app/api/routes.py` (X-API-Key auth): accepts `{face_ids: list[str]}`, calls `store.delete_many()`, returns `{deleted_count: int}`. Add tests in `tests/test_api.py`: success (returns count), invalid API key (401), empty list (400), IDs not in store (returns `{deleted_count: 0}`). + _Verification commands:_ + - `cd ai-vision-service && uv run pytest tests/test_api.py tests/test_embeddings.py` + - `uv run ty check` + - `uv run ruff check` + +- [x] T-030-49 – PHP: dispatch embedding deletion after Face hard-deletes (FR-030-14, S-030-28, S-030-29). + _Intent:_ Create `DeleteFaceEmbeddingsJob` (implements `ShouldQueue`): accepts `array $faceIds`, calls Python `DELETE /embeddings` via HTTP with `X-API-Key`; catches all exceptions, logs a warning (`Log::warning`), and returns without re-throwing (never fails the queue worker or rolls back the Lychee deletion). Dispatch this job from **two explicit call-sites** (no Face model observer — Q-030-52/Option B): (1) in `destroyDismissed` action — collect IDs of dismissed faces before `Face::where('is_dismissed', true)->delete()`, then dispatch job; (2) in `PhotoObserver::deleting` — collect `$photo->faces()->pluck('id')` before cascade delete, then dispatch batch job for those IDs. Write feature tests: `DELETE /Face/dismissed` → job dispatched with correct IDs; Photo delete → job dispatched for cascaded faces; Python service unavailable → Lychee deletion succeeds, warning logged. + _Verification commands:_ + - `php artisan test --filter=FaceEmbeddingSyncTest` + - `make phpstan` + +### I22 – Cluster Review UI: Browse & Bulk-Name/Dismiss Clusters + +- [x] T-030-50 – Write feature tests and implement PHP cluster-review API endpoints (FR-030-15, API-030-18, API-030-19, API-030-20, S-030-31, S-030-32, Q-030-49). + _Intent:_ `GET /api/v2/FaceDetection/clusters`: `SELECT cluster_label, COUNT(*) as size FROM faces WHERE cluster_label IS NOT NULL AND person_id IS NULL AND is_dismissed = false GROUP BY cluster_label ORDER BY cluster_label LIMIT ? OFFSET ?` (uses composite index DO-030-07); for each cluster, load preview faces via `WHERE cluster_label = ? AND person_id IS NULL AND is_dismissed = false`; return `{cluster_id: int, size: int, faces: FaceResource[]}`. Respects `ai_vision_face_permission_mode`. `POST /api/v2/FaceDetection/clusters/{cluster_id}/assign`: validate `cluster_id` is a valid integer `cluster_label` with qualifying faces; create Person if `new_person_name` supplied (or validate existing `person_id`); bulk `UPDATE faces SET person_id = ? WHERE cluster_label = ? AND person_id IS NULL AND is_dismissed = false`; emit `face.cluster_assigned`; return `{person_id, assigned_count}`. `POST /api/v2/FaceDetection/clusters/{cluster_id}/dismiss`: bulk `UPDATE faces SET is_dismissed = true WHERE cluster_label = ? AND person_id IS NULL AND is_dismissed = false`; emit `face.cluster_dismissed`; return `{dismissed_count}`. Feature tests: list clusters (only qualifying faces; already-assigned or dismissed excluded), 404 for unknown cluster_id, assign cluster (new person + faces linked; existing person used if person_id supplied), dismiss cluster (all qualifying faces marked is_dismissed). Test permission mode enforcement (public/restricted at minimum). Register routes in `api_v2.php`. + _Verification commands:_ + - `php artisan test --filter=FaceClusterReviewTest` + - `make phpstan` + +- [x] T-030-51 – Create FaceClusterService.ts, FaceClusters.vue page, and wire into navigation (FR-030-15, UI-030-08, S-030-31, S-030-32). + _Intent:_ Create `services/FaceClusterService.ts` with typed functions: `getClusters(page)`, `assignCluster(clusterId, payload)`, `dismissCluster(clusterId)`, `runClustering()` — all using `${Constants.getApiUrl()}` base URL. New Vue3 view `FaceClusters.vue` at `/people/clusters`. Fetches `GET /FaceDetection/clusters` (paginated, via FaceClusterService). Renders a vertical list of cluster cards; each card shows: first 5 face-crop `` thumbnails (from `crop_url`), "+N more" badge when `size > 5`, size badge, a name `InputText`, a "Create Person & Assign All" `Button` (calls `assignCluster` with `new_person_name`; or if person selected from dropdown, uses `person_id`), and a "Dismiss" `Button` (calls `dismissCluster`). After either action, remove the cluster card from the list without full-page reload. "Run Cluster" `Button` in page header calls `runClustering()` then re-fetches clusters. Empty state illustration when no clusters exist. Add `/people/clusters` route to Vue Router and a "Clusters" navigation link under People in the sidebar (visible only when `ai_vision_face_enabled` is true). + _Verification commands:_ + - `npm run check` + - `npm run format` ## Notes / TODOs diff --git a/docs/specs/4-architecture/knowledge-map.md b/docs/specs/4-architecture/knowledge-map.md index 827190d385d..1d206f6eac4 100644 --- a/docs/specs/4-architecture/knowledge-map.md +++ b/docs/specs/4-architecture/knowledge-map.md @@ -14,6 +14,16 @@ This document tracks modules, dependencies, and architectural relationships acro #### Domain Layer - **Models** (`app/Models/`) - Eloquent ORM models for database entities + - **Person Model** (`app/Models/Person.php`) - Represents an identified individual across multiple photos + - Optional 1-to-1 link to a `User` account (claimed person) + - `is_searchable` flag — when false, face overlays are hidden from non-owners (privacy mode) + - Has many `Face` records + - **Face Model** (`app/Models/Face.php`) - A detected face bounding box on a specific photo + - Bounding box stored as relative floats (0.0–1.0) in `x`, `y`, `width`, `height` + - `crop_token` — opaque token used to serve a cropped face thumbnail via dedicated endpoint + - `is_dismissed` — operator/owner has chosen to ignore this face detection + - Belongs to `Photo` (cascade delete), belongs to `Person` (null on delete) + - Has many `FaceSuggestion` (similar faces ranked by confidence) - **Album Model** - Nested set tree structure with pre-computed statistical fields: - `num_children` - Count of direct child albums - `num_photos` - Count of photos directly in this album (not descendants) @@ -52,6 +62,7 @@ This document tracks modules, dependencies, and architectural relationships acro - `RecomputeAlbumStatsOnAlbumChange` - Dispatches recomputation job for parent album - **Jobs** (`app/Jobs/`) - Asynchronous task definitions - `RecomputeAlbumStatsJob` - Recomputes album statistics and propagates changes to ancestors + - `ScanFacesJob` - Dispatches face detection requests to the Python AI Vision service for a batch of photo IDs; sets `face_scan_status = pending` on dispatch, `scanned` on completion - Uses `WithoutOverlapping` middleware (keyed by album_id) to prevent concurrent updates - Atomic transaction with 3 retries + exponential backoff - Propagates to parent album after successful update (cascades to root) @@ -75,13 +86,21 @@ This document tracks modules, dependencies, and architectural relationships acro - Conditional rendering (hidden when no rated photos) - Toggle behavior (click same star to clear) - Keyboard accessible (Arrow keys, Enter/Space) + - **FaceOverlay** (`photoModule/FaceOverlay.vue`) - Absolutely-positioned bounding boxes over the photo detail view; opens `FaceAssignmentModal` on click of unknown faces - Forms, modals, drawers, settings components + - **FaceAssignmentModal** - Assign an unknown face to an existing or new Person; shows suggestions ranked by confidence + - **SelfieClaimModal** - Upload a selfie to match and claim a Person profile (links Person ↔ User) + - Maintenance components + - **MaintenanceBulkScanFaces** - Card to trigger a bulk scan of all unscanned photos - **Views** (`resources/js/views/`) - Page-level Vue components - Gallery views: Albums, Album, Favourites, Flow, Frame, Map, Search - Admin views: Settings, Users, Permissions, Maintenance, Diagnostics + - People views: **People** (`/people`) — paginated PersonCard grid; **PersonDetail** (`/people/:personId`) — photos grid with edit/delete/merge actions - **Composables** (`resources/js/composables/`) - Reusable composition functions - Album, photo, search, selection, context menu composables - **Services** (`resources/js/services/`) - API communication layer using axios + - `people-service.ts` - CRUD for Person; claim/unclaim; merge; selfie upload + - `face-detection-service.ts` - Scan photos/albums; assign/dismiss faces; bulk scan - **Layouts** (`resources/js/layouts/`) - Photo layout algorithms (square, justified, masonry, grid) #### State Management @@ -106,6 +125,14 @@ This document tracks modules, dependencies, and architectural relationships acro - **LdapRecord Laravel** - LDAP/Active Directory integration (v3.4.2) - Dependency: php-ldap PHP extension required +### External Services +- **AI Vision Service** (`ai-vision-service/`) - Sidecar Python microservice for facial recognition + - Framework: FastAPI + uv package manager + - Single shared symmetric API key (`AI_VISION_FACE_API_KEY` in Lychee / `VISION_FACE_API_KEY` in Python) used in both directions via `X-API-Key` header + - File access: reads photos from a **shared Docker volume** (no file transfer over HTTP) + - Embeddings persisted to a separate named volume (`ai_vision_embeddings`) + - Supporter Edition (SE) feature: endpoints return 403 on non-SE instances + ### Frontend Dependencies - **Vue3** - Progressive JavaScript framework (Composition API) - **TypeScript** - Type-safe JavaScript @@ -231,6 +258,21 @@ Preserves original camera RAW / HEIC / PSD files as a dedicated size variant whi - Resource classes extend Spatie Data (not JsonResource) - No Blade views - Vue3 only +### AI Vision Inter-Service Communication +Lychee uses a **REST + webhook** pattern for facial recognition: + +1. **Scan trigger** — Lychee sets `face_scan_status = pending` on photo(s), dispatches `ScanFacesJob` +2. **Batch HTTP request** — Job sends `POST /detect` to Python service with an array of `{photo_id, file_path}` pairs (file paths resolve to the shared Docker volume mount) +3. **Async callback** — Python service POSTs results back to Lychee's internal callback endpoint with detected bounding boxes and embedding vectors +4. **DB write** — Lychee creates `Face` rows and `FaceSuggestion` rows; sets `face_scan_status = scanned` +5. **Cluster/compare** — On claim or merge, Lychee calls `POST /embeddings/compare` for similarity matching + +Shared volume architecture: +``` +./lychee/uploads ──► lychee_api:/app/public/uploads (read/write) + ──► ai_vision:/data/photos (read-only) +``` + ## Cross-Module Contracts ### API Communication @@ -256,6 +298,7 @@ Preserves original camera RAW / HEIC / PSD files as a dedicated size variant whi ### How-To Guides - [Add OAuth Provider](../2-how-to/add-oauth-provider.md) - Step-by-step OAuth integration - [Configure Pagination](../2-how-to/configure-pagination.md) - Album and photo pagination settings +- [Configure Facial Recognition](../2-how-to/configure-facial-recognition.md) - Docker setup, shared volume, environment variables, and permission modes - [Translating Lychee](../2-how-to/translating-lychee.md) - Translation guide for developers and translators - [Using Renamer](../2-how-to/using-renamer.md) - Filename transformation during import @@ -295,4 +338,4 @@ Preserves original camera RAW / HEIC / PSD files as a dedicated size variant whi --- -*Last updated: January 14, 2026* +*Last updated: March 22, 2026* diff --git a/docs/specs/4-architecture/open-questions.md b/docs/specs/4-architecture/open-questions.md index c9299b9d8fb..817b62423bf 100644 --- a/docs/specs/4-architecture/open-questions.md +++ b/docs/specs/4-architecture/open-questions.md @@ -6,10 +6,108 @@ Track unresolved high- and medium-impact questions here. Remove each row as soon | Question ID | Feature | Priority | Summary | Status | Opened | Updated | |-------------|---------|----------|---------|--------|--------|---------| -| (none) | — | — | — | — | — | — | +| *(none)* | | | | | | | ## Question Details +### ~~Q-030-50: `PersonResource.representative_crop_url` — Selection Rule Unspecified~~ ✅ RESOLVED + +**Resolution:** **Options A + C** combined. Default logic uses highest-confidence non-dismissed face (`ORDER BY confidence DESC LIMIT 1`). A `representative_face_id` nullable FK→`faces` ON DELETE SET NULL is also added to the `persons` table (DO-030-08, T-030-53), allowing admins/users to override the representative via `PATCH /Person/{id}`. `PersonResource` uses the FK if set (and the referenced Face has a `crop_token`); otherwise falls back to the highest-confidence SELECT. Captured in DO-030-01, DO-030-03, DO-030-08, T-030-10 (note), T-030-18, T-030-53. + +**Context:** DO-030-03 (`PersonResource`) lists `representative_crop_url` as a field. T-030-18 mentions it. The spec has no rule for *which* Face crop is chosen as representative. `PersonCard.vue` uses it as the person's avatar on the People page. + +**Impact:** If implementors pick different strategies independently, the result will differ from what product design expects. Affects I6 (FaceResource), I13 (People page thumbnails). + +**Option A (Recommended) — highest-confidence face crop** +- `SELECT crop_token FROM faces WHERE person_id = ? AND is_dismissed = false AND crop_token IS NOT NULL ORDER BY confidence DESC LIMIT 1` +- Deterministic, stable once detection quality is good, no additional sort column needed. + +**Option B — most-recently added face crop** +- `ORDER BY created_at DESC LIMIT 1` +- Reflects the latest photo that person appeared in — may be more "current" but less relevant. + +**Option C — null until user explicitly sets a representative face** +- Add a `representative_face_id` nullable FK on `persons` table, set via a new PATCH sub-action. +- Fully explicit but requires extra migration and UI affordance. + +**Affects:** DO-030-03, T-030-18, PersonResource, PersonCard.vue. + +--- + +### ~~Q-030-51: `ai_vision_enabled` / `ai_vision_face_enabled` Gating Hierarchy~~ ✅ RESOLVED + +**Resolution:** **Option A** — compound gate. All `ai_vision_face_*` functionality is implicitly gated on `ai_vision_enabled = 1`. Any code path that gates on `ai_vision_face_enabled` must first confirm `ai_vision_enabled = 1`. If `ai_vision_enabled = 0`, all AI Vision endpoints return 503 / UI hides all AI Vision elements, regardless of `ai_vision_face_enabled`. Captured in NFR-030-10 and config table note for `ai_vision_face_enabled`. + +**Context:** T-030-12 adds two separate config flags: `ai_vision_enabled` (global feature kill-switch for the whole AI Vision system) and `ai_vision_face_enabled` (specifically enables face detection). T-030-29 fires auto-scan when `ai_vision_face_enabled = 1`, but no task or spec explicitly states that `ai_vision_face_enabled` must ALSO check `ai_vision_enabled` first. An implementor could check only one flag or check both. + +**Impact:** If `ai_vision_enabled = 0` but `ai_vision_face_enabled = 1`, undefined behaviour. Affects I8, I9, I10, I17 (every place that gates on the config). + +**Option A (Recommended) — `ai_vision_face_enabled` implies `ai_vision_enabled`; guard with both** +- Any code path that checks `ai_vision_face_enabled` must first confirm `ai_vision_enabled`. Documented as a compound gate in NFR or as a middleware. +- Spec adds: "All `ai_vision_face_*` functionality is implicitly gated on `ai_vision_enabled = 1`." + +**Option B — single flag; remove `ai_vision_enabled`** +- Since only face detection exists now, `ai_vision_face_enabled` is the only effective toggle. `ai_vision_enabled` is removed or deferred to when a second AI Vision feature ships. +- Simpler, but loses the global kill-switch if other AI features follow. + +**Option C — independent flags; document the combination table** +- `ai_vision_enabled` controls API availability (503 when off). `ai_vision_face_enabled` controls auto-on-upload and People page visibility. Both can vary independently. + +**Affects:** FR-030-08, NFR-030-03, T-030-12, T-030-29, T-030-38, FaceDetectionController, FaceDetectionService.ts. + +--- + +### ~~Q-030-52: Embedding Deletion Dispatch Hook — Observer vs. Photo Pipeline~~ ✅ RESOLVED + +**Resolution:** **Option B** — no Face model observer. Two explicit call-sites: (1) `destroyDismissed` action — collect dismissed face IDs before `Face::where('is_dismissed', true)->delete()`, dispatch `DeleteFaceEmbeddingsJob`; (2) `PhotoObserver::deleting` — collect `$photo->faces()->pluck('id')` before cascade, dispatch batch job. Captured in FR-030-14 and T-030-49. + +**Context:** T-030-49 (FR-030-14) specifies dispatching `DeleteFaceEmbeddingsJob` when Face records are hard-deleted. The task says "Face model observer `deleting` event, **or** by hooking into the Photo delete pipeline." These are architecturally different: + +- **Observer (`deleting`)**: fires per-row; requires N individual event firings for a batch delete; works for both cascade-from-Photo and admin bulk-delete paths uniformly. +- **Photo pipeline hook**: collects all `face_ids` before the cascade delete, dispatches one batch job; avoids N observer firings but only covers Photo→Face cascade. The admin `destroyDismissed` path still needs its own dispatch. + +**Impact:** The observer approach fires for every delete path automatically but causes N jobs for a batch Photo cascade. The pipeline approach is more efficient but duplicates dispatch logic. Affects I21 (PHP `DeleteFaceEmbeddingsJob`), T-030-49. + +**Option A (Recommended) — Observer on `deleting`, but coalesce with batch dispatch** +- Register a `Face` model observer. On `deleting`, collect IDs into a static `$pendingDeletion` buffer. A `deleted` static hook (or `booted` teardown) dispatches one job for the full batch at the end of the request lifecycle. Handles all delete paths without duplicating logic. + +**Option B — No observer; explicit dispatch at each call-site** +- `destroyDismissed`: collects IDs before delete, dispatches one job explicitly. +- Photo delete: `PhotoObserver::deleting` collects `$photo->faces()->pluck('id')` before cascade, dispatches job. +- Simpler per-path but requires remembering to add dispatch at every future Face-delete call-site. + +**Affects:** FR-030-14, T-030-49, I21, Face model observer, PhotoObserver, `destroyDismissed` action. + +--- + +### ~~Q-030-49: Cluster Storage Model — Resolved~~ ✅ RESOLVED — How Should the Backend Know About Existing Clusters? + +**Context:** FR-030-15 and API-030-18/19/20 specify a Cluster Review page. The current spec says `cluster_id` is "derived from the suggestion graph" (connected components of `face_suggestions`). This approach has three fatal flaws: (1) O(V+E) graph traversal per `GET /clusters` request violates NFR-030-02; (2) SHA1-of-sorted-face-IDs IDs are unstable — they change when any face in the cluster is dismissed or assigned, breaking `POST .../clusters/{id}/assign`; (3) pagination over connected components requires materialising all clusters first. + +**Option A (Recommended) — `cluster_label` nullable INT column on `faces`** +- DBSCAN already produces integer labels (0, 1, 2... for clusters; -1 = noise). Persist them directly. +- `POST /cluster-results` payload carries `{face_id, cluster_label}[]` alongside suggestion pairs; PHP bulk-updates `faces.cluster_label`. +- `GET /clusters` = standard `GROUP BY cluster_label` SQL with `LIMIT/OFFSET`; composite index on `(cluster_label, person_id, is_dismissed)`. +- `cluster_id` in the API = `cluster_label` integer (stable between clustering runs). +- Assign/dismiss = `WHERE cluster_label = ?`. +- Stale for faces added after last clustering run (they have `cluster_label = NULL` and don't appear until re-cluster). Acceptable — Cluster Review is explicitly post-clustering UX. +- **One nullable column. No new model. Trivial pagination.** + +**Option B — Separate `face_clusters` table + FK on `faces`** +- `face_clusters`: id (ULID), run_at, size (cached). +- `faces.cluster_id` FK → `face_clusters.id`. +- Pros: opaque ULID IDs, can record run timestamp. Cons: extra table + model, more complex ingestion, size cache goes stale on dismiss/assign. Not worth the complexity over A. + +**Option C — Keep as-is (on-the-fly BFS/DFS over `face_suggestions`)** +- Always reflects current suggestion relationships (no staleness). +- Fatal: O(V+E) per page load, unstable IDs, pagination infeasible. Violates NFR-030-02. **Not viable at scale.** + +**Required spec changes if Option A adopted:** +- `DO-030-02`: add `cluster_label` (nullable INT) to Face +- `DO-030-06` / migration: add `cluster_label INT NULL` column + composite index `(cluster_label, person_id, is_dismissed)` on `faces` +- `FR-030-13` (`POST /cluster-results`): body gains `{face_id: str, cluster_label: int | null}[]` alongside suggestion pairs +- `FR-030-15` / API-030-18/19/20: `cluster_id` = `cluster_label` integer; remove "opaque stable identifier derived from the suggestion graph" language; add note that noisy faces (`cluster_label = NULL`) excluded from Cluster Review + ### ~~Q-030-33: `face_suggestions` Schema Wrong — Face-to-Face, Not Face-to-Person~~ ✅ RESOLVED **Resolution:** **Option A** — schema changed to `(face_id FK→faces, suggested_face_id FK→faces, confidence)`. Both FKs point to `faces`. Python sends `lychee_face_id` (a Face ID) as the suggestion target — there is no concept of Persons in the Python service, and suggestions may reference unassigned faces (where `person_id IS NULL`). The assignment modal resolves `suggested_face_id → faces → persons` via LEFT JOIN at read time. A unique constraint on `(face_id, suggested_face_id)` prevents duplicate suggestion rows. @@ -18,6 +116,10 @@ Track unresolved high- and medium-impact questions here. Remove each row as soon **Resolved:** 2026-03-18 +**Resolution: Option A adopted.** nullable INT column on . Spec updated: DO-030-02, DO-030-07, FR-030-13, FR-030-15, API-030-18/19/20. + +**Resolved:** 2026-03-23 + --- ### ~~Q-030-34: Crop Serving Route Undefined~~ ✅ RESOLVED diff --git a/docs/specs/4-architecture/roadmap.md b/docs/specs/4-architecture/roadmap.md index 0b469ce7355..3f30bd149d7 100644 --- a/docs/specs/4-architecture/roadmap.md +++ b/docs/specs/4-architecture/roadmap.md @@ -6,7 +6,6 @@ High-level planning document for Lychee features and architectural initiatives. | Feature ID | Name | Status | Priority | Assignee | Started | Updated | Progress | |------------|------|--------|----------|----------|---------|---------|----------| -| 030 | AI Vision Service | Planning | P1 | LycheeOrg | 2026-03-15 | 2026-03-15 | Spec, plan, tasks drafted. 43 tasks across 19 increments (I1–I3 Python service, I4–I12 PHP backend, I13–I18 frontend, I19 docs). Q-030-01 through Q-030-12 resolved. 13 new open questions (Q-030-13 through Q-030-25) — 6 high, 7 medium. I1–I3 can start; I8 blocked on Q-030-13; I10 blocked on Q-030-14, Q-030-15, Q-030-17. | | 026 | Album Photo Tag Filter | Ready for Implementation | P2 | LycheeOrg | 2026-03-09 | 2026-03-09 | Spec, plan, tasks complete. 76 tasks across 9 increments (~32h estimated). All open questions resolved. Ready to begin Task 1.1. | ## Paused Features @@ -19,6 +18,7 @@ High-level planning document for Lychee features and architectural initiatives. | Feature ID | Name | Completed | Notes | |------------|------|-----------|-------| +| 030 | AI Vision Service | 2026-03-22 | Facial recognition sidecar service (FastAPI/Python). Python service (I1–I3): detection, embedding, clustering, matching, CI workflow. PHP backend (I4–I12): persons/faces/face_suggestions tables, Person/Face models, PersonResource/FaceResource, CRUD + scan + assign + merge + claim + selfie endpoints, permission matrix (4 modes), ScanFacesJob, CLI commands, admin config keys. Frontend (I13–I19): People/PersonDetail views, PersonCard, FaceOverlay, FaceAssignmentModal, SelfieClaimModal, MaintenanceBulkScanFaces, routes, left menu. All 43 tasks done. Full quality gate: PHPStan 0 errors, php-cs-fixer clean, vue-tsc clean, Python ruff/ty/pytest 51 passed. | | 029 | Camera Capture | 2026-03-18 | "Take Photo" in `+` add menu (album and root views). CameraCapture.vue modal: live video → canvas capture → JPEG preview → push to existing UploadPanel queue. Secure-context guard, mobile layout fixes, `Permissions-Policy: camera=(self)` header. No backend changes. | | 028 | Search UI Refactor | 2026-05-30 | Full refactor: simple input + collapsible advanced panel (17 fields: title, description, location, tags, date range, type, orientation, rating, EXIF fields). Token assembler/parser composable. No-debounce on-demand search. Auto-scroll to first result. vue-tsc clean, 74 PHP tests passed, PHPStan 0 errors. | | 025 | Dynamic Landing Background Options | 2026-03-03 | Spec, plan, tasks completed | diff --git a/lang/en/left-menu.php b/lang/en/left-menu.php index ac3d2cc00c9..2126800a311 100644 --- a/lang/en/left-menu.php +++ b/lang/en/left-menu.php @@ -24,4 +24,5 @@ 'support' => 'Support', 'contact' => 'Contact', 'messages' => 'Messages', + 'people' => 'People', ]; diff --git a/lang/en/maintenance.php b/lang/en/maintenance.php index 9774cccf29e..1bd9a1fcf78 100644 --- a/lang/en/maintenance.php +++ b/lang/en/maintenance.php @@ -99,4 +99,11 @@ 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:recompute-album-sizes', 'button' => 'Compute sizes', ], + 'bulk-scan-faces' => [ + 'description' => 'Queue all photos that have not yet been scanned for facial recognition. Requires the AI Vision service to be running.', + ], + 'run-clustering' => [ + 'description' => 'Trigger face clustering in the AI Vision service. Groups detected faces by similarity so you can assign them to people.', + 'success' => 'Clustering started successfully.', + ], ]; diff --git a/lang/en/people.php b/lang/en/people.php new file mode 100644 index 00000000000..faf4763d4bf --- /dev/null +++ b/lang/en/people.php @@ -0,0 +1,65 @@ + 'People', + 'description' => 'Browse photos by the people in them.', + 'no_people' => 'No people found yet. Scan some photos to detect faces.', + 'photos_label' => 'photo(s)', + 'faces_label' => 'face(s)', + 'hidden_faces' => 'face(s) hidden for privacy', + 'unknown' => 'Unknown', + 'confidence' => 'Confidence', + 'assign_face' => 'Assign face', + 'dismiss_face' => 'Dismiss face', + 'undismiss_face' => 'Undismiss face', + 'scan_faces' => 'Scan for faces', + 'scanning' => 'Scanning for faces…', + 'scan_success' => 'Face scan queued successfully.', + 'not_searchable' => 'Hidden', + 'searchable' => 'Visible', + 'claim_by_selfie' => 'Find me in photos', + 'claim_by_selfie_description' => 'Upload a selfie to find and link your person profile.', + 'claims' => [ + 'success' => 'Successfully linked to your profile.', + 'no_face' => 'No face detected in the selfie.', + 'no_match' => 'No matching person found.', + 'already_claimed' => 'This person is already linked to another user.', + 'low_confidence' => 'Match confidence too low. Please try a clearer photo.', + ], + 'person' => [ + 'edit' => 'Edit', + 'delete' => 'Delete', + 'merge' => 'Merge into…', + 'toggle_searchable' => 'Toggle visibility', + 'claim' => 'This is me', + 'unclaim' => 'Unlink from me', + 'photos_title' => 'Photos of %s', + ], + 'clusters_title' => 'Face Clusters', + 'run_clustering' => 'Run Clustering', + 'no_clusters' => 'No clusters found. Run clustering to group detected faces.', + 'faces' => 'faces', + 'enter_name' => 'Person name…', + 'assign' => 'Assign', + 'dismiss' => 'Dismiss', + 'assignment' => [ + 'title' => 'Assign face to person', + 'select_person' => 'Select existing person…', + 'new_person' => 'Or create new person', + 'new_person_placeholder' => 'New person name…', + 'confirm' => 'Assign', + 'cancel' => 'Cancel', + 'success' => 'Face assigned successfully.', + ], +]; diff --git a/resources/js/components/gallery/PersonCard.vue b/resources/js/components/gallery/PersonCard.vue new file mode 100644 index 00000000000..3d1da57dd3e --- /dev/null +++ b/resources/js/components/gallery/PersonCard.vue @@ -0,0 +1,36 @@ + + + diff --git a/resources/js/components/gallery/photoModule/FaceOverlay.vue b/resources/js/components/gallery/photoModule/FaceOverlay.vue new file mode 100644 index 00000000000..3728035b83d --- /dev/null +++ b/resources/js/components/gallery/photoModule/FaceOverlay.vue @@ -0,0 +1,62 @@ + + + diff --git a/resources/js/components/gallery/photoModule/PhotoBox.vue b/resources/js/components/gallery/photoModule/PhotoBox.vue index 72abeddb9ec..104324da039 100644 --- a/resources/js/components/gallery/photoModule/PhotoBox.vue +++ b/resources/js/components/gallery/photoModule/PhotoBox.vue @@ -51,21 +51,25 @@ medium big
+ +
+ +
+ + diff --git a/resources/js/components/maintenance/MaintenanceRunClustering.vue b/resources/js/components/maintenance/MaintenanceRunClustering.vue new file mode 100644 index 00000000000..487d74a849a --- /dev/null +++ b/resources/js/components/maintenance/MaintenanceRunClustering.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/resources/js/components/modals/FaceAssignmentModal.vue b/resources/js/components/modals/FaceAssignmentModal.vue new file mode 100644 index 00000000000..e6860f1711c --- /dev/null +++ b/resources/js/components/modals/FaceAssignmentModal.vue @@ -0,0 +1,159 @@ + + + diff --git a/resources/js/components/modals/SelfieClaimModal.vue b/resources/js/components/modals/SelfieClaimModal.vue new file mode 100644 index 00000000000..9ba744ee5a2 --- /dev/null +++ b/resources/js/components/modals/SelfieClaimModal.vue @@ -0,0 +1,136 @@ + + + diff --git a/resources/js/composables/contextMenus/leftMenu.ts b/resources/js/composables/contextMenus/leftMenu.ts index c3696f0185e..b9f8e64fcff 100644 --- a/resources/js/composables/contextMenus/leftMenu.ts +++ b/resources/js/composables/contextMenus/leftMenu.ts @@ -87,6 +87,12 @@ export function useLeftMenu( access: user.value?.id !== null, route: "/tags", }, + { + label: "people.title", + icon: "pi pi-users", + access: user.value?.id !== null, + route: "/people", + }, { label: "gallery.favourites", icon: "pi pi-heart", diff --git a/resources/js/lychee.d.ts b/resources/js/lychee.d.ts index dec7169be85..a77059933f1 100644 --- a/resources/js/lychee.d.ts +++ b/resources/js/lychee.d.ts @@ -73,6 +73,8 @@ declare namespace App.Enum { export type DbDriverType = "mysql" | "pgsql" | "sqlite"; export type DefaultAlbumProtectionType = "private" | "public" | "inherit" | "public_hidden"; export type DownloadVariantType = "RAW" | "LIVEPHOTOVIDEO" | "ORIGINAL" | "MEDIUM2X" | "MEDIUM" | "SMALL2X" | "SMALL" | "THUMB2X" | "THUMB"; + export type FacePermissionMode = "public" | "private" | "privacy-preserving" | "restricted"; + export type FaceScanStatus = "pending" | "completed" | "failed"; export type FileStatus = "uploading" | "processing" | "ready" | "skipped" | "done" | "error"; export type FlowStrategy = "auto" | "opt-in"; export type ImageOverlayType = "none" | "desc" | "date" | "exif"; @@ -604,6 +606,26 @@ declare namespace App.Http.Resources.Models { is_read: boolean; created_at: string; }; + export type FaceResource = { + id: string; + photo_id: string; + person_id: string | null; + x: number; + y: number; + width: number; + height: number; + confidence: number; + is_dismissed: boolean; + crop_url: string | null; + person_name: string | null; + suggestions: Array; + }; + export type FaceSuggestionResource = { + suggested_face_id: string; + crop_url: string | null; + person_name: string | null; + confidence: number; + }; export type HeadAbstractAlbumResource = { config: App.Http.Resources.GalleryConfigs.AlbumConfig; resource: @@ -675,6 +697,15 @@ declare namespace App.Http.Resources.Models { title: string; url: string | null; }; + export type PersonResource = { + id: string; + name: string; + user_id: number | null; + is_searchable: boolean; + face_count: number; + photo_count: number; + representative_crop_url: string | null; + }; export type PhotoAlbumResource = { id: string; title: string; @@ -711,6 +742,8 @@ declare namespace App.Http.Resources.Models { palette: App.Http.Resources.Models.ColourPaletteResource | null; statistics: App.Http.Resources.Models.PhotoStatisticsResource | null; rating: App.Http.Resources.Models.PhotoRatingResource | null; + faces: Array; + hidden_face_count: number; }; export type PhotoStatisticsResource = { visit_count: number; diff --git a/resources/js/router/routes.ts b/resources/js/router/routes.ts index 7c467e820a2..5081408d8b4 100644 --- a/resources/js/router/routes.ts +++ b/resources/js/router/routes.ts @@ -34,6 +34,9 @@ const BasketList = () => import("@/views/webshop/BasketList.vue"); const CheckoutPage = () => import("@/views/webshop/CheckoutPage.vue"); const OrderList = () => import("@/views/webshop/OrderList.vue"); const OrderDownload = () => import("@/views/webshop/OrderDownload.vue"); +const People = () => import("@/views/People.vue"); +const PersonDetail = () => import("@/views/PersonDetail.vue"); +const FaceClusters = () => import("@/views/FaceClusters.vue"); const routes_ = [ { @@ -226,6 +229,22 @@ const routes_ = [ component: OrderDownload, props: true, }, + { + name: "people", + path: "/people", + component: People, + }, + { + name: "face-clusters", + path: "/people/clusters", + component: FaceClusters, + }, + { + name: "person", + path: "/people/:personId", + component: PersonDetail, + props: true, + }, ]; if (import.meta.env.MODE === "development" && import.meta.env.VITE_LOCAL_DEV === "true") { diff --git a/resources/js/services/face-cluster-service.ts b/resources/js/services/face-cluster-service.ts new file mode 100644 index 00000000000..74cd0c6f1aa --- /dev/null +++ b/resources/js/services/face-cluster-service.ts @@ -0,0 +1,28 @@ +import axios, { type AxiosResponse } from "axios"; +import Constants, { type PaginatedResponse } from "./constants"; + +export type ClusterPreview = { + cluster_label: number; + face_count: number; + sample_crop_urls: string[]; +}; + +const FaceClusterService = { + getClusters(page: number = 1): Promise>> { + return axios.get(`${Constants.getApiUrl()}FaceDetection/clusters`, { params: { page }, data: {} }); + }, + + assignCluster(label: number, data: { person_id?: string; new_person_name?: string }): Promise> { + return axios.post(`${Constants.getApiUrl()}FaceDetection/clusters/${label}/assign`, data); + }, + + dismissCluster(label: number): Promise> { + return axios.post(`${Constants.getApiUrl()}FaceDetection/clusters/${label}/dismiss`, {}); + }, + + runClustering(): Promise { + return axios.post(`${Constants.getApiUrl()}Maintenance::runFaceClustering`, {}); + }, +}; + +export default FaceClusterService; diff --git a/resources/js/services/face-detection-service.ts b/resources/js/services/face-detection-service.ts new file mode 100644 index 00000000000..dbc9ef57bd3 --- /dev/null +++ b/resources/js/services/face-detection-service.ts @@ -0,0 +1,30 @@ +import axios, { type AxiosResponse } from "axios"; +import Constants from "./constants"; + +const FaceDetectionService = { + scanPhotos(photoIds: string[]): Promise { + return axios.post(`${Constants.getApiUrl()}FaceDetection/scan`, { photo_ids: photoIds }); + }, + + scanAlbum(albumId: string): Promise { + return axios.post(`${Constants.getApiUrl()}FaceDetection/scan`, { album_id: albumId }); + }, + + bulkScan(): Promise { + return axios.post(`${Constants.getApiUrl()}FaceDetection/bulk-scan`, {}); + }, + + assignFace(faceId: string, data: { person_id?: string; new_person_name?: string }): Promise { + return axios.post(`${Constants.getApiUrl()}Face/${faceId}/assign`, data); + }, + + toggleDismissed(faceId: string): Promise { + return axios.patch(`${Constants.getApiUrl()}Face/${faceId}`, {}); + }, + + destroyDismissed(): Promise> { + return axios.delete(`${Constants.getApiUrl()}Face/dismissed`); + }, +}; + +export default FaceDetectionService; diff --git a/resources/js/services/people-service.ts b/resources/js/services/people-service.ts new file mode 100644 index 00000000000..ad0f8497e5c --- /dev/null +++ b/resources/js/services/people-service.ts @@ -0,0 +1,50 @@ +import axios, { type AxiosResponse } from "axios"; +import Constants, { type PaginatedResponse } from "./constants"; + +const PeopleService = { + getPeople(page: number = 1): Promise>> { + return axios.get(`${Constants.getApiUrl()}People`, { params: { page }, data: {} }); + }, + + getPerson(id: string): Promise> { + return axios.get(`${Constants.getApiUrl()}Person/${id}`, { data: {} }); + }, + + getPhotos(id: string, page: number = 1): Promise>> { + return axios.get(`${Constants.getApiUrl()}Person/${id}/photos`, { params: { page }, data: {} }); + }, + + create(name: string): Promise> { + return axios.post(`${Constants.getApiUrl()}People`, { name }); + }, + + update(id: string, data: { name?: string; is_searchable?: boolean }): Promise> { + return axios.patch(`${Constants.getApiUrl()}Person/${id}`, data); + }, + + destroy(id: string): Promise { + return axios.delete(`${Constants.getApiUrl()}Person/${id}`); + }, + + claim(id: string): Promise { + return axios.post(`${Constants.getApiUrl()}Person/${id}/claim`); + }, + + unclaim(id: string): Promise { + return axios.delete(`${Constants.getApiUrl()}Person/${id}/claim`); + }, + + merge(targetId: string, sourcePersonId: string): Promise { + return axios.post(`${Constants.getApiUrl()}Person/${targetId}/merge`, { source_person_id: sourcePersonId }); + }, + + claimBySelfie(file: File): Promise> { + const form = new FormData(); + form.append("selfie", file); + return axios.post(`${Constants.getApiUrl()}Person/claim-by-selfie`, form, { + headers: { "Content-Type": "multipart/form-data" }, + }); + }, +}; + +export default PeopleService; diff --git a/resources/js/views/FaceClusters.vue b/resources/js/views/FaceClusters.vue new file mode 100644 index 00000000000..bec31c89da0 --- /dev/null +++ b/resources/js/views/FaceClusters.vue @@ -0,0 +1,210 @@ + + + diff --git a/resources/js/views/Maintenance.vue b/resources/js/views/Maintenance.vue index 9c34708ae53..2136c7eec65 100644 --- a/resources/js/views/Maintenance.vue +++ b/resources/js/views/Maintenance.vue @@ -38,6 +38,8 @@ + + diff --git a/resources/js/views/People.vue b/resources/js/views/People.vue new file mode 100644 index 00000000000..da97737993a --- /dev/null +++ b/resources/js/views/People.vue @@ -0,0 +1,94 @@ + + + diff --git a/resources/js/views/PersonDetail.vue b/resources/js/views/PersonDetail.vue new file mode 100644 index 00000000000..16792af9131 --- /dev/null +++ b/resources/js/views/PersonDetail.vue @@ -0,0 +1,234 @@ + + + diff --git a/resources/js/views/Profile.vue b/resources/js/views/Profile.vue index 1a32ca4d766..a637018e18b 100644 --- a/resources/js/views/Profile.vue +++ b/resources/js/views/Profile.vue @@ -15,18 +15,29 @@ + + +
+
+ diff --git a/routes/api_v2.php b/routes/api_v2.php index ff7bd801a4f..9f2f2813e7f 100644 --- a/routes/api_v2.php +++ b/routes/api_v2.php @@ -375,9 +375,18 @@ Route::post('/FaceDetection/scan', [AiVision\FaceDetectionController::class, 'scan'])->middleware(['support:se']); Route::post('/FaceDetection/results', [AiVision\FaceDetectionController::class, 'results'])->withoutMiddleware(['auth']); Route::post('/FaceDetection/bulk-scan', [AiVision\FaceDetectionController::class, 'bulkScan'])->middleware(['support:se']); +Route::post('/FaceDetection/cluster-results', [AiVision\FaceDetectionController::class, 'clusterResults'])->withoutMiddleware(['auth']); + +/** + * AI VISION — CLUSTER REVIEW. + */ +Route::get('/FaceDetection/clusters', [AiVision\FaceClusterController::class, 'index'])->middleware(['support:se']); +Route::post('/FaceDetection/clusters/{label}/assign', [AiVision\FaceClusterController::class, 'assign'])->middleware(['support:se']); +Route::post('/FaceDetection/clusters/{label}/dismiss', [AiVision\FaceClusterController::class, 'dismiss'])->middleware(['support:se']); /** * AI VISION — MAINTENANCE. */ Route::get('/Maintenance::resetStuckFaces', [Admin\Maintenance\ResetStuckFaces::class, 'check']); Route::post('/Maintenance::resetStuckFaces', [Admin\Maintenance\ResetStuckFaces::class, 'do']); +Route::post('/Maintenance::runFaceClustering', [Admin\Maintenance\RunFaceClustering::class, 'do']); diff --git a/routes/web_v2.php b/routes/web_v2.php index 46308a4efc4..ccd0c037e01 100644 --- a/routes/web_v2.php +++ b/routes/web_v2.php @@ -79,7 +79,7 @@ Route::get('/login', VueController::class)->middleware(['migration:complete']); Route::get('/register', VueController::class)->name('register')->middleware(['migration:complete']); -Route::get('/people', VueController::class)->middleware(['migration:complete']); +Route::get('/people/{cluster?}', VueController::class)->middleware(['migration:complete']); Route::get('/settings', VueController::class)->middleware(['migration:complete', 'login_required:always']); Route::get('/permissions', VueController::class)->middleware(['migration:complete', 'login_required:always']); Route::get('/fixTree', VueController::class)->middleware(['migration:complete', 'login_required:always']); diff --git a/tests/Feature_v2/AiVision/FaceAssignmentTest.php b/tests/Feature_v2/AiVision/FaceAssignmentTest.php new file mode 100644 index 00000000000..af73c458b01 --- /dev/null +++ b/tests/Feature_v2/AiVision/FaceAssignmentTest.php @@ -0,0 +1,121 @@ +requireSe(); + + Configs::set('ai_vision_enabled', '1'); + Configs::set('ai_vision_face_enabled', '1'); + Configs::set('ai_vision_face_permission_mode', 'public'); + Configs::set('ai_vision_face_person_is_searchable_default', '1'); + + $this->person1 = Person::factory()->with_name('Alice')->create(); + $this->face1 = Face::factory()->for_photo($this->photo1)->create(); + } + + public function tearDown(): void + { + DB::table('face_suggestions')->delete(); + DB::table('faces')->delete(); + DB::table('persons')->delete(); + $this->resetSe(); + parent::tearDown(); + } + + // ── ASSIGN TO EXISTING PERSON ─────────────────────────────── + + public function testAssignToExistingPerson(): void + { + $response = $this->actingAs($this->userMayUpload1)->postJson('Face/' . $this->face1->id . '/assign', [ + 'person_id' => $this->person1->id, + ]); + $this->assertStatus($response, [200, 201]); + self::assertEquals($this->person1->id, $response->json('person_id')); + } + + public function testAssignCreatingNewPerson(): void + { + $response = $this->actingAs($this->userMayUpload1)->postJson('Face/' . $this->face1->id . '/assign', [ + 'new_person_name' => 'Charlie', + ]); + $this->assertStatus($response, [200, 201]); + self::assertNotNull($response->json('person_id')); + + // Verify new person was created + $new_person = Person::find($response->json('person_id')); + self::assertNotNull($new_person); + self::assertEquals('Charlie', $new_person->name); + self::assertTrue($new_person->is_searchable); + } + + public function testReassignFaceToDifferentPerson(): void + { + $this->face1->person_id = $this->person1->id; + $this->face1->save(); + + $person2 = Person::factory()->with_name('Bob')->create(); + + $response = $this->actingAs($this->userMayUpload1)->postJson('Face/' . $this->face1->id . '/assign', [ + 'person_id' => $person2->id, + ]); + $this->assertStatus($response, [200, 201]); + self::assertEquals($person2->id, $response->json('person_id')); + } + + public function testAssignValidationRequiresPersonOrName(): void + { + $response = $this->actingAs($this->admin)->postJson('Face/' . $this->face1->id . '/assign', []); + $this->assertUnprocessable($response); + } + + public function testAssignAsGuestUnauthorized(): void + { + $response = $this->postJson('Face/' . $this->face1->id . '/assign', [ + 'person_id' => $this->person1->id, + ]); + $this->assertUnauthorized($response); + } + + public function testAssignRestrictedModeAsUserForbidden(): void + { + Configs::set('ai_vision_face_permission_mode', 'restricted'); + + $response = $this->actingAs($this->userMayUpload1)->postJson('Face/' . $this->face1->id . '/assign', [ + 'person_id' => $this->person1->id, + ]); + $this->assertForbidden($response); + } + + public function testAssignInvalidPersonId(): void + { + $response = $this->actingAs($this->admin)->postJson('Face/' . $this->face1->id . '/assign', [ + 'person_id' => 'nonexistent_person_id', + ]); + $this->assertUnprocessable($response); + } +} diff --git a/tests/Feature_v2/AiVision/FaceClusterReviewTest.php b/tests/Feature_v2/AiVision/FaceClusterReviewTest.php new file mode 100644 index 00000000000..f7f0d71706f --- /dev/null +++ b/tests/Feature_v2/AiVision/FaceClusterReviewTest.php @@ -0,0 +1,155 @@ +requireSe(); + + Configs::set('ai_vision_enabled', '1'); + Configs::set('ai_vision_face_enabled', '1'); + Configs::set('ai_vision_face_permission_mode', 'public'); + Configs::set('ai_vision_face_person_is_searchable_default', '1'); + } + + public function tearDown(): void + { + DB::table('face_suggestions')->delete(); + DB::table('faces')->delete(); + DB::table('persons')->delete(); + $this->resetSe(); + parent::tearDown(); + } + + // ── LIST CLUSTERS ─────────────────────────────────────────── + + public function testListClusters(): void + { + // Create faces with cluster labels + Face::factory()->for_photo($this->photo1)->with_cluster(1)->create(); + Face::factory()->for_photo($this->photo1)->with_cluster(1)->create(); + Face::factory()->for_photo($this->photo1)->with_cluster(2)->create(); + + $response = $this->actingAs($this->userMayUpload1)->getJson('FaceDetection/clusters'); + $this->assertOk($response); + + $data = $response->json('data'); + self::assertGreaterThanOrEqual(2, count($data)); + } + + public function testListClustersExcludesAssigned(): void + { + $person = Person::factory()->create(); + + // Assigned face should not appear + Face::factory()->for_photo($this->photo1)->for_person($person)->with_cluster(1)->create(); + + // Unassigned face should appear + Face::factory()->for_photo($this->photo1)->with_cluster(2)->create(); + + $response = $this->actingAs($this->admin)->getJson('FaceDetection/clusters'); + $this->assertOk($response); + + $cluster_labels = collect($response->json('data'))->pluck('cluster_label')->all(); + self::assertNotContains(1, $cluster_labels); + self::assertContains(2, $cluster_labels); + } + + public function testListClustersExcludesDismissed(): void + { + Face::factory()->for_photo($this->photo1)->dismissed()->with_cluster(3)->create(); + + $response = $this->actingAs($this->admin)->getJson('FaceDetection/clusters'); + $this->assertOk($response); + + $cluster_labels = collect($response->json('data'))->pluck('cluster_label')->all(); + self::assertNotContains(3, $cluster_labels); + } + + public function testListClustersAsGuestUnauthorized(): void + { + $response = $this->getJson('FaceDetection/clusters'); + $this->assertUnauthorized($response); + } + + // ── ASSIGN CLUSTER ────────────────────────────────────────── + + public function testAssignClusterToNewPerson(): void + { + Face::factory()->for_photo($this->photo1)->with_cluster(5)->count(3)->create(); + + $response = $this->actingAs($this->admin)->postJson('FaceDetection/clusters/5/assign', [ + 'new_person_name' => 'Cluster Person', + ]); + $this->assertOk($response); + self::assertEquals(3, $response->json('assigned_count')); + + // Verify person was created + $person = Person::where('name', 'Cluster Person')->first(); + self::assertNotNull($person); + + // All faces should have person_id set + $faces = Face::where('cluster_label', 5)->get(); + foreach ($faces as $face) { + self::assertEquals($person->id, $face->person_id); + } + } + + public function testAssignClusterToExistingPerson(): void + { + $person = Person::factory()->with_name('Existing Person')->create(); + Face::factory()->for_photo($this->photo1)->with_cluster(6)->count(2)->create(); + + $response = $this->actingAs($this->admin)->postJson('FaceDetection/clusters/6/assign', [ + 'person_id' => $person->id, + ]); + $this->assertOk($response); + self::assertEquals(2, $response->json('assigned_count')); + } + + public function testAssignClusterNotFound(): void + { + $response = $this->actingAs($this->admin)->postJson('FaceDetection/clusters/999/assign', [ + 'new_person_name' => 'Test', + ]); + $this->assertOk($response); + self::assertEquals(0, $response->json('assigned_count')); + } + + // ── DISMISS CLUSTER ───────────────────────────────────────── + + public function testDismissCluster(): void + { + Face::factory()->for_photo($this->photo1)->with_cluster(7)->count(3)->create(); + + $response = $this->actingAs($this->admin)->postJson('FaceDetection/clusters/7/dismiss'); + $this->assertOk($response); + self::assertEquals(3, $response->json('dismissed_count')); + + // All faces should be dismissed + $faces = Face::where('cluster_label', 7)->get(); + foreach ($faces as $face) { + self::assertTrue($face->is_dismissed); + } + } +} diff --git a/tests/Feature_v2/AiVision/FaceDetectionServiceUnavailableTest.php b/tests/Feature_v2/AiVision/FaceDetectionServiceUnavailableTest.php new file mode 100644 index 00000000000..16474f4c9d5 --- /dev/null +++ b/tests/Feature_v2/AiVision/FaceDetectionServiceUnavailableTest.php @@ -0,0 +1,65 @@ +requireSe(); + + Configs::set('ai_vision_enabled', '1'); + Configs::set('ai_vision_face_enabled', '1'); + Configs::set('ai_vision_face_permission_mode', 'public'); + + config(['features.ai-vision.face-url' => 'http://fake-vision-service:8000']); + config(['features.ai-vision.face-api-key' => 'test-api-key']); + } + + public function tearDown(): void + { + DB::table('face_suggestions')->delete(); + DB::table('faces')->delete(); + DB::table('persons')->delete(); + $this->resetSe(); + parent::tearDown(); + } + + public function testClusteringWhenServiceUnavailable(): void + { + Http::fake([ + 'fake-vision-service:8000/*' => Http::response(null, 503), + ]); + + $response = $this->actingAs($this->admin)->postJson('Maintenance::runFaceClustering'); + $this->assertStatus($response, [500, 503]); + } + + public function testOtherEndpointsContinueWorking(): void + { + Http::fake([ + 'fake-vision-service:8000/*' => Http::response(null, 503), + ]); + + // Other Lychee endpoints should still work fine + $response = $this->actingAs($this->admin)->getJson('Albums'); + $this->assertOk($response); + } +} diff --git a/tests/Feature_v2/AiVision/FaceDetectionTest.php b/tests/Feature_v2/AiVision/FaceDetectionTest.php new file mode 100644 index 00000000000..28589634ac9 --- /dev/null +++ b/tests/Feature_v2/AiVision/FaceDetectionTest.php @@ -0,0 +1,251 @@ +requireSe(); + + Configs::set('ai_vision_enabled', '1'); + Configs::set('ai_vision_face_enabled', '1'); + Configs::set('ai_vision_face_permission_mode', 'public'); + + $this->api_key = 'test-api-key-12345'; + config(['features.ai-vision.face-api-key' => $this->api_key]); + } + + public function tearDown(): void + { + DB::table('face_suggestions')->delete(); + DB::table('faces')->delete(); + DB::table('persons')->delete(); + $this->resetSe(); + parent::tearDown(); + } + + // ── SCAN TRIGGER ──────────────────────────────────────────── + + public function testScanPhotos(): void + { + Queue::fake(); + + $response = $this->actingAs($this->userMayUpload1)->postJson('FaceDetection/scan', [ + 'photo_ids' => [$this->photo1->id], + ]); + $this->assertStatus($response, 202); + } + + public function testScanAlbum(): void + { + Queue::fake(); + + $response = $this->actingAs($this->userMayUpload1)->postJson('FaceDetection/scan', [ + 'album_id' => $this->album1->id, + ]); + $this->assertStatus($response, 202); + } + + public function testScanValidationRequiresPhotoOrAlbum(): void + { + $response = $this->actingAs($this->admin)->postJson('FaceDetection/scan', []); + $this->assertUnprocessable($response); + } + + public function testScanAsGuestUnauthorized(): void + { + $response = $this->postJson('FaceDetection/scan', [ + 'photo_ids' => [$this->photo1->id], + ]); + $this->assertUnauthorized($response); + } + + // ── RESULTS CALLBACK ──────────────────────────────────────── + + public function testResultsSuccess(): void + { + // Set photo to pending status + Photo::where('id', $this->photo1->id)->update(['face_scan_status' => FaceScanStatus::PENDING->value]); + + $response = $this->postJson('FaceDetection/results', [ + 'photo_id' => $this->photo1->id, + 'status' => 'success', + 'faces' => [ + [ + 'x' => 0.1, + 'y' => 0.2, + 'width' => 0.15, + 'height' => 0.2, + 'confidence' => 0.95, + 'embedding_id' => 'emb_001', + 'crop' => base64_encode('fake-crop-data'), + ], + ], + ], ['X-API-Key' => $this->api_key]); + + $this->assertOk($response); + self::assertCount(1, $response->json('faces')); + self::assertEquals('emb_001', $response->json('faces.0.embedding_id')); + + // Verify face was created + $faces = Face::where('photo_id', $this->photo1->id)->get(); + self::assertCount(1, $faces); + self::assertEqualsWithDelta(0.95, $faces->first()->confidence, 0.001); + + // Verify photo status updated + $this->photo1->refresh(); + self::assertEquals(FaceScanStatus::COMPLETED, $this->photo1->face_scan_status); + } + + public function testResultsError(): void + { + Photo::where('id', $this->photo1->id)->update(['face_scan_status' => FaceScanStatus::PENDING->value]); + + $response = $this->postJson('FaceDetection/results', [ + 'photo_id' => $this->photo1->id, + 'status' => 'error', + 'message' => 'No face found', + ], ['X-API-Key' => $this->api_key]); + + $this->assertOk($response); + + // Photo should be marked as failed + $this->photo1->refresh(); + self::assertEquals(FaceScanStatus::FAILED, $this->photo1->face_scan_status); + } + + public function testResultsInvalidApiKey(): void + { + $response = $this->postJson('FaceDetection/results', [ + 'photo_id' => $this->photo1->id, + 'status' => 'success', + 'faces' => [], + ], ['X-API-Key' => 'wrong-key']); + + $this->assertForbidden($response); + } + + public function testResultsInvalidPhotoId(): void + { + $response = $this->postJson('FaceDetection/results', [ + 'photo_id' => 'nonexistent_photo_id', + 'status' => 'success', + 'faces' => [], + ], ['X-API-Key' => $this->api_key]); + + $this->assertUnprocessable($response); + } + + public function testResultsRescanPreservesPersonId(): void + { + // Create existing face with person + $person = Person::factory()->with_name('Alice')->create(); + $existing_face = Face::factory()->for_photo($this->photo1)->for_person($person)->create([ + 'x' => 0.1, + 'y' => 0.2, + 'width' => 0.15, + 'height' => 0.2, + ]); + + Photo::where('id', $this->photo1->id)->update(['face_scan_status' => FaceScanStatus::PENDING->value]); + + // Re-scan with a face at the same location (high IoU) + $response = $this->postJson('FaceDetection/results', [ + 'photo_id' => $this->photo1->id, + 'status' => 'success', + 'faces' => [ + [ + 'x' => 0.1, + 'y' => 0.2, + 'width' => 0.15, + 'height' => 0.2, + 'confidence' => 0.98, + 'embedding_id' => 'emb_rescan_001', + ], + ], + ], ['X-API-Key' => $this->api_key]); + + $this->assertOk($response); + + // New face should have inherited the person_id + $new_face = Face::where('photo_id', $this->photo1->id)->first(); + self::assertNotNull($new_face); + self::assertEquals($person->id, $new_face->person_id); + } + + // ── BULK SCAN ─────────────────────────────────────────────── + + public function testBulkScanAsAdmin(): void + { + Queue::fake(); + + $response = $this->actingAs($this->admin)->postJson('FaceDetection/bulk-scan'); + $this->assertStatus($response, 202); + } + + public function testBulkScanAsUserForbidden(): void + { + $response = $this->actingAs($this->userMayUpload1)->postJson('FaceDetection/bulk-scan'); + $this->assertForbidden($response); + } + + // ── CLUSTER RESULTS ───────────────────────────────────────── + + public function testClusterResults(): void + { + $face1 = Face::factory()->for_photo($this->photo1)->create(); + $face2 = Face::factory()->for_photo($this->photo1)->create(); + + $response = $this->postJson('FaceDetection/cluster-results', [ + 'labels' => [ + ['face_id' => $face1->id, 'cluster_label' => 1], + ['face_id' => $face2->id, 'cluster_label' => 1], + ], + 'suggestions' => [ + ['face_id' => $face1->id, 'suggested_face_id' => $face2->id, 'confidence' => 0.9], + ], + ], ['X-API-Key' => $this->api_key]); + + $this->assertStatus($response, 202); + + // Verify cluster labels + $face1->refresh(); + $face2->refresh(); + self::assertEquals(1, $face1->cluster_label); + self::assertEquals(1, $face2->cluster_label); + } + + public function testClusterResultsInvalidApiKey(): void + { + $response = $this->postJson('FaceDetection/cluster-results', [ + 'labels' => [], + 'suggestions' => [], + ], ['X-API-Key' => 'wrong-key']); + + $this->assertForbidden($response); + } +} diff --git a/tests/Feature_v2/AiVision/FaceDismissTest.php b/tests/Feature_v2/AiVision/FaceDismissTest.php new file mode 100644 index 00000000000..77bb16c34e3 --- /dev/null +++ b/tests/Feature_v2/AiVision/FaceDismissTest.php @@ -0,0 +1,116 @@ +requireSe(); + + Configs::set('ai_vision_enabled', '1'); + Configs::set('ai_vision_face_enabled', '1'); + Configs::set('ai_vision_face_permission_mode', 'public'); + + $this->face1 = Face::factory()->for_photo($this->photo1)->create(); + $this->face2 = Face::factory()->for_photo($this->photo1)->create(); + } + + public function tearDown(): void + { + DB::table('face_suggestions')->delete(); + DB::table('faces')->delete(); + DB::table('persons')->delete(); + $this->resetSe(); + parent::tearDown(); + } + + // ── TOGGLE DISMISSED ──────────────────────────────────────── + + public function testToggleDismissedByPhotoOwner(): void + { + $response = $this->actingAs($this->userMayUpload1)->patchJson('Face/' . $this->face1->id); + $this->assertOk($response); + self::assertTrue($response->json('is_dismissed')); + + // Toggle back + $response = $this->actingAs($this->userMayUpload1)->patchJson('Face/' . $this->face1->id); + $this->assertOk($response); + self::assertFalse($response->json('is_dismissed')); + } + + public function testToggleDismissedByNonOwnerForbidden(): void + { + $response = $this->actingAs($this->userMayUpload2)->patchJson('Face/' . $this->face1->id); + $this->assertForbidden($response); + } + + public function testToggleDismissedByAdmin(): void + { + $response = $this->actingAs($this->admin)->patchJson('Face/' . $this->face1->id); + $this->assertOk($response); + self::assertTrue($response->json('is_dismissed')); + } + + public function testToggleDismissedAsGuestUnauthorized(): void + { + $response = $this->patchJson('Face/' . $this->face1->id); + $this->assertUnauthorized($response); + } + + // ── DESTROY DISMISSED ─────────────────────────────────────── + + public function testDestroyDismissedAsAdmin(): void + { + // Dismiss some faces first + $this->face1->is_dismissed = true; + $this->face1->save(); + + $response = $this->actingAs($this->admin)->deleteJson('Face/dismissed'); + $this->assertOk($response); + self::assertEquals(1, $response->json('deleted_count')); + + // face1 should be deleted, face2 should still exist + self::assertNull(Face::find($this->face1->id)); + self::assertNotNull(Face::find($this->face2->id)); + } + + public function testDestroyDismissedAsUserForbidden(): void + { + $response = $this->actingAs($this->userMayUpload1)->deleteJson('Face/dismissed'); + $this->assertForbidden($response); + } + + public function testDestroyDismissedAsGuestUnauthorized(): void + { + $response = $this->deleteJson('Face/dismissed'); + $this->assertUnauthorized($response); + } + + public function testDestroyDismissedNoneToDelete(): void + { + $response = $this->actingAs($this->admin)->deleteJson('Face/dismissed'); + $this->assertOk($response); + self::assertEquals(0, $response->json('deleted_count')); + } +} diff --git a/tests/Feature_v2/AiVision/MaintenanceResetStuckFacesTest.php b/tests/Feature_v2/AiVision/MaintenanceResetStuckFacesTest.php new file mode 100644 index 00000000000..bde05340ee2 --- /dev/null +++ b/tests/Feature_v2/AiVision/MaintenanceResetStuckFacesTest.php @@ -0,0 +1,143 @@ +delete(); + DB::table('faces')->delete(); + DB::table('persons')->delete(); + parent::tearDown(); + } + + // ── CHECK (GET) ───────────────────────────────────────────── + + public function testCheckAsGuest(): void + { + $response = $this->getJson('Maintenance::resetStuckFaces'); + $this->assertUnauthorized($response); + } + + public function testCheckAsUser(): void + { + $response = $this->actingAs($this->userMayUpload1)->getJson('Maintenance::resetStuckFaces'); + $this->assertForbidden($response); + } + + public function testCheckAsAdmin(): void + { + // Create a stuck pending photo + Photo::where('id', $this->photo1->id)->update([ + 'face_scan_status' => FaceScanStatus::PENDING, + 'updated_at' => Carbon::now()->subMinutes(800), + ]); + + $response = $this->actingAs($this->admin)->getJson('Maintenance::resetStuckFaces'); + $this->assertOk($response); + + $count = $response->json(); + self::assertGreaterThanOrEqual(1, $count); + } + + public function testCheckReturnsZeroWhenNoneStuck(): void + { + $response = $this->actingAs($this->admin)->getJson('Maintenance::resetStuckFaces'); + $this->assertOk($response); + self::assertEquals(0, $response->json()); + } + + // ── DO (POST) ─────────────────────────────────────────────── + + public function testDoAsGuest(): void + { + $response = $this->postJson('Maintenance::resetStuckFaces'); + $this->assertUnauthorized($response); + } + + public function testDoAsUser(): void + { + $response = $this->actingAs($this->userMayUpload1)->postJson('Maintenance::resetStuckFaces'); + $this->assertForbidden($response); + } + + public function testDoAsAdmin(): void + { + // Create a stuck pending photo (older than default threshold) + Photo::where('id', $this->photo1->id)->update([ + 'face_scan_status' => FaceScanStatus::PENDING, + 'updated_at' => Carbon::now()->subMinutes(800), + ]); + + $response = $this->actingAs($this->admin)->postJson('Maintenance::resetStuckFaces'); + $this->assertOk($response); + self::assertGreaterThanOrEqual(1, $response->json('reset_count')); + + // Photo should be reset to null + $this->photo1->refresh(); + self::assertNull($this->photo1->face_scan_status); + } + + public function testDoWithCustomThreshold(): void + { + // Set pending photo that is 30 minutes old + Photo::where('id', $this->photo1->id)->update([ + 'face_scan_status' => FaceScanStatus::PENDING, + 'updated_at' => Carbon::now()->subMinutes(30), + ]); + + // Default threshold is 720 min (12h), so this should NOT be reset + $response = $this->actingAs($this->admin)->postJson('Maintenance::resetStuckFaces'); + $this->assertOk($response); + self::assertEquals(0, $response->json('reset_count')); + + // With a custom threshold of 15 minutes, it SHOULD be reset + $response = $this->actingAs($this->admin)->postJson('Maintenance::resetStuckFaces', [ + 'older_than_minutes' => 15, + ]); + $this->assertOk($response); + self::assertGreaterThanOrEqual(1, $response->json('reset_count')); + } + + public function testDoDoesNotResetRecentPending(): void + { + // Set pending photo that is recent (5 minutes) + Photo::where('id', $this->photo1->id)->update([ + 'face_scan_status' => FaceScanStatus::PENDING, + 'updated_at' => Carbon::now()->subMinutes(5), + ]); + + $response = $this->actingAs($this->admin)->postJson('Maintenance::resetStuckFaces', [ + 'older_than_minutes' => 60, + ]); + $this->assertOk($response); + self::assertEquals(0, $response->json('reset_count')); + + // Photo should still be pending + $this->photo1->refresh(); + self::assertEquals(FaceScanStatus::PENDING, $this->photo1->face_scan_status); + } +} diff --git a/tests/Feature_v2/AiVision/PeopleControllerTest.php b/tests/Feature_v2/AiVision/PeopleControllerTest.php new file mode 100644 index 00000000000..3e18999ad4b --- /dev/null +++ b/tests/Feature_v2/AiVision/PeopleControllerTest.php @@ -0,0 +1,223 @@ +requireSe(); + + Configs::set('ai_vision_enabled', '1'); + Configs::set('ai_vision_face_enabled', '1'); + Configs::set('ai_vision_face_permission_mode', 'public'); + + $this->person1 = Person::factory()->with_name('Alice')->create(); + $this->person2 = Person::factory()->with_name('Bob')->create(); + $this->personHidden = Person::factory()->with_name('Hidden')->not_searchable()->create(); + } + + public function tearDown(): void + { + DB::table('face_suggestions')->delete(); + DB::table('faces')->delete(); + DB::table('persons')->delete(); + $this->resetSe(); + parent::tearDown(); + } + + // ── INDEX ─────────────────────────────────────────────────── + + public function testIndexAsGuest(): void + { + $response = $this->getJson('People'); + $this->assertOk($response); + + // Guest should see searchable persons but not hidden + $names = collect($response->json('data'))->pluck('name')->all(); + self::assertContains('Alice', $names); + self::assertContains('Bob', $names); + self::assertNotContains('Hidden', $names); + } + + public function testIndexAsAdmin(): void + { + $response = $this->actingAs($this->admin)->getJson('People'); + $this->assertOk($response); + + // Admin sees all including non-searchable + $names = collect($response->json('data'))->pluck('name')->all(); + self::assertContains('Alice', $names); + self::assertContains('Hidden', $names); + } + + public function testIndexLinkedUserSeesOwnHiddenPerson(): void + { + $this->personHidden->user_id = $this->userMayUpload1->id; + $this->personHidden->save(); + + $response = $this->actingAs($this->userMayUpload1)->getJson('People'); + $this->assertOk($response); + + $names = collect($response->json('data'))->pluck('name')->all(); + self::assertContains('Hidden', $names); + } + + public function testIndexRestrictedMode(): void + { + Configs::set('ai_vision_face_permission_mode', 'restricted'); + + // Guest should be forbidden + $response = $this->getJson('People'); + $this->assertForbidden($response); + + // Admin still sees all + $response = $this->actingAs($this->admin)->getJson('People'); + $this->assertOk($response); + } + + // ── SHOW ──────────────────────────────────────────────────── + + public function testShowPerson(): void + { + $response = $this->actingAs($this->admin)->getJson('Person/' . $this->person1->id); + $this->assertOk($response); + self::assertEquals('Alice', $response->json('name')); + self::assertArrayHasKey('face_count', $response->json()); + self::assertArrayHasKey('photo_count', $response->json()); + } + + public function testShowNonSearchablePersonAsGuestForbidden(): void + { + $response = $this->getJson('Person/' . $this->personHidden->id); + $this->assertForbidden($response); + } + + public function testShowNonExistentPerson(): void + { + $response = $this->actingAs($this->admin)->getJson('Person/nonexistent_id_12345'); + $this->assertNotFound($response); + } + + // ── STORE ─────────────────────────────────────────────────── + + public function testStoreAsGuest(): void + { + $response = $this->postJson('Person', ['name' => 'Charlie']); + // In public mode, logged users can create; guests cannot (no auth) + $this->assertUnauthorized($response); + } + + public function testStoreAsUser(): void + { + $response = $this->actingAs($this->userMayUpload1)->postJson('Person', ['name' => 'Charlie']); + $this->assertOk($response); + self::assertEquals('Charlie', $response->json('name')); + self::assertTrue($response->json('is_searchable')); + } + + public function testStoreValidationRequiresName(): void + { + $response = $this->actingAs($this->admin)->postJson('Person', []); + $this->assertUnprocessable($response); + } + + public function testStoreRestrictedModeAsUser(): void + { + Configs::set('ai_vision_face_permission_mode', 'restricted'); + + $response = $this->actingAs($this->userMayUpload1)->postJson('Person', ['name' => 'Charlie']); + $this->assertForbidden($response); + + // Admin can still create + $response = $this->actingAs($this->admin)->postJson('Person', ['name' => 'Charlie']); + $this->assertOk($response); + } + + // ── UPDATE ────────────────────────────────────────────────── + + public function testUpdateName(): void + { + $response = $this->actingAs($this->admin)->patchJson('Person/' . $this->person1->id, [ + 'name' => 'Alice Updated', + ]); + $this->assertOk($response); + self::assertEquals('Alice Updated', $response->json('name')); + } + + public function testUpdateSearchability(): void + { + $response = $this->actingAs($this->admin)->patchJson('Person/' . $this->person1->id, [ + 'is_searchable' => false, + ]); + $this->assertOk($response); + self::assertFalse($response->json('is_searchable')); + } + + public function testUpdateAsGuestUnauthorized(): void + { + $response = $this->patchJson('Person/' . $this->person1->id, ['name' => 'Hacker']); + $this->assertUnauthorized($response); + } + + // ── DESTROY ───────────────────────────────────────────────── + + public function testDestroyNullifiesFaces(): void + { + $face = Face::factory()->for_photo($this->photo1)->for_person($this->person1)->create(); + + $response = $this->actingAs($this->admin)->deleteJson('Person/' . $this->person1->id, [ + 'person_id' => $this->person1->id, + ]); + $this->assertNoContent($response); + + self::assertNull(Person::find($this->person1->id)); + + $face->refresh(); + self::assertNull($face->person_id); + } + + public function testDestroyAsGuestUnauthorized(): void + { + $response = $this->deleteJson('Person/' . $this->person1->id, [ + 'person_id' => $this->person1->id, + ]); + $this->assertUnauthorized($response); + } + + // ── HIDDEN FACE COUNT ─────────────────────────────────────── + + public function testHiddenFaceCountInPhotoResponse(): void + { + Face::factory()->for_photo($this->photo1)->for_person($this->person1)->create(); + Face::factory()->for_photo($this->photo1)->for_person($this->personHidden)->create(); + + // The photo detail should include hidden_face_count for non-admin + // This is tested through the PhotoResource integration + $response = $this->actingAs($this->admin)->getJson('Person/' . $this->person1->id); + $this->assertOk($response); + self::assertGreaterThanOrEqual(1, $response->json('face_count')); + } +} diff --git a/tests/Feature_v2/AiVision/PersonClaimTest.php b/tests/Feature_v2/AiVision/PersonClaimTest.php new file mode 100644 index 00000000000..636d1bb20b8 --- /dev/null +++ b/tests/Feature_v2/AiVision/PersonClaimTest.php @@ -0,0 +1,187 @@ +requireSe(); + + Configs::set('ai_vision_enabled', '1'); + Configs::set('ai_vision_face_enabled', '1'); + Configs::set('ai_vision_face_permission_mode', 'public'); + Configs::set('ai_vision_face_allow_user_claim', '1'); + + $this->person1 = Person::factory()->with_name('Alice')->create(); + $this->person2 = Person::factory()->with_name('Bob')->create(); + } + + public function tearDown(): void + { + DB::table('face_suggestions')->delete(); + DB::table('faces')->delete(); + DB::table('persons')->delete(); + $this->resetSe(); + parent::tearDown(); + } + + // ── CLAIM ─────────────────────────────────────────────────── + + public function testClaimSuccess(): void + { + $response = $this->actingAs($this->userMayUpload1)->postJson('Person/' . $this->person1->id . '/claim'); + $this->assertOk($response); + self::assertEquals($this->userMayUpload1->id, $response->json('user_id')); + } + + public function testClaimAlreadyClaimedByAnotherUserConflict(): void + { + $this->person1->user_id = $this->userMayUpload2->id; + $this->person1->save(); + + $response = $this->actingAs($this->userMayUpload1)->postJson('Person/' . $this->person1->id . '/claim'); + $this->assertConflict($response); + } + + public function testClaimAlreadyHaveDifferentPersonConflict(): void + { + // User already has person1 claimed + $this->person1->user_id = $this->userMayUpload1->id; + $this->person1->save(); + + // Trying to claim person2 should conflict + $response = $this->actingAs($this->userMayUpload1)->postJson('Person/' . $this->person2->id . '/claim'); + $this->assertConflict($response); + } + + public function testAdminForceClaim(): void + { + // Person1 is already claimed by userMayUpload2 + $this->person1->user_id = $this->userMayUpload2->id; + $this->person1->save(); + + // Admin can override existing claim + $response = $this->actingAs($this->admin)->postJson('Person/' . $this->person1->id . '/claim'); + $this->assertOk($response); + self::assertEquals($this->admin->id, $response->json('user_id')); + } + + public function testClaimAsGuestUnauthorized(): void + { + $response = $this->postJson('Person/' . $this->person1->id . '/claim'); + $this->assertUnauthorized($response); + } + + public function testClaimDisabledForNonAdmin(): void + { + Configs::set('ai_vision_face_allow_user_claim', '0'); + + $response = $this->actingAs($this->userMayUpload1)->postJson('Person/' . $this->person1->id . '/claim'); + $this->assertForbidden($response); + + // Admin still can claim + $response = $this->actingAs($this->admin)->postJson('Person/' . $this->person1->id . '/claim'); + $this->assertOk($response); + } + + // ── UNCLAIM ───────────────────────────────────────────────── + + public function testUnclaim(): void + { + $this->person1->user_id = $this->userMayUpload1->id; + $this->person1->save(); + + $response = $this->actingAs($this->userMayUpload1)->deleteJson('Person/' . $this->person1->id . '/claim'); + $this->assertNoContent($response); + + $this->person1->refresh(); + self::assertNull($this->person1->user_id); + } + + public function testUnclaimOtherUserForbidden(): void + { + $this->person1->user_id = $this->userMayUpload1->id; + $this->person1->save(); + + $response = $this->actingAs($this->userMayUpload2)->deleteJson('Person/' . $this->person1->id . '/claim'); + $this->assertForbidden($response); + } + + public function testAdminCanUnclaim(): void + { + $this->person1->user_id = $this->userMayUpload1->id; + $this->person1->save(); + + $response = $this->actingAs($this->admin)->deleteJson('Person/' . $this->person1->id . '/claim'); + $this->assertNoContent($response); + + $this->person1->refresh(); + self::assertNull($this->person1->user_id); + } + + // ── MERGE ─────────────────────────────────────────────────── + + public function testMerge(): void + { + $face = \App\Models\Face::factory()->for_photo($this->photo1)->for_person($this->person2)->create(); + + $response = $this->actingAs($this->admin)->postJson('Person/' . $this->person1->id . '/merge', [ + 'source_person_id' => $this->person2->id, + ]); + $this->assertOk($response); + + // Source person deleted + self::assertNull(Person::find($this->person2->id)); + + // Face reassigned to target + $face->refresh(); + self::assertEquals($this->person1->id, $face->person_id); + } + + public function testMergeAsGuestUnauthorized(): void + { + $response = $this->postJson('Person/' . $this->person1->id . '/merge', [ + 'source_person_id' => $this->person2->id, + ]); + $this->assertUnauthorized($response); + } + + public function testMergeRestrictedAsUserForbidden(): void + { + Configs::set('ai_vision_face_permission_mode', 'restricted'); + + $response = $this->actingAs($this->userMayUpload1)->postJson('Person/' . $this->person1->id . '/merge', [ + 'source_person_id' => $this->person2->id, + ]); + $this->assertForbidden($response); + } + + public function testMergeInvalidSourcePerson(): void + { + $response = $this->actingAs($this->admin)->postJson('Person/' . $this->person1->id . '/merge', [ + 'source_person_id' => 'nonexistent_12345678', + ]); + $this->assertUnprocessable($response); + } +} diff --git a/tests/Feature_v2/AiVision/PersonPhotosTest.php b/tests/Feature_v2/AiVision/PersonPhotosTest.php new file mode 100644 index 00000000000..e52985c5515 --- /dev/null +++ b/tests/Feature_v2/AiVision/PersonPhotosTest.php @@ -0,0 +1,82 @@ +requireSe(); + + Configs::set('ai_vision_enabled', '1'); + Configs::set('ai_vision_face_enabled', '1'); + Configs::set('ai_vision_face_permission_mode', 'public'); + + $this->person1 = Person::factory()->with_name('Alice')->create(); + } + + public function tearDown(): void + { + DB::table('face_suggestions')->delete(); + DB::table('faces')->delete(); + DB::table('persons')->delete(); + $this->resetSe(); + parent::tearDown(); + } + + public function testListPhotosForPerson(): void + { + Face::factory()->for_photo($this->photo1)->for_person($this->person1)->create(); + + $response = $this->actingAs($this->admin)->getJson('Person/' . $this->person1->id . '/photos'); + $this->assertOk($response); + + $photo_ids = collect($response->json('data'))->pluck('id')->all(); + self::assertContains($this->photo1->id, $photo_ids); + } + + public function testEmptyResultForPersonWithNoFaces(): void + { + $response = $this->actingAs($this->admin)->getJson('Person/' . $this->person1->id . '/photos'); + $this->assertOk($response); + self::assertEmpty($response->json('data')); + } + + public function testNonSearchablePersonForbiddenForNonOwner(): void + { + $hidden = Person::factory()->not_searchable()->create(); + + $response = $this->actingAs($this->userMayUpload1)->getJson('Person/' . $hidden->id . '/photos'); + $this->assertForbidden($response); + } + + public function testAdminCanViewPhotosOfNonSearchablePerson(): void + { + $hidden = Person::factory()->not_searchable()->create(); + Face::factory()->for_photo($this->photo1)->for_person($hidden)->create(); + + $response = $this->actingAs($this->admin)->getJson('Person/' . $hidden->id . '/photos'); + $this->assertOk($response); + self::assertNotEmpty($response->json('data')); + } +} diff --git a/tests/Feature_v2/AiVision/ScanFacesCommandTest.php b/tests/Feature_v2/AiVision/ScanFacesCommandTest.php new file mode 100644 index 00000000000..49ce707ae47 --- /dev/null +++ b/tests/Feature_v2/AiVision/ScanFacesCommandTest.php @@ -0,0 +1,89 @@ +delete(); + DB::table('faces')->delete(); + DB::table('persons')->delete(); + parent::tearDown(); + } + + public function testCommandEnqueuesUnscannedPhotos(): void + { + Queue::fake(); + + // Ensure photo1 is unscanned (null status) + Photo::where('id', $this->photo1->id)->update(['face_scan_status' => null]); + + $this->artisan('lychee:scan-faces') + ->expectsOutputToContain('Dispatched') + ->assertExitCode(0); + + // Photo should now be pending + $this->photo1->refresh(); + self::assertEquals(FaceScanStatus::PENDING, $this->photo1->face_scan_status); + } + + public function testCommandSkipsAlreadyScanned(): void + { + Queue::fake(); + + // Mark photo as completed + Photo::where('id', $this->photo1->id)->update(['face_scan_status' => FaceScanStatus::COMPLETED->value]); + + $this->artisan('lychee:scan-faces') + ->expectsOutputToContain('Dispatched') + ->assertExitCode(0); + + // Should still be completed (not re-queued) + $this->photo1->refresh(); + self::assertEquals(FaceScanStatus::COMPLETED, $this->photo1->face_scan_status); + } + + public function testCommandWithAlbumFilter(): void + { + Queue::fake(); + + // Ensure photos are unscanned + Photo::where('id', $this->photo1->id)->update(['face_scan_status' => null]); + Photo::where('id', $this->photo2->id)->update(['face_scan_status' => null]); + + $this->artisan('lychee:scan-faces', ['--album' => $this->album1->id]) + ->expectsOutputToContain('Dispatched') + ->assertExitCode(0); + + // photo1 is in album1 — should be pending + $this->photo1->refresh(); + self::assertEquals(FaceScanStatus::PENDING, $this->photo1->face_scan_status); + + // photo2 is in album2 — should still be null + $this->photo2->refresh(); + self::assertNull($this->photo2->face_scan_status); + } +} diff --git a/tests/Feature_v2/AiVision/SelfieClaimTest.php b/tests/Feature_v2/AiVision/SelfieClaimTest.php new file mode 100644 index 00000000000..957d72ab2a8 --- /dev/null +++ b/tests/Feature_v2/AiVision/SelfieClaimTest.php @@ -0,0 +1,142 @@ +requireSe(); + + Configs::set('ai_vision_enabled', '1'); + Configs::set('ai_vision_face_enabled', '1'); + Configs::set('ai_vision_face_permission_mode', 'public'); + Configs::set('ai_vision_face_allow_user_claim', '1'); + Configs::set('ai_vision_face_selfie_confidence_threshold', '0.8'); + + config(['features.ai-vision.face-url' => 'http://fake-vision-service:8000']); + config(['features.ai-vision.face-api-key' => 'test-api-key']); + + $this->person1 = Person::factory()->with_name('Alice')->create(); + } + + public function tearDown(): void + { + DB::table('face_suggestions')->delete(); + DB::table('faces')->delete(); + DB::table('persons')->delete(); + $this->resetSe(); + parent::tearDown(); + } + + public function testSelfieClaimSuccess(): void + { + Http::fake([ + 'fake-vision-service:8000/match' => Http::response([ + 'matches' => [ + [ + 'lychee_face_id' => 'some_face_id', + 'person_id' => $this->person1->id, + 'confidence' => 0.95, + ], + ], + ], 200), + ]); + + $selfie = UploadedFile::fake()->image('selfie.jpg', 200, 200); + + $response = $this->actingAs($this->userMayUpload1)->post( + self::API_PREFIX . 'Person/claim-by-selfie', + ['selfie' => $selfie], + ['CONTENT_TYPE' => 'multipart/form-data', 'Accept' => 'application/json'] + ); + + $this->assertOk($response); + self::assertEquals($this->person1->id, $response->json('id')); + + // Verify person is now linked to user + $this->person1->refresh(); + self::assertEquals($this->userMayUpload1->id, $this->person1->user_id); + } + + public function testSelfieClaimNoFaceDetected(): void + { + Http::fake([ + 'fake-vision-service:8000/match' => Http::response([ + 'matches' => [], + ], 200), + ]); + + $selfie = UploadedFile::fake()->image('selfie.jpg', 200, 200); + + $response = $this->actingAs($this->userMayUpload1)->post( + self::API_PREFIX . 'Person/claim-by-selfie', + ['selfie' => $selfie], + ['CONTENT_TYPE' => 'multipart/form-data', 'Accept' => 'application/json'] + ); + + $this->assertNotFound($response); + } + + public function testSelfieClaimAlreadyClaimed(): void + { + $this->person1->user_id = $this->userMayUpload2->id; + $this->person1->save(); + + Http::fake([ + 'fake-vision-service:8000/match' => Http::response([ + 'matches' => [ + [ + 'lychee_face_id' => 'some_face_id', + 'person_id' => $this->person1->id, + 'confidence' => 0.95, + ], + ], + ], 200), + ]); + + $selfie = UploadedFile::fake()->image('selfie.jpg', 200, 200); + + $response = $this->actingAs($this->userMayUpload1)->post( + self::API_PREFIX . 'Person/claim-by-selfie', + ['selfie' => $selfie], + ['CONTENT_TYPE' => 'multipart/form-data', 'Accept' => 'application/json'] + ); + + $this->assertConflict($response); + } + + public function testSelfieClaimAsGuestUnauthorized(): void + { + $selfie = UploadedFile::fake()->image('selfie.jpg', 200, 200); + + $response = $this->post( + self::API_PREFIX . 'Person/claim-by-selfie', + ['selfie' => $selfie], + ['CONTENT_TYPE' => 'multipart/form-data', 'Accept' => 'application/json'] + ); + + $this->assertUnauthorized($response); + } +} diff --git a/tests/LoadedSubscriber.php b/tests/LoadedSubscriber.php index ea23cd7c4db..3abea29b8ac 100644 --- a/tests/LoadedSubscriber.php +++ b/tests/LoadedSubscriber.php @@ -30,8 +30,15 @@ public function notify(Loaded $event): void app()->singleton(ConfigManager::class, fn () => new ConfigManager()); // If there are any users in the DB, this tends to crash some tests (because we check exact count of users). + // Disable FK checks and clean all related tables to avoid constraint violations. if (User::query()->count() > 0) { + $db = \Illuminate\Support\Facades\DB::getFacadeRoot(); + $db::statement('PRAGMA foreign_keys = OFF'); + $db::table('face_suggestions')->delete(); + $db::table('faces')->delete(); + $db::table('persons')->delete(); User::truncate(); + $db::statement('PRAGMA foreign_keys = ON'); } HandleExceptions::flushState(); diff --git a/tests/Unit/Models/FaceTest.php b/tests/Unit/Models/FaceTest.php new file mode 100644 index 00000000000..a85a113e5ec --- /dev/null +++ b/tests/Unit/Models/FaceTest.php @@ -0,0 +1,129 @@ +may_upload()->create(); + $photo = Photo::factory()->owned_by($user)->create(); + $face = Face::factory()->for_photo($photo)->create(); + + self::assertNotNull($face->photo); + self::assertEquals($photo->id, $face->photo->id); + } + + public function testFacePersonRelationship(): void + { + $user = User::factory()->may_upload()->create(); + $photo = Photo::factory()->owned_by($user)->create(); + $person = Person::factory()->create(); + $face = Face::factory()->for_photo($photo)->for_person($person)->create(); + + self::assertNotNull($face->person); + self::assertEquals($person->id, $face->person->id); + } + + public function testFaceNullablePersonRelationship(): void + { + $user = User::factory()->may_upload()->create(); + $photo = Photo::factory()->owned_by($user)->create(); + $face = Face::factory()->for_photo($photo)->create(); + + self::assertNull($face->person); + self::assertNull($face->person_id); + } + + public function testBoundingBoxValidation(): void + { + $user = User::factory()->may_upload()->create(); + $photo = Photo::factory()->owned_by($user)->create(); + $face = Face::factory()->for_photo($photo)->create(); + + self::assertIsFloat($face->x); + self::assertIsFloat($face->y); + self::assertIsFloat($face->width); + self::assertIsFloat($face->height); + self::assertGreaterThanOrEqual(0.0, $face->x); + self::assertLessThanOrEqual(1.0, $face->x); + self::assertGreaterThanOrEqual(0.0, $face->y); + self::assertLessThanOrEqual(1.0, $face->y); + } + + public function testCropUrlAccessorWithToken(): void + { + $user = User::factory()->may_upload()->create(); + $photo = Photo::factory()->owned_by($user)->create(); + $face = Face::factory()->for_photo($photo)->create(); + + $tok = $face->crop_token; + self::assertNotNull($face->crop_url); + self::assertEquals( + 'uploads/faces/' . substr($tok, 0, 2) . '/' . substr($tok, 2, 2) . '/' . $tok . '.jpg', + $face->crop_url + ); + } + + public function testCropUrlAccessorWithoutToken(): void + { + $user = User::factory()->may_upload()->create(); + $photo = Photo::factory()->owned_by($user)->create(); + $face = Face::factory()->for_photo($photo)->without_crop()->create(); + + self::assertNull($face->crop_url); + } + + public function testDismissedCast(): void + { + $user = User::factory()->may_upload()->create(); + $photo = Photo::factory()->owned_by($user)->create(); + $face = Face::factory()->for_photo($photo)->create(); + + self::assertIsBool($face->is_dismissed); + self::assertFalse($face->is_dismissed); + + $dismissed = Face::factory()->for_photo($photo)->dismissed()->create(); + self::assertTrue($dismissed->is_dismissed); + } + + public function testClusterLabelCast(): void + { + $user = User::factory()->may_upload()->create(); + $photo = Photo::factory()->owned_by($user)->create(); + $face = Face::factory()->for_photo($photo)->with_cluster(5)->create(); + + self::assertIsInt($face->cluster_label); + self::assertEquals(5, $face->cluster_label); + } + + public function testConfidenceCast(): void + { + $user = User::factory()->may_upload()->create(); + $photo = Photo::factory()->owned_by($user)->create(); + $face = Face::factory()->for_photo($photo)->with_confidence(0.95)->create(); + + self::assertIsFloat($face->confidence); + self::assertEqualsWithDelta(0.95, $face->confidence, 0.001); + } +} diff --git a/tests/Unit/Models/PersonTest.php b/tests/Unit/Models/PersonTest.php new file mode 100644 index 00000000000..0b1e8669740 --- /dev/null +++ b/tests/Unit/Models/PersonTest.php @@ -0,0 +1,117 @@ +may_upload()->create(); + $person = Person::factory()->linked_to($user)->create(); + + self::assertNotNull($person->user); + self::assertEquals($user->id, $person->user->id); + } + + public function testPersonFacesRelationship(): void + { + $user = User::factory()->may_upload()->create(); + $photo = Photo::factory()->owned_by($user)->create(); + $person = Person::factory()->create(); + + $face1 = Face::factory()->for_photo($photo)->for_person($person)->create(); + $face2 = Face::factory()->for_photo($photo)->for_person($person)->create(); + + $person->refresh(); + self::assertCount(2, $person->faces); + self::assertTrue($person->faces->contains($face1)); + self::assertTrue($person->faces->contains($face2)); + } + + public function testPersonRepresentativeFaceRelationship(): void + { + $user = User::factory()->may_upload()->create(); + $photo = Photo::factory()->owned_by($user)->create(); + $person = Person::factory()->create(); + $face = Face::factory()->for_photo($photo)->for_person($person)->create(); + + $person->representative_face_id = $face->id; + $person->save(); + $person->refresh(); + + self::assertNotNull($person->representativeFace); + self::assertEquals($face->id, $person->representativeFace->id); + } + + public function testPersonDeleteNullifiesFaces(): void + { + $user = User::factory()->may_upload()->create(); + $photo = Photo::factory()->owned_by($user)->create(); + $person = Person::factory()->create(); + $face = Face::factory()->for_photo($photo)->for_person($person)->create(); + + self::assertEquals($person->id, $face->person_id); + + $person->delete(); + + $face->refresh(); + self::assertNull($face->person_id); + } + + public function testPhotoDeleteCascadesFaces(): void + { + $user = User::factory()->may_upload()->create(); + $photo = Photo::factory()->owned_by($user)->create(); + $person = Person::factory()->create(); + $face = Face::factory()->for_photo($photo)->for_person($person)->create(); + $face_id = $face->id; + + // Use DB-level delete to trigger cascade FK without model observers + \Illuminate\Support\Facades\DB::table('photos')->where('id', '=', $photo->id)->delete(); + + self::assertNull(Face::find($face_id)); + } + + public function testScopeSearchable(): void + { + Person::factory()->with_name('Visible')->create(); + Person::factory()->with_name('Hidden')->not_searchable()->create(); + + $searchable = Person::searchable()->get(); + self::assertCount(1, $searchable); + self::assertEquals('Visible', $searchable->first()->name); + } + + public function testNullableUserRelationship(): void + { + $person = Person::factory()->create(); + self::assertNull($person->user); + self::assertNull($person->user_id); + } + + public function testCastsAreCorrect(): void + { + $person = Person::factory()->create(); + self::assertIsBool($person->is_searchable); + } +}