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/dependabot.yml b/.github/dependabot.yml index 8d5825f1f78..481a3a18415 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -18,9 +18,6 @@ updates: dependency-type: "production" development-dependencies: dependency-type: "development" - ignore: - - dependency-name: "typescript" - versions: [ ">=6.0.0" ] - package-ecosystem: composer directory: / diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 5cae425a87e..4d84cedc09d 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -11,11 +11,13 @@ on: - '**/*.md' - 'public/dist/*.js' - 'public/dist/**/*.js' + - 'ai-vision-service/**' pull_request: paths-ignore: - '**/*.md' - 'public/dist/*.js' - 'public/dist/**/*.js' + - 'ai-vision-service/**' # Allow manually triggering the workflow. workflow_dispatch: diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 4b8fb809f2e..6eb5adb1716 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -25,3 +25,8 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: 'Dependency Review' uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 + with: + # No fix available yet + # Note that the model is directly baked into the inage + # So the risk is limited. + allow-ghsas: GHSA-hqmj-h5c6-369m diff --git a/.github/workflows/python_ai_vision_face_recognition.yml b/.github/workflows/python_ai_vision_face_recognition.yml new file mode 100644 index 00000000000..4d17a5affd3 --- /dev/null +++ b/.github/workflows/python_ai_vision_face_recognition.yml @@ -0,0 +1,153 @@ +name: Python AI Vision Service for face recognition + +on: + push: + branches: + - master + - assisted-vision + paths: + - 'ai-vision-service/face-recognition/**' + pull_request: + paths: + - 'ai-vision-service/face-recognition/**' + workflow_dispatch: + +# Declare default permissions as read only. +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ !contains(github.ref, 'master') && !startsWith(github.ref, 'refs/tags/') }} + +defaults: + run: + working-directory: ai-vision-service/face-recognition + +jobs: + # --------------------------------------------------------------------------- + # Lint – formatting and style + # --------------------------------------------------------------------------- + lint: + name: Lint (ruff) + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + with: + enable-cache: true + + - name: Install dev dependencies + run: uv sync --frozen + + - name: Check formatting + run: uv run ruff format --check app/ tests/ + + - name: Lint + run: uv run ruff check app/ tests/ + + # --------------------------------------------------------------------------- + # Type check + # --------------------------------------------------------------------------- + typecheck: + name: Type check (ty) + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + with: + enable-cache: true + + - name: Install dev dependencies + run: uv sync --frozen + + - name: Type check + run: uv run ty check app/ + + # --------------------------------------------------------------------------- + # Test matrix – Python 3.13 and 3.14 + # --------------------------------------------------------------------------- + test: + name: Tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: + - "3.13" + + steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + with: + enable-cache: true + python-version: ${{ matrix.python-version }} + + - name: Install dev dependencies + run: uv sync --frozen + + - name: Run tests + run: uv run pytest --cov=app --cov-report=xml -v + + - name: Upload coverage + if: matrix.python-version == '3.13' + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + with: + files: ai-vision-service/face-recognition/coverage.xml + flags: ai-vision-service/face-recognition + continue-on-error: true + + # --------------------------------------------------------------------------- + # Docker build verification + # --------------------------------------------------------------------------- + docker-build: + name: Docker build + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0 + + - name: Build Docker image (no model bake in CI to save time) + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + with: + context: ai-vision-service/face-recognition + push: false + load: true + tags: lychee-ai-vision:ci + # Override the model-bake step by targeting the builder stage + # to avoid downloading 300MB of model weights in CI. + target: builder + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 0089dc9ee36..b0ec9675541 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,8 @@ clover.xml .NO_AUTO_COMPOSER_MIGRATE storage/bootstrap/cache/* storage/image-jobs/* +**/__pycache__/** +.coverage # used by Vite public/hot diff --git a/Makefile b/Makefile index 29671d52a63..113d68cf32c 100644 --- a/Makefile +++ b/Makefile @@ -166,6 +166,9 @@ class-leak: docker-build: docker build -t lychee-frankenphp . +docker-build-legacy: + docker build -t lychee-frankenphp -f Dockerfile-legacy . + docker-build-no-cache: docker build -t lychee-frankenphp . --no-cache diff --git a/ai-vision-service/face-recognition/.insightface/.gitignore b/ai-vision-service/face-recognition/.insightface/.gitignore new file mode 100644 index 00000000000..c96a04f008e --- /dev/null +++ b/ai-vision-service/face-recognition/.insightface/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/ai-vision-service/face-recognition/Dockerfile b/ai-vision-service/face-recognition/Dockerfile new file mode 100644 index 00000000000..aa2725223c7 --- /dev/null +++ b/ai-vision-service/face-recognition/Dockerfile @@ -0,0 +1,59 @@ +# Multi-stage build: keep the runtime image lean. + +# --------------------------------------------------------------------------- +# Stage 1 – builder: install dependencies and bake the model weights. +# --------------------------------------------------------------------------- +FROM python:3.13-slim@sha256:739e7213785e88c0f702dcdc12c0973afcbd606dbf021a589cab77d6b00b579d AS builder + +# Install uv from the official image. +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Runtime libraries required by opencv-python. +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgl1 \ + libglib2.0-0 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install dependencies only (no source code) so layer cache is reused when +# only application code changes. +COPY pyproject.toml uv.lock README.md ./ +RUN uv sync --frozen --no-dev + +# Bake ArcFace + RetinaFace model weights into the image at build time. +# The resulting image starts instantly and works in airgapped environments. +# Model updates require an image rebuild. +RUN DEEPFACE_HOME=/root/.deepface uv run python -c \ + "from deepface import DeepFace; \ + import numpy as np; \ + DeepFace.represent( \ + img_path=np.zeros((1, 1, 3), dtype='uint8'), \ + model_name='ArcFace', \ + detector_backend='retinaface', \ + enforce_detection=False, \ + ); \ + print('ArcFace + RetinaFace models downloaded.')" + +# --------------------------------------------------------------------------- +# Stage 2 – runtime: copy only what's needed to run. +# --------------------------------------------------------------------------- +FROM python:3.13-slim@sha256:739e7213785e88c0f702dcdc12c0973afcbd606dbf021a589cab77d6b00b579d AS runtime + +WORKDIR /app + +# Copy the pre-built virtualenv and baked model weights from the builder stage. +COPY --from=builder /app/.venv /app/.venv +COPY --from=builder /root/.deepface /root/.deepface + +# Copy application source. +COPY app/ ./app/ + +ENV PATH="/app/.venv/bin:$PATH" +ENV DEEPFACE_HOME=/root/.deepface + +EXPOSE 8000 + +# Use a shell-form CMD so that the ${VISION_FACE_WORKERS:-1} variable is +# expanded at container startup, not at image build time. +CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers ${VISION_FACE_WORKERS:-1}"] diff --git a/ai-vision-service/face-recognition/README.md b/ai-vision-service/face-recognition/README.md new file mode 100644 index 00000000000..b3e13a76b94 --- /dev/null +++ b/ai-vision-service/face-recognition/README.md @@ -0,0 +1,147 @@ +# Lychee AI Vision Service + +Facial recognition microservice for [Lychee](https://github.com/LycheeOrg/Lychee). + +Detects faces in photos, stores embeddings, and supports selfie-based person +claiming via a REST API consumed by the Lychee PHP backend. + +## Tech stack + +| Concern | Library | +|---------|---------| +| Web framework | FastAPI + Uvicorn | +| Face detection & recognition | DeepFace (`ArcFace` + `retinaface` backend) | +| Face crop generation | Pillow | +| Embedding clustering | scikit-learn (DBSCAN) | +| Embedding storage | SQLite + sqlite-vec (default) / PostgreSQL + pgvector | +| HTTP client (callbacks) | httpx | +| Config | Pydantic BaseSettings | + +## Directory layout + +``` +ai-vision-service/ +├── app/ +│ ├── __init__.py +│ ├── config.py # AppSettings (Pydantic BaseSettings) +│ ├── main.py # FastAPI app factory & lifespan handler +│ ├── api/ +│ │ ├── __init__.py +│ │ ├── dependencies.py # API key auth dependency +│ │ ├── routes.py # /detect, /match, /health +│ │ └── schemas.py # Pydantic request/response models +│ ├── detection/ +│ │ ├── __init__.py +│ │ ├── detector.py # DeepFace wrapper +│ │ └── cropper.py # 150×150 JPEG crop generator +│ ├── embeddings/ +│ │ ├── __init__.py +│ │ ├── store.py # Abstract EmbeddingStore protocol +│ │ ├── sqlite_store.py # SQLite + sqlite-vec implementation +│ │ └── pgvector_store.py # PostgreSQL + pgvector implementation +│ ├── clustering/ +│ │ ├── __init__.py +│ │ └── clusterer.py # DBSCAN clustering +│ └── matching/ +│ ├── __init__.py +│ └── matcher.py # Selfie similarity matching +├── tests/ +│ └── __init__.py +├── Dockerfile +├── pyproject.toml +└── README.md +``` + +## Environment variables + +All variables are prefixed `VISION_FACE_`. + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `VISION_FACE_LYCHEE_API_URL` | Yes | — | Lychee base URL for callbacks | +| `VISION_FACE_API_KEY` | Yes | — | Shared API key: validated on inbound requests from Lychee, and sent on outbound callbacks to Lychee | +| `VISION_FACE_VERIFY_SSL` | No | `true` | Verify SSL certificates when making callbacks to Lychee. Set to `false` for dev environments with self-signed certificates | +| `VISION_FACE_MODEL_NAME` | No | `ArcFace` | DeepFace recognition model | +| `VISION_FACE_DETECTOR_BACKEND` | No | `retinaface` | DeepFace detector backend (`retinaface`, `mtcnn`, `opencv`, `ssd`) | +| `VISION_FACE_DETECTION_THRESHOLD` | No | `0.5` | Confidence filter for detected faces | +| `VISION_FACE_MATCH_THRESHOLD` | No | `0.5` | Cosine-similarity cutoff for selfie matching | +| `VISION_FACE_RESCAN_IOU_THRESHOLD` | No | `0.5` | IoU threshold for bounding-box matching on re-scan | +| `VISION_FACE_MAX_FACES_PER_PHOTO` | No | `10` | Maximum faces included in a callback payload | +| `VISION_FACE_THREAD_POOL_SIZE` | No | `1` | Inference thread-pool size | +| `VISION_FACE_STORAGE_BACKEND` | No | `sqlite` | `sqlite` or `pgvector` | +| `VISION_FACE_STORAGE_PATH` | No | `/data/embeddings` | SQLite DB directory | +| `VISION_FACE_PG_HOST` | No* | `localhost` | PostgreSQL host (*required with pgvector) | +| `VISION_FACE_PG_PORT` | No | `5432` | PostgreSQL port | +| `VISION_FACE_PG_DATABASE` | No* | `ai_vision` | PostgreSQL database (*required with pgvector) | +| `VISION_FACE_PG_USER` | No* | `ai_vision` | PostgreSQL user (*required with pgvector) | +| `VISION_FACE_PG_PASSWORD` | No* | `` | PostgreSQL password (*required with pgvector) | +| `VISION_FACE_PHOTOS_PATH` | No | `/data/photos` | Shared volume mount for photo files | +| `VISION_FACE_WORKERS` | No | `1` | Number of Uvicorn worker processes | +| `VISION_FACE_LOG_LEVEL` | No | `info` | Log level | + +## Development + +### Setup + +```bash +# Install uv (https://docs.astral.sh/uv/getting-started/installation/) +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Install all dependencies (including dev) +uv sync + +# Configure .env file (create or edit .env in this directory) +# Minimum required variables: +# VISION_FACE_LYCHEE_API_URL=https://lychee.test +# VISION_FACE_API_KEY=changeme +# VISION_FACE_VERIFY_SSL=false +# VISION_FACE_PHOTOS_PATH=../../public/uploads +``` + +### Running locally + +```bash +# Using uv run (recommended) +uv run python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +``` + +The service will be available at http://localhost:8000 +- API docs: http://localhost:8000/docs +- Health check: http://localhost:8000/health + +### Linting and testing + +```bash +# Lint and format +uv run ruff format +uv run ruff check --fix + +# Type check +uv run ty check + +# Run tests +uv run pytest + +# Run tests with coverage +uv run pytest --cov=app --cov-report=html +``` + +## Docker + +```bash +# Build (bakes ArcFace + RetinaFace models into the image – ~500 MB download on first build) +docker build -t lychee-ai-vision . + +# Run +docker run --rm \ + -e VISION_FACE_LYCHEE_API_URL=http://lychee \ + -e VISION_FACE_API_KEY=changeme \ + -v /path/to/photos:/data/photos:ro \ + -v ai-vision-embeddings:/data/embeddings \ + -p 8000:8000 \ + lychee-ai-vision +``` + +--- + +*Last updated: 2026-04-24* diff --git a/ai-vision-service/face-recognition/app/__init__.py b/ai-vision-service/face-recognition/app/__init__.py new file mode 100644 index 00000000000..6f6a628fe93 --- /dev/null +++ b/ai-vision-service/face-recognition/app/__init__.py @@ -0,0 +1 @@ +"""Lychee AI Vision Service.""" diff --git a/ai-vision-service/face-recognition/app/api/__init__.py b/ai-vision-service/face-recognition/app/api/__init__.py new file mode 100644 index 00000000000..0a5af4779e5 --- /dev/null +++ b/ai-vision-service/face-recognition/app/api/__init__.py @@ -0,0 +1 @@ +"""FastAPI routes, dependencies, and schemas.""" diff --git a/ai-vision-service/face-recognition/app/api/dependencies.py b/ai-vision-service/face-recognition/app/api/dependencies.py new file mode 100644 index 00000000000..a89ee2f8138 --- /dev/null +++ b/ai-vision-service/face-recognition/app/api/dependencies.py @@ -0,0 +1,42 @@ +"""FastAPI dependency providers. + +Centralises per-request dependency resolution. All functions follow +FastAPI's ``Depends()`` contract so they can be overridden in tests via +``app.dependency_overrides``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from fastapi import Depends, Header, HTTPException, Request + +from app.config import AppSettings, get_settings + +if TYPE_CHECKING: + from app.detection.detector import FaceDetector + from app.embeddings.store import EmbeddingStore + + +async def require_api_key( + x_api_key: str = Header(..., alias="X-API-Key"), + settings: AppSettings = Depends(get_settings), +) -> None: + """FastAPI dependency that validates the ``X-API-Key`` request header. + + Raises: + HTTPException(401): If the header is missing or does not match + ``VISION_FACE_API_KEY``. + """ + if x_api_key != settings.api_key: + raise HTTPException(status_code=401, detail="Invalid or missing API key") + + +def get_detector(request: Request) -> FaceDetector: + """Return the :class:`FaceDetector` stored in ``app.state``.""" + return request.app.state.detector + + +def get_store(request: Request) -> EmbeddingStore: + """Return the :class:`EmbeddingStore` stored in ``app.state``.""" + return request.app.state.store diff --git a/ai-vision-service/face-recognition/app/api/routes.py b/ai-vision-service/face-recognition/app/api/routes.py new file mode 100644 index 00000000000..25b1779d23e --- /dev/null +++ b/ai-vision-service/face-recognition/app/api/routes.py @@ -0,0 +1,589 @@ +"""FastAPI route handlers. + +Endpoints: + POST /detect - Accept a face-detection job; run async, callback to Lychee. + POST /match - Accept a selfie; return top-N similar stored faces. + GET /health - Return service health and embedding count. +""" + +from __future__ import annotations + +import asyncio +import logging +import uuid +from pathlib import Path +from typing import TYPE_CHECKING + +import httpx +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, UploadFile +from fastapi.responses import RedirectResponse + +from app.api.dependencies import get_detector, get_store, require_api_key +from app.api.schemas import ( + ClusterCallbackPayload, + ClusterFaceResult, + ClusterSuggestion, + DeleteEmbeddingsRequest, + DeleteEmbeddingsResponse, + DetectCallbackPayload, + DetectCallbackResponse, + DetectRequest, + EmbeddingExportItem, + EmbeddingExportResponse, + ErrorCallbackPayload, + FaceResult, + HealthResponse, + MatchResponse, + MatchResult, + SuggestionResult, +) +from app.clustering.clusterer import FaceClusterer +from app.config import AppSettings, get_settings +from app.detection.cropper import generate_crop + +if TYPE_CHECKING: + from concurrent.futures import Executor + + from app.detection.detector import DetectedFace, FaceDetector + from app.embeddings.store import EmbeddingStore + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# --------------------------------------------------------------------------- +# GET / - Redirect to /health +# --------------------------------------------------------------------------- + + +@router.get("/") +async def root() -> RedirectResponse: + """Redirect root to /health endpoint.""" + return RedirectResponse(url="/health") + + +# --------------------------------------------------------------------------- +# POST /detect +# --------------------------------------------------------------------------- + + +@router.post("/detect", status_code=202) +async def detect( + body: DetectRequest, + background_tasks: BackgroundTasks, + request: Request, + settings: AppSettings = Depends(get_settings), + _: None = Depends(require_api_key), +) -> None: + """Accept a face-detection job. + + Validates the photo path (path-traversal protection), then immediately + returns **202 Accepted** and schedules detection as a background task. + Results are POSTed back to Lychee's results endpoint once detection + completes. + """ + resolved = Path(settings.photos_path.removesuffix("/") + "/" + body.photo_path.removeprefix("/")).resolve() + photos_root = Path(settings.photos_path).resolve() + + if not str(resolved).startswith(str(photos_root) + "/") and resolved != photos_root: + logger.warning( + "[/detect] 400 path-traversal: photo_id=%s resolved=%s photos_root=%s", + body.photo_id, + resolved, + photos_root, + ) + raise HTTPException(status_code=400, detail=f"photo_path {resolved} is outside the allowed directory") + + if not resolved.is_file(): + logger.warning( + "[/detect] 400 file-not-found: photo_id=%s resolved=%s", + body.photo_id, + resolved, + ) + raise HTTPException(status_code=400, detail="photo_path does not exist or is not a file") + + detector: FaceDetector = get_detector(request) + store: EmbeddingStore = get_store(request) + executor: Executor = request.app.state.executor + + background_tasks.add_task( + _run_detection_job, + body.photo_id, + resolved, + detector, + store, + executor, + settings, + ) + + +# --------------------------------------------------------------------------- +# POST /match +# --------------------------------------------------------------------------- + + +@router.post("/match") +async def match( + file: UploadFile, + request: Request, + settings: AppSettings = Depends(get_settings), + _: None = Depends(require_api_key), +) -> MatchResponse: + """Match a selfie against stored face embeddings. + + Accepts a multipart image upload, detects the face, embeds it, and returns + the closest matches from the embedding store above the configured threshold. + + Returns **422** if no face is detected in the selfie. + """ + image_bytes = await file.read() + + detector: FaceDetector = get_detector(request) + store: EmbeddingStore = get_store(request) + executor: Executor = request.app.state.executor + + logger.info("Processing selfie match request (%d bytes)", len(image_bytes)) + + loop = asyncio.get_running_loop() + raw_faces: list[DetectedFace] = await loop.run_in_executor(executor, detector.detect_bytes, image_bytes) + + if not raw_faces: + logger.warning("No face detected in uploaded selfie image") + raise HTTPException(status_code=422, detail="No face detected in the uploaded image") + + best = raw_faces[0] # highest confidence (sorted descending) + matches = store.similarity_search(best.embedding, settings.match_threshold, limit=10) + + logger.info( + "Selfie match found %d match(es) above threshold %.2f (detected face confidence: %.3f)", + len(matches), + settings.match_threshold, + best.confidence, + ) + + 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) + + +@router.get("/embeddings/export") +async def export_embeddings( + request: Request, + _: None = Depends(require_api_key), +) -> EmbeddingExportResponse: + """Export all face embeddings with metadata for synchronization. + + Called by Lychee maintenance to re-sync face data after callback failures + or to verify database consistency. + """ + store: EmbeddingStore = get_store(request) + all_data = store.get_all_with_metadata() + + items = [ + EmbeddingExportItem( + lychee_face_id=str(row["lychee_face_id"]), + photo_id=str(row["photo_id"]), + laplacian_variance=float(row["laplacian_variance"] or 0.0), + crop_path=str(row["crop_path"]), + ) + for row in all_data + ] + + return EmbeddingExportResponse(embeddings=items) + + +# --------------------------------------------------------------------------- +# POST /cluster +# --------------------------------------------------------------------------- + + +@router.post("/cluster", status_code=202) +async def cluster( + background_tasks: BackgroundTasks, + request: Request, + settings: AppSettings = Depends(get_settings), + _: None = Depends(require_api_key), +) -> None: + """Run DBSCAN clustering over all stored face embeddings. + + Immediately returns **202 Accepted** and schedules clustering as a background + task. Results are POSTed back to Lychee's clustering results endpoint once + clustering completes. + """ + store: EmbeddingStore = get_store(request) + executor: Executor = request.app.state.executor + + background_tasks.add_task( + _run_clustering_job, + store, + executor, + settings, + ) + + +# --------------------------------------------------------------------------- +# GET /health +# --------------------------------------------------------------------------- + + +@router.get("/health") +async def health(request: Request) -> HealthResponse: + """Return service health, model-loaded status, and embedding count. + + This endpoint is intentionally unauthenticated so that load-balancers and + Docker health checks can probe it without an API key. + """ + detector: FaceDetector = request.app.state.detector + store: EmbeddingStore = request.app.state.store + + return HealthResponse( + status="ok" if detector.is_loaded else "degraded", + model_loaded=detector.is_loaded, + embedding_count=store.count(), + ) + + +# --------------------------------------------------------------------------- +# Background detection job +# --------------------------------------------------------------------------- + + +async def _run_detection_job( + photo_id: str, + image_path: Path, + detector: FaceDetector, + store: EmbeddingStore, + executor: Executor, + settings: AppSettings, +) -> None: + """Detect faces, build the callback payload, and notify Lychee. + + Runs entirely as an async background task after the ``/detect`` route has + returned 202. All CPU-bound work is offloaded to ``executor`` via + ``run_in_executor`` so the event loop remains responsive. + """ + logger.info("Starting detection job for photo_id=%s, path=%s", photo_id, image_path) + try: + # --- 0. Check if faces already exist for this photo --- + existing_count = store.count_by_photo_id(photo_id) + if existing_count > 0: + logger.info( + "Skipping detection for photo_id=%s: %d face(s) already processed", + photo_id, + existing_count, + ) + # Send empty success callback to indicate no new faces detected + payload = DetectCallbackPayload(photo_id=photo_id, faces=[]) + callback_url = f"{settings.lychee_api_url}/api/v2/FaceDetection/results" + async with httpx.AsyncClient(verify=settings.verify_ssl) as client: + response = await client.post( + callback_url, + json=payload.model_dump(), + headers={ + "X-API-Key": settings.api_key, + "Content-Type": "application/json", + "Accept": "application/json", + }, + timeout=30.0, + ) + response.raise_for_status() + return + + loop = asyncio.get_running_loop() + + # --- 1. Detect faces (CPU-bound, runs in thread pool) --- + raw_faces: list[DetectedFace] = await loop.run_in_executor(executor, detector.detect, image_path) + + if len(raw_faces) > settings.max_faces_per_photo: + logger.info( + "Limiting faces from %d to %d (max_faces_per_photo setting)", + len(raw_faces), + settings.max_faces_per_photo, + ) + raw_faces = raw_faces[: settings.max_faces_per_photo] + + if not raw_faces: + logger.info("No faces detected in photo_id=%s, sending empty results", photo_id) + + # --- 2. For each face: generate crop + search suggestions --- + face_data: list[tuple[str, list[float], FaceResult]] = [] + + for raw_face in raw_faces: + emp_id = str(uuid.uuid4()) + + crop_b64: str = await loop.run_in_executor( + executor, + generate_crop, + image_path, + raw_face.x, + raw_face.y, + raw_face.width, + raw_face.height, + ) + + suggestions = store.similarity_search(raw_face.embedding, settings.match_threshold, limit=10) + + if suggestions: + logger.debug( + "Found %d suggestion(s) for face with confidence=%.3f", + len(suggestions), + raw_face.confidence, + ) + + result = FaceResult( + x=raw_face.x, + y=raw_face.y, + width=raw_face.width, + height=raw_face.height, + confidence=raw_face.confidence, + embedding_id=emp_id, + crop=crop_b64, + laplacian_variance=raw_face.laplacian_variance, + suggestions=[SuggestionResult(lychee_face_id=fid, confidence=conf) for fid, conf in suggestions], + ) + face_data.append((emp_id, raw_face.embedding, result)) + + # --- 3. POST success callback to Lychee --- + payload = DetectCallbackPayload( + photo_id=photo_id, + faces=[fd[2] for fd in face_data], + ) + callback_url = f"{settings.lychee_api_url}/api/v2/FaceDetection/results" + logger.debug("Sending detection results to Lychee for photo_id=%s (%d face(s))", photo_id, len(face_data)) + logger.debug("Detection callback payload: %s", payload.model_dump()) + + async with httpx.AsyncClient(verify=settings.verify_ssl) as client: + response = await client.post( + callback_url, + json=payload.model_dump(), + headers={ + "X-API-Key": settings.api_key, + "Content-Type": "application/json", + "Accept": "application/json", + }, + timeout=30.0, + ) + response.raise_for_status() + callback_resp = DetectCallbackResponse.model_validate(response.json()) + + logger.info( + "Successfully sent detection results to Lychee for photo_id=%s (%d face(s))", + photo_id, + len(face_data), + ) + + # --- 4. Persist embeddings + crops now that we have stable lychee_face_ids --- + id_to_data: dict[str, tuple[list[float], FaceResult]] = {eid: (vec, res) for eid, vec, res in face_data} + crop_dir = Path("data/faces") + crop_dir.mkdir(parents=True, exist_ok=True) + + for mapping in callback_resp.faces: + data = id_to_data.get(mapping.embedding_id) + if data is not None: + vec, face_result = data + lychee_face_id = mapping.lychee_face_id + + # Save face crop to disk + crop_path = f"faces/{lychee_face_id}.jpg" + crop_file = crop_dir / f"{lychee_face_id}.jpg" + import base64 + + crop_bytes = base64.b64decode(face_result.crop) + crop_file.write_bytes(crop_bytes) + + # Persist embedding with metadata + store.add( + lychee_face_id=lychee_face_id, + embedding=vec, + photo_id=photo_id, + laplacian_variance=face_result.laplacian_variance, + crop_path=crop_path, + ) + + except Exception: + logger.exception("Detection job failed for photo_id=%s; sending error callback", photo_id) + await _send_error_callback(photo_id, "internal_error", "Detection pipeline failed", settings) + + +async def _send_error_callback(photo_id: str, error_code: str, message: str, settings: AppSettings) -> None: + """Best-effort POST of an error callback to Lychee.""" + payload = ErrorCallbackPayload(photo_id=photo_id, error_code=error_code, message=message) + callback_url = f"{settings.lychee_api_url}/api/v2/FaceDetection/results" + try: + async with httpx.AsyncClient(verify=settings.verify_ssl) as client: + await client.post( + callback_url, + json=payload.model_dump(), + headers={ + "X-API-Key": settings.api_key, + "Content-Type": "application/json", + "Accept": "application/json", + }, + timeout=10.0, + ) + except Exception: + logger.exception("Failed to send error callback for photo_id=%s", photo_id) + + +# --------------------------------------------------------------------------- +# Background clustering job +# --------------------------------------------------------------------------- + + +async def _run_clustering_job( + store: EmbeddingStore, + executor: Executor, + settings: AppSettings, +) -> None: + """Run DBSCAN clustering and notify Lychee with results. + + Runs entirely as an async background task after the ``/cluster`` route has + returned 202. CPU-bound clustering work is offloaded to ``executor`` via + ``run_in_executor`` so the event loop remains responsive. + """ + try: + # --- 1. Fetch all embeddings from store --- + all_embeddings = store.get_all() + + if not all_embeddings: + # Send success callback with empty results + payload = ClusterCallbackPayload(labels=[]) + await _send_cluster_callback(payload, settings) + return + + # --- 2. Run DBSCAN clustering (CPU-bound, runs in thread pool) --- + loop = asyncio.get_running_loop() + clusterer = FaceClusterer(eps=settings.cluster_eps) + results: list[tuple[str, int]] = await loop.run_in_executor( + executor, + clusterer.cluster, + all_embeddings, + ) + + # --- 3. Build cluster label assignments --- + labels = [ClusterFaceResult(face_id=fid, cluster_label=label) for fid, label in results] + + # --- 4. Generate cross-cluster suggestions --- + # Only include face_ids that exist in cluster_results to pass PHP's exists:faces,id validation + valid_face_ids = {fid for fid, _ in results} + suggestions = _generate_cross_cluster_suggestions( + results, + all_embeddings, + store, + valid_face_ids, + settings.match_threshold, + ) + + payload = ClusterCallbackPayload(labels=labels, suggestions=suggestions) + + # --- 5. POST success callback to Lychee --- + await _send_cluster_callback(payload, settings) + + except Exception: + logger.exception("Clustering job failed; sending empty results to Lychee") + # PHP endpoint doesn't handle error payloads, so send empty results + try: + empty_payload = ClusterCallbackPayload(labels=[]) + await _send_cluster_callback(empty_payload, settings) + except Exception: + logger.exception("Failed to send fallback empty clustering results") + + +async def _send_cluster_callback(payload: ClusterCallbackPayload, settings: AppSettings) -> None: + """POST clustering results to Lychee.""" + callback_url = f"{settings.lychee_api_url}/api/v2/FaceDetection/cluster-results" + try: + async with httpx.AsyncClient(verify=settings.verify_ssl) as client: + response = await client.post( + callback_url, + json=payload.model_dump(), + headers={ + "X-API-Key": settings.api_key, + "Content-Type": "application/json", + "Accept": "application/json", + }, + timeout=30.0, + ) + response.raise_for_status() + except Exception: + logger.exception("Failed to send clustering callback") + raise + + +def _generate_cross_cluster_suggestions( + cluster_results: list[tuple[str, int]], + all_embeddings: list[tuple[str, list[float]]], + store: EmbeddingStore, + valid_face_ids: set[str], + threshold: float, + max_per_face: int = 3, +) -> list[ClusterSuggestion]: + """Generate cross-cluster face suggestions for UI review. + + For each clustered face, find similar faces from different clusters + that are above the similarity threshold. This helps identify potential + mis-clusterings and allows manual review. + + Args: + cluster_results: List of (face_id, cluster_label) tuples + all_embeddings: List of (face_id, embedding) tuples + store: Embedding store for similarity search + valid_face_ids: Set of face IDs that exist in Lychee's database + threshold: Minimum similarity threshold + max_per_face: Maximum suggestions per face + """ + suggestions = [] + face_to_cluster = {fid: label for fid, label in cluster_results} + embedding_map = {fid: emb for fid, emb in all_embeddings} + + for face_id, cluster_label in cluster_results: + # Skip noise points (cluster_label == -1) + if cluster_label == -1: + continue + + embedding = embedding_map.get(face_id) + if embedding is None: + continue + + # Find similar faces from the embedding store + matches = store.similarity_search(embedding, threshold, limit=max_per_face + 10) + + # Filter to only faces from different clusters that exist in Lychee's database + for suggested_face_id, confidence in matches: + # Skip if suggested face doesn't exist in Lychee's database + if suggested_face_id not in valid_face_ids: + continue + + suggested_cluster = face_to_cluster.get(suggested_face_id) + if suggested_cluster is not None and suggested_cluster != cluster_label and suggested_cluster != -1: + suggestions.append( + ClusterSuggestion( + face_id=face_id, + suggested_face_id=suggested_face_id, + confidence=float(confidence), # Ensure it's a Python float, not numpy + ) + ) + if len([s for s in suggestions if s.face_id == face_id]) >= max_per_face: + break + + return suggestions diff --git a/ai-vision-service/face-recognition/app/api/schemas.py b/ai-vision-service/face-recognition/app/api/schemas.py new file mode 100644 index 00000000000..9af12428197 --- /dev/null +++ b/ai-vision-service/face-recognition/app/api/schemas.py @@ -0,0 +1,267 @@ +"""Pydantic request/response schemas for the AI Vision Service API. + +These models define the contract between the Python service and Lychee (PHP). +All fields are fully type-annotated; no ``Any`` types are used. +""" + +from pydantic import BaseModel, Field + +# --------------------------------------------------------------------------- +# /detect - Lychee -> Python (request) & Python -> Lychee (callback) +# --------------------------------------------------------------------------- + + +class DetectRequest(BaseModel): + """Body sent by Lychee when dispatching a face-detection job. + + ``callback_url`` is intentionally absent: the service reads + ``VISION_FACE_LYCHEE_API_URL`` from env and posts results there directly. + """ + + photo_id: str + """Lychee-internal photo identifier (string PK).""" + + photo_path: str + """Absolute filesystem path on the shared Docker volume. + + The service validates that this path starts with ``VISION_FACE_PHOTOS_PATH`` + before opening the file (path-traversal protection). + """ + + +class SuggestionResult(BaseModel): + """A single similar face returned as a suggestion.""" + + lychee_face_id: str + """The Lychee ``Face.id`` of the suggested similar face. + + Stored as ``face_suggestions.suggested_face_id`` in the PHP layer; + Lychee JOINs to resolve the linked person at read time. + """ + + confidence: float = Field(ge=0.0, le=1.0) + """Cosine-similarity score (0.0-1.0) between the new embedding and this suggestion.""" + + +class FaceResult(BaseModel): + """One detected face included in the callback payload.""" + + x: float = Field(ge=0.0, le=1.0) + """Left edge of the bounding box as a fraction of image width.""" + + y: float = Field(ge=0.0, le=1.0) + """Top edge of the bounding box as a fraction of image height.""" + + width: float = Field(ge=0.0, le=1.0) + """Bounding-box width as a fraction of image width.""" + + height: float = Field(ge=0.0, le=1.0) + """Bounding-box height as a fraction of image height.""" + + confidence: float = Field(ge=0.0, le=1.0) + """Detection confidence score from the face detector.""" + + embedding_id: str + """Transient identifier generated by the service for this embedding. + + Echoed back in ``DetectCallbackResponse.faces`` so the service can update + its embedding store with the ``lychee_face_id`` assigned by Lychee. + This value is *not* persisted on the Lychee ``Face`` model. + """ + + crop: str + """Base64-encoded 150x150 JPEG face crop. + + Lychee stores it at ``uploads/faces/{token[0:2]}/{token[2:4]}/{token}.jpg`` + where ``token`` is a random high-entropy string generated by Lychee. + Only the top ``max_faces_per_photo`` faces (by confidence) are included. + """ + + laplacian_variance: float + """Laplacian variance sharpness score for the face crop. + + Higher values indicate sharper images. Lychee stores this value to allow + users to tune quality filtering thresholds without re-scanning. + """ + + suggestions: list[SuggestionResult] = [] + """Pre-computed similar faces from the embedding store (may be empty).""" + + +class DetectCallbackPayload(BaseModel): + """Payload POSTed by the service to Lychee's results endpoint on success.""" + + photo_id: str + status: str = "success" + faces: list[FaceResult] + + +class ErrorCallbackPayload(BaseModel): + """Payload POSTed by the service to Lychee's results endpoint on failure.""" + + photo_id: str + status: str = "error" + error_code: str + """Machine-readable error code (e.g. ``"corrupt_file"``, ``"no_faces"``, ``"oom"``).""" + message: str + """Human-readable description of the failure.""" + + +class FaceMapping(BaseModel): + """Mapping returned by Lychee after processing a successful callback. + + The service uses these to update its embedding store with the stable + Lychee-assigned ``lychee_face_id`` for each embedding. + """ + + embedding_id: str + """Echoed from ``FaceResult.embedding_id``.""" + + lychee_face_id: str + """Stable Lychee ``Face.id`` assigned to this embedding.""" + + +class DetectCallbackResponse(BaseModel): + """200 response body returned by Lychee after a successful callback.""" + + faces: list[FaceMapping] + + +# --------------------------------------------------------------------------- +# /match - Lychee -> Python (multipart file) & Python -> Lychee (response) +# --------------------------------------------------------------------------- + + +class MatchResult(BaseModel): + """A single candidate face returned from a selfie-match query.""" + + lychee_face_id: str + """Stable Lychee ``Face.id``; resolved to ``person_id`` by Lychee.""" + + confidence: float = Field(ge=0.0, le=1.0) + """Cosine-similarity score between the selfie embedding and this face.""" + + +class MatchResponse(BaseModel): + """Response body for ``POST /match``.""" + + matches: list[MatchResult] + """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.""" + + face_id: str + """Lychee ``Face.id``.""" + + cluster_label: int + """DBSCAN cluster label. ``-1`` = noise / unassigned.""" + + +class ClusterSuggestion(BaseModel): + """Cross-cluster face suggestion for UI review.""" + + face_id: str + """The face that should show this suggestion.""" + + suggested_face_id: str + """The suggested similar face from a different cluster.""" + + confidence: float = Field(ge=0.0, le=1.0) + """Cosine-similarity score between the two faces.""" + + +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).""" + + labels: list[ClusterFaceResult] + """Per-face cluster label assignments.""" + + +class ClusterCallbackPayload(BaseModel): + """Payload POSTed by the service to Lychee's clustering results endpoint on success.""" + + model_config = {"validate_assignment": True} + + labels: list[ClusterFaceResult] + """Per-face cluster label assignments.""" + + suggestions: list[ClusterSuggestion] = [] + """Cross-cluster face suggestions (optional).""" + + +# --------------------------------------------------------------------------- +# /health +# --------------------------------------------------------------------------- + + +class HealthResponse(BaseModel): + """Response body for ``GET /health``.""" + + status: str + """``"ok"`` when fully operational; ``"degraded"`` if the model is not loaded.""" + + model_loaded: bool + """Whether the face detection/recognition model has been successfully initialised.""" + + embedding_count: int + """Total number of face embeddings currently stored.""" + + +# --------------------------------------------------------------------------- +# GET /embeddings/export - Python -> Lychee (for sync) +# --------------------------------------------------------------------------- + + +class EmbeddingExportItem(BaseModel): + """One face embedding metadata record for syncing.""" + + lychee_face_id: str + """Stable Lychee ``Face.id``.""" + + photo_id: str + """Lychee photo ID.""" + + laplacian_variance: float + """Sharpness score.""" + + crop_path: str + """Relative path to stored face crop.""" + + +class EmbeddingExportResponse(BaseModel): + """Response body for ``GET /embeddings/export``.""" + + embeddings: list[EmbeddingExportItem] + """All stored face embeddings with metadata.""" diff --git a/ai-vision-service/face-recognition/app/clustering/__init__.py b/ai-vision-service/face-recognition/app/clustering/__init__.py new file mode 100644 index 00000000000..089731a0f84 --- /dev/null +++ b/ai-vision-service/face-recognition/app/clustering/__init__.py @@ -0,0 +1 @@ +"""Face embedding clustering (DBSCAN).""" diff --git a/ai-vision-service/face-recognition/app/clustering/clusterer.py b/ai-vision-service/face-recognition/app/clustering/clusterer.py new file mode 100644 index 00000000000..82287a4d8df --- /dev/null +++ b/ai-vision-service/face-recognition/app/clustering/clusterer.py @@ -0,0 +1,80 @@ +"""Face embedding clustering using scikit-learn DBSCAN. + +Groups stored face embeddings into clusters automatically, without requiring +the number of clusters to be specified in advance. Each cluster corresponds +to a likely distinct identity; noise points (label ``-1``) are unassigned. +""" + +from __future__ import annotations + +import logging + +import numpy as np +from sklearn.cluster import DBSCAN + +logger = logging.getLogger(__name__) + + +class FaceClusterer: + """Clusters face embeddings with DBSCAN. + + DBSCAN (Density-Based Spatial Clustering of Applications with Noise) is + well-suited for face grouping because: + - The number of clusters does not need to be specified up front. + - Noise / outlier faces are marked with label ``-1`` rather than forced + into a cluster. + - It works on cosine distance natively when ``metric="cosine"``. + + Args: + eps: Maximum cosine *distance* (1 - similarity) between two samples + for them to be considered neighbours. Lower values mean tighter + clusters. + min_samples: Minimum number of samples in a neighbourhood to form a + core point. Set to ``1`` so that even a single unique face gets + its own cluster (rather than being labelled noise). + """ + + def __init__(self, eps: float = 0.4, min_samples: int = 1) -> None: + self._eps = eps + self._min_samples = min_samples + + def cluster(self, face_embeddings: list[tuple[str, list[float]]]) -> list[tuple[str, int]]: + """Cluster the supplied embeddings. + + Args: + face_embeddings: List of ``(lychee_face_id, embedding)`` pairs. + Each embedding is a 512-dimensional float list. + + Returns: + List of ``(lychee_face_id, cluster_label)`` pairs in the same + order as the input. ``cluster_label == -1`` means the face was + classified as noise. + + Returns an empty list when ``face_embeddings`` is empty. + """ + if not face_embeddings: + return [] + + ids = [fid for fid, _ in face_embeddings] + vectors = np.array([emb for _, emb in face_embeddings], dtype=np.float32) + + # Normalise vectors to unit length so that Euclidean distance equals + # sqrt(2 * (1 - cosine_similarity)). DBSCAN's cosine metric directly + # uses (1 - cosine_similarity) as the distance measure. + norms: np.ndarray = np.linalg.norm(vectors, axis=1, keepdims=True) + # Avoid division by zero for zero-norm vectors + 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)) + logger.info("Cluster label distribution: %s", dict(zip(*np.unique(labels, return_counts=True), strict=False))) + + return list(zip(ids, labels, strict=True)) diff --git a/ai-vision-service/face-recognition/app/config.py b/ai-vision-service/face-recognition/app/config.py new file mode 100644 index 00000000000..e3830966410 --- /dev/null +++ b/ai-vision-service/face-recognition/app/config.py @@ -0,0 +1,134 @@ +"""Application configuration via Pydantic BaseSettings. + +All settings are loaded from environment variables prefixed with ``VISION_FACE_``. +Example: the ``api_key`` field maps to the ``VISION_FACE_API_KEY`` env var. +""" + +from functools import lru_cache +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class AppSettings(BaseSettings): + """Runtime configuration for the AI Vision Service. + + All values are read from environment variables prefixed ``VISION_FACE_``. + """ + + # --- Required --- + lychee_api_url: str + """Lychee instance base URL for callbacks (e.g. ``http://lychee``). No trailing slash.""" + + api_key: str + """Shared API key used in both directions: validated on *inbound* requests from Lychee + (``X-API-Key`` header) and sent as ``X-API-Key`` on *outbound* callbacks to Lychee. + Must match ``AI_VISION_FACE_API_KEY`` in the Lychee ``.env``.""" + + verify_ssl: bool = True + """Whether to verify SSL certificates when making callbacks to Lychee. + Set to ``False`` for development environments with self-signed certificates. + **WARNING:** Disabling SSL verification in production is a security risk.""" + + # --- Model --- + model_name: str = "ArcFace" + """DeepFace recognition model name. ``ArcFace`` = high-accuracy 512-dim embeddings (default); + other supported models include ``Facenet512``, ``VGG-Face``, etc.""" + + detector_backend: str = "retinaface" + """DeepFace detector backend. ``retinaface`` = high-accuracy (default); + alternatives include ``mtcnn``, ``opencv``, ``ssd``.""" + + # --- Detection thresholds --- + detection_threshold: float = 0.5 + """Bounding-box confidence filter — faces below this score are excluded from the callback payload.""" + + match_threshold: float = 0.5 + """Cosine-similarity cutoff for selfie match results and suggestion candidates.""" + + rescan_iou_threshold: float = 0.5 + """IoU threshold for bounding-box matching on re-scan (preserves ``person_id``).""" + + max_faces_per_photo: int = 10 + """Maximum faces included in a callback payload (top-N by confidence; rest dropped).""" + + # --- Concurrency --- + thread_pool_size: int = 1 + """Number of threads in the ``ThreadPoolExecutor`` used for CPU-bound inference.""" + + workers: int = 1 + """Number of Uvicorn worker processes.""" + + # --- Embedding storage --- + storage_backend: str = "sqlite" + """Embedding storage engine: ``sqlite`` or ``pgvector``.""" + + storage_path: str = "/data/embeddings" + """SQLite DB directory (used when ``storage_backend = "sqlite"``).""" + + # --- PostgreSQL (pgvector) --- + pg_host: str = "localhost" + """PostgreSQL host (only when ``storage_backend = "pgvector"``).""" + + pg_port: int = 5432 + """PostgreSQL port.""" + + pg_database: str = "ai_vision" + """PostgreSQL database name.""" + + pg_user: str = "ai_vision" + """PostgreSQL username.""" + + pg_password: str = "" + """PostgreSQL password.""" + + # --- Photo volume --- + photos_path: str = "/data/photos" + """Shared Docker-volume mount point for photo files. + + ``photo_path`` values from Lychee are validated to reside within this prefix + (path-traversal protection). + """ + + # --- Logging --- + 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 = 0.5 + """Laplacian variance threshold for blur detection. + Face crops with a variance below this value are discarded before embedding.""" + + model_root: str = "/root/.deepface" + """Root directory for DeepFace model weights. Exposed as ``DEEPFACE_HOME`` when the service starts. + Defaults to the library's default (``~/.deepface``) but can be overridden to point to a shared + Docker volume if desired.""" + + model_config = SettingsConfigDict( + env_prefix="VISION_FACE_", + # Support .env files in development but never require them in production. + # Load project root .env first (fallback), then working directory .env (override) + env_file=( + Path(__file__).parent.parent / ".env", # Project root (fallback) + ".env", # Current working directory (takes precedence) + ), + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", # Ignore extra fields (e.g., from Lychee's .env when running from main project) + ) + + +@lru_cache +def get_settings() -> AppSettings: + """Return a cached ``AppSettings`` instance. + + Call this function via ``Depends(get_settings)`` in FastAPI route handlers. + Override ``app.dependency_overrides[get_settings]`` in tests to inject + mock settings without touching environment variables. + """ + return AppSettings() # ty: ignore diff --git a/ai-vision-service/face-recognition/app/detection/__init__.py b/ai-vision-service/face-recognition/app/detection/__init__.py new file mode 100644 index 00000000000..9dabc000d3e --- /dev/null +++ b/ai-vision-service/face-recognition/app/detection/__init__.py @@ -0,0 +1 @@ +"""Face detection and crop generation.""" diff --git a/ai-vision-service/face-recognition/app/detection/cropper.py b/ai-vision-service/face-recognition/app/detection/cropper.py new file mode 100644 index 00000000000..572e25437c8 --- /dev/null +++ b/ai-vision-service/face-recognition/app/detection/cropper.py @@ -0,0 +1,180 @@ +"""Face crop generation using Pillow. + +Produces 150 x 150 JPEG crops, base64-encoded, ready to embed in JSON payloads. +""" + +from __future__ import annotations + +import base64 +import io +from typing import TYPE_CHECKING + +from PIL import Image + +if TYPE_CHECKING: + from pathlib import Path + +CROP_SIZE: int = 150 +"""Output crop dimensions in pixels (square).""" + +_PADDING_FACTOR: float = 0.15 +"""Fractional padding added around each bounding box side before cropping.""" + + +def _calculate_square_crop_coords( + x: float, y: float, width: float, height: float, img_w: int, img_h: int, padding_factor: float +) -> tuple[int, int, int, int]: + """Calculate square crop coordinates centered on the face bounding box. + + Attempts to create a square crop centered on the face. If the square would + extend beyond image boundaries, it shifts the crop to fit. If the square is + larger than the image dimensions, the crop will be the maximum square that + fits within the image. + + Args: + x: Normalised left edge of the bounding box (0.0-1.0). + y: Normalised top edge of the bounding box (0.0-1.0). + width: Normalised bounding-box width (0.0-1.0). + height: Normalised bounding-box height (0.0-1.0). + img_w: Image width in pixels. + img_h: Image height in pixels. + padding_factor: Fractional padding to add around the bounding box. + + Returns: + Tuple of (x1, y1, x2, y2) defining a square crop region in absolute pixels. + """ + # Convert to absolute pixels + abs_x = x * img_w + abs_y = y * img_h + abs_w = width * img_w + abs_h = height * img_h + + # Add padding + pad_x = abs_w * padding_factor + pad_y = abs_h * padding_factor + + padded_x = abs_x - pad_x + padded_y = abs_y - pad_y + padded_w = abs_w + 2 * pad_x + padded_h = abs_h + 2 * pad_y + + # Determine square size (use the larger dimension) + square_size = max(padded_w, padded_h) + + # Cap square size to image dimensions (can't crop larger than the image) + max_possible_size = min(img_w, img_h) + square_size = min(square_size, max_possible_size) + + # Calculate center point of the padded bounding box + center_x = padded_x + padded_w / 2 + center_y = padded_y + padded_h / 2 + + # Calculate square crop coordinates centered on the face + x1 = center_x - square_size / 2 + y1 = center_y - square_size / 2 + x2 = center_x + square_size / 2 + y2 = center_y + square_size / 2 + + # Adjust to keep square within image boundaries + # If the square extends beyond the left edge, shift it right + if x1 < 0: + shift = -x1 + x1 = 0 + x2 = min(float(img_w), x2 + shift) + # If the square extends beyond the right edge, shift it left + if x2 > img_w: + shift = x2 - img_w + x2 = img_w + x1 = max(0.0, x1 - shift) + + # If the square extends beyond the top edge, shift it down + if y1 < 0: + shift = -y1 + y1 = 0 + y2 = min(float(img_h), y2 + shift) + # If the square extends beyond the bottom edge, shift it up + if y2 > img_h: + shift = y2 - img_h + y2 = img_h + y1 = max(0.0, y1 - shift) + + return int(x1), int(y1), int(x2), int(y2) + + +def _pad_to_square(img: Image.Image) -> Image.Image: + """Pad a non-square image to square with black borders. + + Args: + img: Input PIL Image. + + Returns: + Square PIL Image with black padding if needed. + """ + width, height = img.size + if width == height: + return img + + size = max(width, height) + square_img = Image.new("RGB", (size, size), (0, 0, 0)) + paste_x = (size - width) // 2 + paste_y = (size - height) // 2 + square_img.paste(img, (paste_x, paste_y)) + return square_img + + +def generate_crop(image_path: Path, x: float, y: float, width: float, height: float) -> str: + """Generate a base64-encoded 150 x 150 JPEG face crop. + + Args: + image_path: Absolute path to the source image. + x: Normalised left edge of the bounding box (0.0-1.0). + y: Normalised top edge of the bounding box (0.0-1.0). + width: Normalised bounding-box width (0.0-1.0). + height: Normalised bounding-box height (0.0-1.0). + + Returns: + Base64-encoded JPEG bytes (ASCII string). + """ + img = Image.open(image_path).convert("RGB") + img_w, img_h = img.size + + # Calculate square crop coordinates + x1, y1, x2, y2 = _calculate_square_crop_coords(x, y, width, height, img_w, img_h, _PADDING_FACTOR) + + # Crop and ensure it's square (pad if needed due to edge constraints) + crop = img.crop((x1, y1, x2, y2)) + crop = _pad_to_square(crop) + crop = crop.resize((CROP_SIZE, CROP_SIZE), Image.Resampling.LANCZOS) + + buf = io.BytesIO() + crop.save(buf, format="JPEG", quality=85) + return base64.b64encode(buf.getvalue()).decode("ascii") + + +def generate_crop_from_bytes(image_bytes: bytes, x: float, y: float, width: float, height: float) -> str: + """Generate a crop from raw image bytes (used in testing / matching). + + Args: + image_bytes: Raw image file bytes. + x: Normalised left edge of the bounding box (0.0-1.0). + y: Normalised top edge of the bounding box (0.0-1.0). + width: Normalised bounding-box width (0.0-1.0). + height: Normalised bounding-box height (0.0-1.0). + + Returns: + Base64-encoded JPEG bytes (ASCII string). + """ + img = Image.open(io.BytesIO(image_bytes)).convert("RGB") + img_w, img_h = img.size + + # Calculate square crop coordinates + x1, y1, x2, y2 = _calculate_square_crop_coords(x, y, width, height, img_w, img_h, _PADDING_FACTOR) + + # Crop and ensure it's square (pad if needed due to edge constraints) + crop = img.crop((x1, y1, x2, y2)) + crop = _pad_to_square(crop) + crop = crop.resize((CROP_SIZE, CROP_SIZE), Image.Resampling.LANCZOS) + + buf = io.BytesIO() + crop.save(buf, format="JPEG", quality=85) + return base64.b64encode(buf.getvalue()).decode("ascii") diff --git a/ai-vision-service/face-recognition/app/detection/detector.py b/ai-vision-service/face-recognition/app/detection/detector.py new file mode 100644 index 00000000000..b33a2ea4760 --- /dev/null +++ b/ai-vision-service/face-recognition/app/detection/detector.py @@ -0,0 +1,245 @@ +"""DeepFace wrapper for face detection and embedding generation.""" + +from __future__ import annotations + +import logging +import threading +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, cast + +if TYPE_CHECKING: + from pathlib import Path + +logger = logging.getLogger(__name__) + + +@dataclass +class DetectedFace: + """Raw face detection result, including the ArcFace embedding vector.""" + + x: float + """Left edge of bounding box as a fraction of image width (0.0-1.0).""" + + y: float + """Top edge of bounding box as a fraction of image height (0.0-1.0).""" + + width: float + """Bounding-box width as a fraction of image width (0.0-1.0).""" + + height: float + """Bounding-box height as a fraction of image height (0.0-1.0).""" + + confidence: float + """Detection confidence score from RetinaFace (0.0-1.0).""" + + embedding: list[float] = field(default_factory=list) + """512-dimensional ArcFace embedding vector.""" + + laplacian_variance: float = 0.0 + """Laplacian variance sharpness score (higher = sharper).""" + + +class FaceDetector: + """Thread-safe wrapper around DeepFace for face detection and embedding generation. + + Uses ArcFace recognition with the RetinaFace detector backend by default. + The model is loaded once at startup (via :meth:`load`) and shared across + threads; a lock guards the non-thread-safe ``DeepFace.represent()`` call. + """ + + def __init__( + self, + model_name: str = "ArcFace", + detection_threshold: float = 0.5, + blur_threshold: float = 100.0, + detector_backend: str = "retinaface", + ) -> None: + self._model_name = model_name + self._detection_threshold = detection_threshold + self._blur_threshold = blur_threshold + self._detector_backend = detector_backend + self._loaded: bool = False + self._lock = threading.Lock() + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def load(self) -> None: + """Load the DeepFace model into memory. + + Idempotent - safe to call more than once. + Must be called before :meth:`detect` or :meth:`detect_bytes`. + """ + import numpy as np + from deepface import DeepFace + + with self._lock: + if self._loaded: + return + # Warmup: triggers model weight download/cache on first call. + DeepFace.represent( + img_path=np.zeros((1, 1, 3), dtype=np.uint8), + model_name=self._model_name, + detector_backend=self._detector_backend, + enforce_detection=False, + ) + self._loaded = True + + @property + def is_loaded(self) -> bool: + """Return ``True`` if the model has been successfully loaded.""" + return self._loaded + + # ------------------------------------------------------------------ + # Detection + # ------------------------------------------------------------------ + + def detect(self, image_path: Path) -> list[DetectedFace]: + """Detect faces in an image file. + + Args: + image_path: Absolute path to the image file. + + Returns: + Detected faces sorted by descending confidence, with normalised + bounding box coordinates (0.0-1.0) and 512-dim embeddings. + + Raises: + RuntimeError: If :meth:`load` has not been called. + ValueError: If the file cannot be decoded as an image. + """ + import cv2 + + img: Any = cv2.imread(str(image_path)) + if img is None: + raise ValueError(f"Cannot read image: {image_path}") + return self._detect_array(img) + + def detect_bytes(self, image_bytes: bytes) -> list[DetectedFace]: + """Detect faces from raw image bytes. + + Useful when the caller already has the image in memory (e.g. for + selfie matching so that no temporary file needs to be written). + + Args: + image_bytes: Raw bytes of any image format supported by OpenCV. + + Returns: + Detected faces sorted by descending confidence. + + Raises: + RuntimeError: If :meth:`load` has not been called. + ValueError: If the bytes cannot be decoded as an image. + """ + import cv2 + import numpy as np + + nparr: Any = np.frombuffer(image_bytes, np.uint8) + img: Any = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + if img is None: + raise ValueError("Cannot decode image bytes") + return self._detect_array(img) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _detect_array(self, img: Any) -> list[DetectedFace]: + """Run detection on a BGR numpy array (internal).""" + import cv2 + + if not self._loaded: + raise RuntimeError("FaceDetector not loaded - call load() first.") + + from deepface import DeepFace + + h: int = int(img.shape[0]) + w: int = int(img.shape[1]) + + with self._lock: + raw_faces: list[dict[str, Any]] = cast( + "list[dict[str, Any]]", + DeepFace.represent( + img_path=img, + model_name=self._model_name, + detector_backend=self._detector_backend, + enforce_detection=False, + ), + ) + + logger.info("Face detection: found %d raw face(s) in %dx%d image", len(raw_faces), w, h) + + results: list[DetectedFace] = [] + filtered_by_confidence = 0 + filtered_by_blur = 0 + + for face in raw_faces: + score: float = float(face["face_confidence"]) + if score < self._detection_threshold: + filtered_by_confidence += 1 + continue + + area = face["facial_area"] + x1 = float(area["x"]) + y1 = float(area["y"]) + x2 = x1 + float(area["w"]) + y2 = y1 + float(area["h"]) + + # Compute Laplacian variance on the face crop region. + # This sharpness score is always computed and sent to Lychee for filtering/tuning. + px1 = max(0, int(x1)) + py1 = max(0, int(y1)) + px2 = min(w, int(x2)) + py2 = min(h, int(y2)) + variance: float = 0.0 + if px2 > px1 and py2 > py1: + crop_region = img[py1:py2, px1:px2] + gray = cv2.cvtColor(crop_region, cv2.COLOR_BGR2GRAY) + variance = float(cv2.Laplacian(gray, cv2.CV_64F).var()) + + # Blur filter: exclude faces below threshold if enabled (threshold > 0.0) + if self._blur_threshold > 0.0 and variance < self._blur_threshold: + filtered_by_blur += 1 + logger.info( + "Filtered blurry face: variance=%.2f < threshold=%.2f", + 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)) + fw = max(0.0, min(1.0, (x2 - x1) / w)) + fh = max(0.0, min(1.0, (y2 - y1) / h)) + + embedding: list[float] = [float(v) for v in face["embedding"]] + + results.append( + DetectedFace( + x=fx, + y=fy, + width=fw, + height=fh, + confidence=score, + embedding=embedding, + laplacian_variance=variance, + ) + ) + + # Descending confidence order + results.sort(key=lambda f: f.confidence, reverse=True) + + # Log summary + if filtered_by_confidence > 0 or filtered_by_blur > 0: + logger.info( + "Face detection: %d face(s) passed filters (filtered %d by confidence, %d by blur)", + len(results), + filtered_by_confidence, + filtered_by_blur, + ) + else: + logger.info("Face detection: %d face(s) detected (all passed filters)", len(results)) + + return results diff --git a/ai-vision-service/face-recognition/app/embeddings/__init__.py b/ai-vision-service/face-recognition/app/embeddings/__init__.py new file mode 100644 index 00000000000..f174c5f4101 --- /dev/null +++ b/ai-vision-service/face-recognition/app/embeddings/__init__.py @@ -0,0 +1 @@ +"""Embedding storage layer.""" diff --git a/ai-vision-service/face-recognition/app/embeddings/factory.py b/ai-vision-service/face-recognition/app/embeddings/factory.py new file mode 100644 index 00000000000..5d737cd4390 --- /dev/null +++ b/ai-vision-service/face-recognition/app/embeddings/factory.py @@ -0,0 +1,42 @@ +"""Factory for creating the appropriate EmbeddingStore backend.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from app.config import AppSettings + from app.embeddings.store import EmbeddingStore + + +def create_store(settings: AppSettings) -> EmbeddingStore: + """Return a configured :class:`EmbeddingStore` for the given settings. + + Args: + settings: Application settings (reads ``storage_backend``). + + Returns: + A ready-to-use :class:`EmbeddingStore` instance. + + Raises: + ValueError: If ``storage_backend`` is not ``"sqlite"`` or ``"pgvector"``. + """ + backend = settings.storage_backend.lower() + + if backend == "sqlite": + from app.embeddings.sqlite_store import SQLiteEmbeddingStore + + return SQLiteEmbeddingStore(storage_path=settings.storage_path) + + if backend == "pgvector": + from app.embeddings.pgvector_store import PgVectorEmbeddingStore + + return PgVectorEmbeddingStore( + host=settings.pg_host, + port=settings.pg_port, + database=settings.pg_database, + user=settings.pg_user, + password=settings.pg_password, + ) + + raise ValueError(f"Unknown storage_backend {backend!r}. Expected 'sqlite' or 'pgvector'.") diff --git a/ai-vision-service/face-recognition/app/embeddings/pgvector_store.py b/ai-vision-service/face-recognition/app/embeddings/pgvector_store.py new file mode 100644 index 00000000000..f1e17187423 --- /dev/null +++ b/ai-vision-service/face-recognition/app/embeddings/pgvector_store.py @@ -0,0 +1,207 @@ +"""PostgreSQL + pgvector embedding storage backend. + +Requires the ``pgvector`` extension to be installed in the target database. +Use this backend for production-scale deployments where SQLite throughput +is a bottleneck. + +Table layout (single table, lychee_face_id as primary key): + face_embeddings: lychee_face_id TEXT PK, embedding vector(512) +""" + +from __future__ import annotations + +import threading +from typing import Any + +from app.embeddings.store import EmbeddingStore + +_EMBEDDING_DIM = 512 +"""ArcFace (buffalo_l) embedding dimension.""" + + +def _to_pg_vector(embedding: list[float]) -> str: + """Serialise a float list to the pgvector literal format ``[a,b,c,...]``.""" + return "[" + ",".join(f"{v:.8f}" for v in embedding) + "]" + + +class PgVectorEmbeddingStore(EmbeddingStore): + """Embedding store backed by PostgreSQL + pgvector. + + Thread-safe: each method acquires an RLock to serialise access to the + connection pool (single connection for simplicity - swap for a real pool + in high-throughput deployments). + """ + + def __init__( + self, + host: str = "localhost", + port: int = 5432, + database: str = "ai_vision", + user: str = "ai_vision", + password: str = "", + ) -> None: + self._dsn = f"host={host} port={port} dbname={database} user={user} password={password}" + self._lock = threading.RLock() + self._conn: Any = None + self._init_db() + + # ------------------------------------------------------------------ + # EmbeddingStore protocol + # ------------------------------------------------------------------ + + def add( + self, + lychee_face_id: str, + embedding: list[float], + photo_id: str, + laplacian_variance: float, + crop_path: str, + ) -> None: + """Upsert an embedding row.""" + vec_str = _to_pg_vector(embedding) + with self._lock: + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO face_embeddings (lychee_face_id, embedding, photo_id, laplacian_variance, crop_path) + VALUES (%s, %s::vector, %s, %s, %s) + ON CONFLICT (lychee_face_id) DO UPDATE + SET embedding = EXCLUDED.embedding, + photo_id = EXCLUDED.photo_id, + laplacian_variance = EXCLUDED.laplacian_variance, + crop_path = EXCLUDED.crop_path + """, + (lychee_face_id, vec_str, photo_id, laplacian_variance, crop_path), + ) + conn.commit() + + def delete(self, lychee_face_id: str) -> None: + """Remove an embedding by Lychee Face ID.""" + with self._lock: + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute( + "DELETE FROM face_embeddings WHERE lychee_face_id = %s", + (lychee_face_id,), + ) + conn.commit() + + def similarity_search( + self, + embedding: list[float], + threshold: float, + limit: int = 10, + ) -> list[tuple[str, float]]: + """Cosine-similarity search using pgvector's ``<=>`` operator.""" + vec_str = _to_pg_vector(embedding) + with self._lock: + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute( + """ + WITH q AS (SELECT %s::vector AS qvec) + SELECT f.lychee_face_id, + 1.0 - (f.embedding <=> q.qvec) AS similarity + FROM face_embeddings f, q + WHERE 1.0 - (f.embedding <=> q.qvec) >= %s + ORDER BY f.embedding <=> q.qvec + LIMIT %s + """, + (vec_str, threshold, limit), + ) + 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 get_all_with_metadata(self) -> list[dict[str, str | float | None]]: + """Return all stored embeddings with metadata.""" + with self._lock: + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute("SELECT lychee_face_id, photo_id, laplacian_variance, crop_path FROM face_embeddings") + rows: list[Any] = cur.fetchall() + results: list[dict[str, str | float | None]] = [] + for row in rows: + results.append( + { + "lychee_face_id": row[0], + "photo_id": row[1], + "laplacian_variance": float(row[2]) if row[2] is not None else None, + "crop_path": row[3], + } + ) + return results + + def count(self) -> int: + """Return the total number of stored embeddings.""" + with self._lock: + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute("SELECT COUNT(*) FROM face_embeddings") + row: Any = cur.fetchone() + return int(row[0]) if row else 0 + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _get_conn(self) -> Any: + """Return the shared connection, reconnecting if needed.""" + import psycopg2 + + if self._conn is None or self._conn.closed: + self._conn = psycopg2.connect(self._dsn) + return self._conn + + def _init_db(self) -> None: + """Create the table and index if they do not already exist.""" + with self._lock: + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute("CREATE EXTENSION IF NOT EXISTS vector") + cur.execute( + f""" + CREATE TABLE IF NOT EXISTS face_embeddings ( + lychee_face_id TEXT PRIMARY KEY, + embedding vector({_EMBEDDING_DIM}) NOT NULL, + photo_id TEXT, + laplacian_variance REAL, + crop_path TEXT + ) + """ + ) + # IVFFlat index for approx nearest-neighbour search. + # ``lists`` should be tuned (~sqrt of row count). + cur.execute( + """ + CREATE INDEX IF NOT EXISTS face_embeddings_embedding_cosine_idx + ON face_embeddings USING ivfflat (embedding vector_cosine_ops) + WITH (lists = 100) + """ + ) + conn.commit() diff --git a/ai-vision-service/face-recognition/app/embeddings/sqlite_store.py b/ai-vision-service/face-recognition/app/embeddings/sqlite_store.py new file mode 100644 index 00000000000..afcf3d2eda1 --- /dev/null +++ b/ai-vision-service/face-recognition/app/embeddings/sqlite_store.py @@ -0,0 +1,260 @@ +"""SQLite + sqlite-vec embedding storage backend. + +Uses the sqlite-vec extension for approximate nearest-neighbour (ANN) search +over 512-dimensional float vectors. This is the default backend, suitable +for single-container deployments. + +Table layout: + vec_faces (vec0 virtual table) - stores raw float vectors; indexed by rowid. + face_meta (regular table) - stores lychee_face_id -> vec_rowid mapping. +""" + +from __future__ import annotations + +import sqlite3 +import struct +import threading +from pathlib import Path + +from app.embeddings.store import EmbeddingStore + +_EMBEDDING_DIM = 512 +"""ArcFace (buffalo_l) embedding dimension.""" + + +def _to_blob(embedding: list[float]) -> bytes: + """Serialise a float list to a little-endian float32 byte blob.""" + return struct.pack(f"{len(embedding)}f", *embedding) + + +class SQLiteEmbeddingStore(EmbeddingStore): + """Embedding store backed by SQLite + sqlite-vec. + + Thread-safe: all write operations are protected by a reentrant lock. + """ + + def __init__(self, storage_path: str) -> None: + """Initialise the store. + + Args: + storage_path: Directory where the SQLite database file will be + created (filename ``embeddings.db``). + """ + db_dir = Path(storage_path) + db_dir.mkdir(parents=True, exist_ok=True) + self._db_path = str(db_dir / "embeddings.db") + self._lock = threading.RLock() + self._init_db() + + # ------------------------------------------------------------------ + # EmbeddingStore protocol + # ------------------------------------------------------------------ + + def add( + self, + lychee_face_id: str, + embedding: list[float], + photo_id: str, + laplacian_variance: float, + crop_path: str, + ) -> None: + """Upsert an embedding row.""" + with self._lock: + conn = self._connect() + try: + # Remove existing entry for this face (if any) before inserting. + self._delete_internal(conn, lychee_face_id) + cursor = conn.execute( + "INSERT INTO vec_faces(face_embedding) VALUES (?)", + [_to_blob(embedding)], + ) + vec_rowid: int = cursor.lastrowid # ty: ignore + conn.execute( + "INSERT INTO face_meta(vec_rowid, lychee_face_id, photo_id, laplacian_variance, " + "crop_path) VALUES (?, ?, ?, ?, ?)", + [vec_rowid, lychee_face_id, photo_id, laplacian_variance, crop_path], + ) + conn.commit() + finally: + conn.close() + + def delete(self, lychee_face_id: str) -> None: + """Remove an embedding by Lychee Face ID.""" + with self._lock: + conn = self._connect() + try: + self._delete_internal(conn, lychee_face_id) + conn.commit() + finally: + conn.close() + + def similarity_search( + self, + embedding: list[float], + threshold: float, + limit: int = 10, + ) -> list[tuple[str, float]]: + """KNN cosine-similarity search. + + Fetches up to ``limit * 5`` candidates from vec0 (to allow for + threshold filtering) and returns at most ``limit`` results that + exceed ``threshold``. + """ + conn = self._connect() + try: + k = limit * 5 # over-fetch so we have headroom after threshold filter + rows = conn.execute( + """ + SELECT m.lychee_face_id, v.distance + FROM vec_faces v + JOIN face_meta m ON m.vec_rowid = v.rowid + WHERE v.face_embedding MATCH ? AND k = ? + ORDER BY v.distance + """, + [_to_blob(embedding), k], + ).fetchall() + + results: list[tuple[str, float]] = [] + for lychee_face_id, distance in rows: + similarity = 1.0 - float(distance) + if similarity >= threshold: + results.append((lychee_face_id, similarity)) + if len(results) >= limit: + break + return results + 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 get_all_with_metadata(self) -> list[dict[str, str | float | None]]: + """Return all stored embeddings with metadata.""" + conn = self._connect() + try: + rows = conn.execute( + """ + SELECT lychee_face_id, photo_id, laplacian_variance, crop_path + FROM face_meta + """, + ).fetchall() + results: list[dict[str, str | float | None]] = [] + for lychee_face_id, photo_id, laplacian_variance, crop_path in rows: + results.append( + { + "lychee_face_id": lychee_face_id, + "photo_id": photo_id, + "laplacian_variance": laplacian_variance, + "crop_path": crop_path, + } + ) + return results + finally: + conn.close() + + def count(self) -> int: + """Return the number of stored embeddings.""" + conn = self._connect() + try: + row = conn.execute("SELECT COUNT(*) FROM face_meta").fetchone() + return int(row[0]) if row else 0 + finally: + conn.close() + + def count_by_photo_id(self, photo_id: str) -> int: + """Count how many faces have been stored for a given photo.""" + conn = self._connect() + try: + row = conn.execute( + "SELECT COUNT(*) FROM face_meta WHERE photo_id = ?", + [photo_id], + ).fetchone() + return int(row[0]) if row else 0 + finally: + conn.close() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _connect(self) -> sqlite3.Connection: + """Open a new SQLite connection with sqlite-vec loaded.""" + import sqlite_vec + + conn = sqlite3.connect(self._db_path, check_same_thread=False) + conn.enable_load_extension(True) + sqlite_vec.load(conn) + conn.enable_load_extension(False) + return conn + + def _init_db(self) -> None: + """Create tables if they do not already exist.""" + with self._lock: + conn = self._connect() + try: + conn.execute( + f"CREATE VIRTUAL TABLE IF NOT EXISTS vec_faces USING vec0(face_embedding float[{_EMBEDDING_DIM}])" + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS face_meta ( + vec_rowid INTEGER UNIQUE NOT NULL, + lychee_face_id TEXT PRIMARY KEY NOT NULL, + photo_id TEXT, + laplacian_variance REAL, + crop_path TEXT + ) + """ + ) + conn.commit() + finally: + conn.close() + + def _delete_internal(self, conn: sqlite3.Connection, lychee_face_id: str) -> None: + """Delete an entry without committing (caller must commit).""" + row = conn.execute( + "SELECT vec_rowid FROM face_meta WHERE lychee_face_id = ?", + [lychee_face_id], + ).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 = ?", [lychee_face_id]) diff --git a/ai-vision-service/face-recognition/app/embeddings/store.py b/ai-vision-service/face-recognition/app/embeddings/store.py new file mode 100644 index 00000000000..97c40fdaf5a --- /dev/null +++ b/ai-vision-service/face-recognition/app/embeddings/store.py @@ -0,0 +1,114 @@ +"""Abstract EmbeddingStore protocol. + +All embedding storage backends must conform to this interface so that the +application code is independent of the concrete storage engine chosen. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class EmbeddingStore(Protocol): + """Protocol for face embedding storage backends. + + Implementations must be thread-safe when called from concurrent request + handlers. + """ + + def add( + self, + lychee_face_id: str, + embedding: list[float], + photo_id: str, + laplacian_variance: float, + crop_path: str, + ) -> None: + """Persist an embedding, keyed by its Lychee Face ID. + + If an entry for ``lychee_face_id`` already exists, it is replaced. + + Args: + lychee_face_id: Stable Lychee ``Face.id`` (string PK). + embedding: 512-dimensional ArcFace float vector. + photo_id: Lychee photo ID for re-synchronization support. + laplacian_variance: Sharpness score for filtering/tuning. + crop_path: Relative path to stored face crop (e.g. 'faces/{id}.jpg'). + """ + ... + + def delete(self, lychee_face_id: str) -> None: + """Remove an embedding by Lychee Face ID. + + No-op if the ID is not found. + + Args: + lychee_face_id: Stable Lychee ``Face.id`` to remove. + """ + ... + + def similarity_search( + self, + embedding: list[float], + threshold: float, + limit: int = 10, + ) -> list[tuple[str, float]]: + """Return the most similar stored faces. + + Args: + embedding: Query embedding vector. + threshold: Minimum cosine-similarity score (0.0-1.0). + limit: Maximum number of results to return. + + Returns: + List of ``(lychee_face_id, similarity)`` tuples ordered by + descending similarity score. Only entries above ``threshold`` + are included. + """ + ... + + 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 get_all_with_metadata(self) -> list[dict[str, str | float | None]]: + """Return all stored embeddings with metadata. + + Returns: + List of dicts with keys: lychee_face_id, photo_id, laplacian_variance, crop_path. + Used for syncing embeddings back to Lychee. + """ + ... + + def count_by_photo_id(self, photo_id: str) -> int: + """Count how many faces have been stored for a given photo. + + Args: + photo_id: Lychee photo ID to check. + + Returns: + Number of face embeddings stored for this photo. + """ + ... + + def count(self) -> int: + """Return the total number of stored embeddings.""" + ... diff --git a/ai-vision-service/face-recognition/app/main.py b/ai-vision-service/face-recognition/app/main.py new file mode 100644 index 00000000000..a337f2ca7f2 --- /dev/null +++ b/ai-vision-service/face-recognition/app/main.py @@ -0,0 +1,123 @@ +"""FastAPI application factory. + +Entry point for the AI Vision Service. The ``create_app`` factory accepts an +optional ``lifespan`` parameter so that tests can inject a custom lifespan +context that pre-populates ``app.state`` with mock objects instead of loading +the real face recognition model. +""" + +from __future__ import annotations + +import logging +import os +from concurrent.futures import ThreadPoolExecutor +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Any + +import httpx +from fastapi import FastAPI + +from app.api.routes import router +from app.config import AppSettings, get_settings + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def _default_lifespan(app: FastAPI) -> AsyncGenerator[None]: + """Production lifespan: load the face model and initialise the store.""" + settings: AppSettings = get_settings() + + # Configure logging + logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO)) + + # Verify Lychee connectivity + lychee_up_url = f"{settings.lychee_api_url}/up" + logger.info("Checking Lychee connectivity at %s", lychee_up_url) + try: + async with httpx.AsyncClient(verify=settings.verify_ssl, timeout=10.0) as client: + response = await client.get(lychee_up_url) + response.raise_for_status() + logger.info("✓ Lychee is reachable (status=%d)", response.status_code) + except httpx.HTTPStatusError as e: + logger.error("✗ Lychee /up endpoint returned status %d", e.response.status_code) + raise RuntimeError( + f"Lychee health check failed: /up returned {e.response.status_code}. " + "Ensure VISION_FACE_LYCHEE_API_URL is correct and Lychee is running." + ) from e + except httpx.RequestError as e: + logger.error("✗ Cannot connect to Lychee at %s: %s", lychee_up_url, e) + raise RuntimeError( + f"Cannot connect to Lychee at {lychee_up_url}. " + "Ensure VISION_FACE_LYCHEE_API_URL is correct and Lychee is reachable." + ) from e + + # Load detector + from app.detection.detector import FaceDetector + + # Expose model_root as DEEPFACE_HOME before load() so deepface discovers cached weights. + # deepface reads DEEPFACE_HOME lazily (via get_deepface_home() on each access), not at + # import time, so setting it here — before detector.load() — is reliable. + # In Docker deployments, ENV DEEPFACE_HOME is also set in the Dockerfile as a fallback. + os.environ["DEEPFACE_HOME"] = settings.model_root + + detector = FaceDetector( + model_name=settings.model_name, + detection_threshold=settings.detection_threshold, + blur_threshold=settings.blur_threshold, + detector_backend=settings.detector_backend, + ) + detector.load() + logger.info("DeepFace model '%s' (backend: %s) loaded successfully", settings.model_name, settings.detector_backend) + + # Initialise embedding store + from app.embeddings.factory import create_store + + store = create_store(settings) + logger.info( + "Embedding store initialised (backend=%s, count=%d)", + settings.storage_backend, + store.count(), + ) + + # Thread pool for CPU-bound inference + executor = ThreadPoolExecutor(max_workers=settings.thread_pool_size) + + app.state.detector = detector + app.state.store = store + app.state.executor = executor + + yield + + executor.shutdown(wait=False) + logger.info("AI Vision Service shut down") + + +def create_app(lifespan: Any = None) -> FastAPI: + """Create and configure the FastAPI application. + + Args: + lifespan: Optional async context manager to use as the application + lifespan. When ``None``, :func:`_default_lifespan` is used. + Override in tests to inject mock state without loading the model. + + Returns: + A configured :class:`fastapi.FastAPI` instance. + """ + used_lifespan = lifespan if lifespan is not None else _default_lifespan + + application = FastAPI( + title="Lychee AI Vision Service", + description="Facial recognition microservice for Lychee photo gallery.", + version="0.1.0", + lifespan=used_lifespan, + ) + application.include_router(router) + return application + + +# Module-level app instance used by uvicorn when started via the Dockerfile CMD. +app: FastAPI = create_app() diff --git a/ai-vision-service/face-recognition/app/matching/__init__.py b/ai-vision-service/face-recognition/app/matching/__init__.py new file mode 100644 index 00000000000..ba5a1fe05de --- /dev/null +++ b/ai-vision-service/face-recognition/app/matching/__init__.py @@ -0,0 +1 @@ +"""Selfie-to-face similarity matching.""" diff --git a/ai-vision-service/face-recognition/app/matching/matcher.py b/ai-vision-service/face-recognition/app/matching/matcher.py new file mode 100644 index 00000000000..8b7e1c907e8 --- /dev/null +++ b/ai-vision-service/face-recognition/app/matching/matcher.py @@ -0,0 +1,62 @@ +"""Selfie-to-face similarity matching. + +Accepts a selfie image (as raw bytes), detects the face, embeds it, and +returns the best matching Lychee Face IDs from the embedding store. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from app.api.schemas import MatchResponse, MatchResult + +if TYPE_CHECKING: + from app.detection.detector import FaceDetector + from app.embeddings.store import EmbeddingStore + + +class FaceMatcher: + """Encapsulates the selfie-match logic used by the ``POST /match`` route. + + Args: + detector: Loaded :class:`~app.detection.detector.FaceDetector`. + store: Configured :class:`~app.embeddings.store.EmbeddingStore`. + threshold: Minimum cosine-similarity score for a match to be included. + """ + + def __init__( + self, + detector: FaceDetector, + store: EmbeddingStore, + threshold: float = 0.5, + ) -> None: + self._detector = detector + self._store = store + self._threshold = threshold + + def match(self, image_bytes: bytes, limit: int = 10) -> MatchResponse: + """Run selfie matching. + + Args: + image_bytes: Raw bytes of the uploaded selfie image. + limit: Maximum number of matches to return. + + Returns: + :class:`~app.api.schemas.MatchResponse` with matches ordered by + descending confidence. The list may be empty if no stored face + exceeds the threshold. + + Raises: + ValueError: If no face is detected in the selfie. + """ + raw_faces = self._detector.detect_bytes(image_bytes) + if not raw_faces: + raise ValueError("No face detected in the selfie image.") + + # Use the highest-confidence face for matching. + best = raw_faces[0] + matches = self._store.similarity_search(best.embedding, self._threshold, limit=limit) + + return MatchResponse( + matches=[MatchResult(lychee_face_id=face_id, confidence=conf) for face_id, conf in matches] + ) diff --git a/ai-vision-service/face-recognition/data/.gitignore b/ai-vision-service/face-recognition/data/.gitignore new file mode 100644 index 00000000000..c96a04f008e --- /dev/null +++ b/ai-vision-service/face-recognition/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/ai-vision-service/face-recognition/pyproject.toml b/ai-vision-service/face-recognition/pyproject.toml new file mode 100644 index 00000000000..1aff5e59b3d --- /dev/null +++ b/ai-vision-service/face-recognition/pyproject.toml @@ -0,0 +1,94 @@ +[project] +name = "ai-vision-service" +version = "0.1.0" +description = "Lychee AI Vision Service – facial recognition microservice" +readme = "README.md" +requires-python = ">=3.13,<3.14" +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.32", + "pydantic>=2.10", + "pydantic-settings>=2.7", + "httpx>=0.28", + "deepface>=0.0.93", + "tensorflow>=2.16", + # deepface requires opencv-python (not headless) as a transitive dependency. + # The headless variant cannot be used here because uv treats them as conflicting packages. + # The Dockerfile installs libgl1/libglib2.0-0 to satisfy opencv-python's system requirements. + "opencv-python>=4.10", + "pillow>=11.0", + "numpy>=2.2", + "scikit-learn>=1.6", + "sqlite-vec>=0.1", + "psycopg2-binary>=2.9", + "python-multipart>=0.0.20", +] + +[project.optional-dependencies] +gpu = ["tensorflow[and-cuda]"] + +[dependency-groups] +dev = [ + "ruff>=0.9", + "ty>=0.0.1a10", + "pytest>=8.3", + "pytest-cov>=6.0", + "pytest-asyncio>=0.25", + "anyio>=4.7", + "respx>=0.21", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +# --------------------------------------------------------------------------- +# Ruff – linting and formatting +# --------------------------------------------------------------------------- +[tool.ruff] +target-version = "py313" +line-length = 120 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "ANN", # flake8-annotations + "B", # flake8-bugbear + "A", # flake8-builtins + "SIM", # flake8-simplify + "TCH", # flake8-type-checking + "RUF", # Ruff-specific rules +] +ignore = [ + "B008", # FastAPI requires Depends() calls in function default arguments + "ANN401", # typing.Any needed for numpy array and deepface return type annotations +] + +[tool.ruff.lint.isort] +known-first-party = ["app"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + +# --------------------------------------------------------------------------- +# ty – type checking +# --------------------------------------------------------------------------- +[tool.ty] +# ty inherits Python version from requires-python + +# --------------------------------------------------------------------------- +# pytest +# --------------------------------------------------------------------------- +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +addopts = "--strict-markers" diff --git a/ai-vision-service/face-recognition/tests/__init__.py b/ai-vision-service/face-recognition/tests/__init__.py new file mode 100644 index 00000000000..4e7c0ac83f7 --- /dev/null +++ b/ai-vision-service/face-recognition/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for the AI Vision Service.""" diff --git a/ai-vision-service/face-recognition/tests/conftest.py b/ai-vision-service/face-recognition/tests/conftest.py new file mode 100644 index 00000000000..1a4929602ca --- /dev/null +++ b/ai-vision-service/face-recognition/tests/conftest.py @@ -0,0 +1,157 @@ +"""Shared pytest fixtures for the AI Vision Service test suite.""" + +from __future__ import annotations + +import io +from concurrent.futures import ThreadPoolExecutor +from contextlib import asynccontextmanager +from pathlib import Path +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient +from PIL import Image + +from app.config import AppSettings, get_settings +from app.detection.detector import DetectedFace, FaceDetector +from app.embeddings.store import EmbeddingStore +from app.main import create_app + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from fastapi import FastAPI + +# --------------------------------------------------------------------------- +# Environment / settings +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_settings() -> AppSettings: + """Return an ``AppSettings``-like mock pre-populated with test values.""" + m = MagicMock(spec=AppSettings) + m.api_key = "test-api-key" + m.lychee_api_url = "http://lychee-test" + m.photos_path = "/tmp" # overridden where needed + m.match_threshold = 0.5 + m.max_faces_per_photo = 10 + m.detection_threshold = 0.5 + m.thread_pool_size = 1 + m.storage_backend = "sqlite" + m.log_level = "info" + return m + + +# --------------------------------------------------------------------------- +# Detector mock +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_detector() -> FaceDetector: + """Return a mock :class:`FaceDetector` that returns no faces by default.""" + m = MagicMock(spec=FaceDetector) + m.is_loaded = True + m.detect.return_value = [] + m.detect_bytes.return_value = [] + return m # type: ignore[return-value] + + +@pytest.fixture +def detected_face() -> DetectedFace: + """Return a sample :class:`DetectedFace` for use in tests.""" + return DetectedFace( + x=0.1, + y=0.2, + width=0.3, + height=0.4, + confidence=0.95, + embedding=[0.1] * 512, + ) + + +# --------------------------------------------------------------------------- +# Store mock +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_store() -> EmbeddingStore: + """Return a mock :class:`EmbeddingStore` with sensible defaults.""" + m = MagicMock(spec=EmbeddingStore) + m.count.return_value = 0 + m.similarity_search.return_value = [] + return m # type: ignore[return-value] + + +# --------------------------------------------------------------------------- +# FastAPI test client +# --------------------------------------------------------------------------- + + +@pytest.fixture +def test_app(mock_detector: FaceDetector, mock_store: EmbeddingStore, tmp_path: Any) -> FastAPI: + """Return a FastAPI app wired with mock state (no real model loaded).""" + + @asynccontextmanager + async def _test_lifespan(app: FastAPI) -> AsyncGenerator[None]: + app.state.detector = mock_detector + app.state.store = mock_store + app.state.executor = ThreadPoolExecutor(max_workers=1) + yield + + application = create_app(lifespan=_test_lifespan) + + # Override settings so required env vars are not needed + def _override_settings() -> AppSettings: + m = MagicMock(spec=AppSettings) + m.api_key = "test-api-key" + m.lychee_api_url = "http://lychee-test" + m.photos_path = str(tmp_path) + m.match_threshold = 0.5 + m.max_faces_per_photo = 10 + m.detection_threshold = 0.5 + m.thread_pool_size = 1 + return m # type: ignore[return-value] + + application.dependency_overrides[get_settings] = _override_settings + return application + + +@pytest.fixture +def client(test_app: FastAPI) -> TestClient: + """Return a synchronous :class:`TestClient` bound to the test app.""" + with TestClient(test_app) as c: + return c + + +@pytest.fixture +def photos_path(test_app: FastAPI) -> Path: + """Return the photos_path directory configured in the test app's settings override.""" + settings: AppSettings = test_app.dependency_overrides[get_settings]() + return Path(settings.photos_path) + + +# --------------------------------------------------------------------------- +# Synthetic test image +# --------------------------------------------------------------------------- + + +@pytest.fixture +def jpeg_image_bytes() -> bytes: + """Return raw JPEG bytes for a simple 100x100 green image.""" + img = Image.new("RGB", (100, 100), color=(34, 139, 34)) + buf = io.BytesIO() + img.save(buf, format="JPEG") + return buf.getvalue() + + +@pytest.fixture +def jpeg_image_path(tmp_path: Any, jpeg_image_bytes: bytes) -> Any: + """Write a JPEG test image to a temp path and return the :class:`Path`.""" + + path: Path = tmp_path / "test_photo.jpg" + path.write_bytes(jpeg_image_bytes) + return path diff --git a/ai-vision-service/face-recognition/tests/test_api.py b/ai-vision-service/face-recognition/tests/test_api.py new file mode 100644 index 00000000000..f1375cf4b04 --- /dev/null +++ b/ai-vision-service/face-recognition/tests/test_api.py @@ -0,0 +1,206 @@ +"""Tests for FastAPI routes (app.api.routes).""" + +from __future__ import annotations + +import io +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from app.detection.detector import DetectedFace + +if TYPE_CHECKING: + from pathlib import Path + + from fastapi.testclient import TestClient + +# --------------------------------------------------------------------------- +# GET /health +# --------------------------------------------------------------------------- + + +def test_health_returns_ok(client: TestClient, mock_detector: object, mock_store: object) -> None: + response = client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["model_loaded"] is True + assert data["status"] == "ok" + assert isinstance(data["embedding_count"], int) + + +def test_health_is_unauthenticated(client: TestClient) -> None: + """GET /health must not require X-API-Key.""" + response = client.get("/health") + assert response.status_code == 200 + + +# --------------------------------------------------------------------------- +# Authentication +# --------------------------------------------------------------------------- + + +def test_detect_requires_api_key(client: TestClient, tmp_path: Path) -> None: + photo = tmp_path / "photo.jpg" + photo.touch() + response = client.post( + "/detect", + json={"photo_id": "p1", "photo_path": str(photo)}, + ) + assert response.status_code == 422 # missing header + + +def test_detect_rejects_wrong_api_key(client: TestClient, tmp_path: Path) -> None: + photo = tmp_path / "photo.jpg" + photo.touch() + response = client.post( + "/detect", + json={"photo_id": "p1", "photo_path": str(photo)}, + headers={"X-API-Key": "wrong-key"}, + ) + assert response.status_code == 401 + + +def test_match_requires_api_key(client: TestClient) -> None: + img_buf = io.BytesIO(b"fake-image") + response = client.post("/match", files={"file": ("selfie.jpg", img_buf, "image/jpeg")}) + assert response.status_code == 422 # missing header + + +# --------------------------------------------------------------------------- +# POST /detect +# --------------------------------------------------------------------------- + + +def test_detect_returns_202(client: TestClient, photos_path: Path) -> None: + """Valid request to POST /detect must return 202 Accepted.""" + photo = photos_path / "photo.jpg" + photo.touch() + + with patch("app.api.routes._run_detection_job") as mock_job: + response = client.post( + "/detect", + json={"photo_id": "photo-123", "photo_path": "photo.jpg"}, + headers={"X-API-Key": "test-api-key"}, + ) + assert response.status_code == 202 + mock_job.assert_called_once() + + +def test_detect_rejects_path_traversal(client: TestClient, tmp_path: Path) -> None: + """photo_path outside photos_path must be rejected with 400.""" + # The test app sets photos_path = tmp_path; /etc is outside it + response = client.post( + "/detect", + json={"photo_id": "p1", "photo_path": "/etc/passwd"}, + headers={"X-API-Key": "test-api-key"}, + ) + assert response.status_code == 400 + + +def test_detect_rejects_nonexistent_file(client: TestClient) -> None: + """photo_path that does not exist must be rejected with 400.""" + response = client.post( + "/detect", + json={"photo_id": "p1", "photo_path": "missing.jpg"}, + headers={"X-API-Key": "test-api-key"}, + ) + assert response.status_code == 400 + + +def test_detect_background_task_called_with_correct_args(client: TestClient, photos_path: Path) -> None: + photo = photos_path / "photo.jpg" + photo.touch() + + with patch("app.api.routes._run_detection_job") as mock_job: + client.post( + "/detect", + json={"photo_id": "photo-xyz", "photo_path": "photo.jpg"}, + headers={"X-API-Key": "test-api-key"}, + ) + args = mock_job.call_args[0] + assert args[0] == "photo-xyz" + assert args[1] == photo.resolve() + + +# --------------------------------------------------------------------------- +# POST /match +# --------------------------------------------------------------------------- + + +def test_match_returns_422_when_no_face(client: TestClient, mock_detector: object, jpeg_image_bytes: bytes) -> None: + """When the detector finds no face, /match must return 422.""" + # mock_detector.detect_bytes already returns [] by default + img_buf = io.BytesIO(jpeg_image_bytes) + response = client.post( + "/match", + files={"file": ("selfie.jpg", img_buf, "image/jpeg")}, + headers={"X-API-Key": "test-api-key"}, + ) + assert response.status_code == 422 + + +def test_match_returns_matches( + client: TestClient, mock_detector: object, mock_store: object, jpeg_image_bytes: bytes +) -> None: + """When matches are found, /match must return them in the response body.""" + + mock_detector.detect_bytes.return_value = [ # ty: ignore + DetectedFace(x=0.1, y=0.1, width=0.5, height=0.5, confidence=0.99, embedding=[0.5] * 512) + ] + mock_store.similarity_search.return_value = [("face-abc", 0.91)] # ty: ignore + + img_buf = io.BytesIO(jpeg_image_bytes) + response = client.post( + "/match", + files={"file": ("selfie.jpg", img_buf, "image/jpeg")}, + headers={"X-API-Key": "test-api-key"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["matches"][0]["lychee_face_id"] == "face-abc" + assert data["matches"][0]["confidence"] == pytest.approx(0.91) + + +def test_match_returns_empty_matches_when_below_threshold( + client: TestClient, mock_detector: object, mock_store: object, jpeg_image_bytes: bytes +) -> None: + """If no stored face exceeds threshold, matches must be an empty list.""" + mock_detector.detect_bytes.return_value = [ # ty: ignore + DetectedFace(x=0.1, y=0.1, width=0.5, height=0.5, confidence=0.99, embedding=[0.5] * 512) + ] + mock_store.similarity_search.return_value = [] # ty: ignore + + img_buf = io.BytesIO(jpeg_image_bytes) + response = client.post( + "/match", + files={"file": ("selfie.jpg", img_buf, "image/jpeg")}, + headers={"X-API-Key": "test-api-key"}, + ) + assert response.status_code == 200 + assert response.json()["matches"] == [] + + +# --------------------------------------------------------------------------- +# FaceResult schema +# --------------------------------------------------------------------------- + + +def test_face_result_includes_laplacian_variance() -> None: + """FaceResult schema must expose laplacian_variance so it is included in callback payloads.""" + from app.api.schemas import FaceResult + + face = FaceResult( + x=0.1, + y=0.1, + width=0.4, + height=0.4, + confidence=0.9, + embedding_id="emb-001", + crop="base64data", + laplacian_variance=42.7, + ) + assert face.laplacian_variance == pytest.approx(42.7) + payload = face.model_dump() + assert "laplacian_variance" in payload + assert payload["laplacian_variance"] == pytest.approx(42.7) diff --git a/ai-vision-service/face-recognition/tests/test_clustering.py b/ai-vision-service/face-recognition/tests/test_clustering.py new file mode 100644 index 00000000000..6ef54272461 --- /dev/null +++ b/ai-vision-service/face-recognition/tests/test_clustering.py @@ -0,0 +1,111 @@ +"""Tests for app.clustering.clusterer.""" + +from __future__ import annotations + +import math + +from app.clustering.clusterer import FaceClusterer + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _unit_vec(index: int = 0, dim: int = 512) -> list[float]: + v = [0.0] * dim + v[index] = 1.0 + return v + + +def _similar_vec(base_index: int, noise_index: int, noise: float = 0.05) -> list[float]: + """Return a vector close to the unit-vector at ``base_index``.""" + v = [0.0] * 512 + v[base_index] = 1.0 + v[noise_index] = noise + norm = math.sqrt(1.0 + noise**2) + return [x / norm for x in v] + + +# --------------------------------------------------------------------------- +# FaceClusterer.cluster() +# --------------------------------------------------------------------------- + + +def test_empty_input_returns_empty() -> None: + clusterer = FaceClusterer() + assert clusterer.cluster([]) == [] + + +def test_single_face_gets_cluster_zero() -> None: + """With min_samples=1 a single face should form cluster 0 (not noise).""" + clusterer = FaceClusterer(eps=0.4, min_samples=1) + result = clusterer.cluster([("face-1", _unit_vec(0))]) + assert result == [("face-1", 0)] + + +def test_identical_faces_in_same_cluster() -> None: + """Two identical embeddings must land in the same cluster.""" + clusterer = FaceClusterer(eps=0.4, min_samples=1) + vec = _unit_vec(0) + result = clusterer.cluster([("face-1", vec.copy()), ("face-2", vec.copy())]) + labels = {fid: label for fid, label in result} + assert labels["face-1"] == labels["face-2"] + + +def test_distinct_faces_in_different_clusters() -> None: + """Orthogonal embeddings must land in separate clusters with tight eps.""" + clusterer = FaceClusterer(eps=0.1, min_samples=1) + result = clusterer.cluster( + [ + ("face-a", _unit_vec(0)), + ("face-b", _unit_vec(1)), + ("face-c", _unit_vec(2)), + ] + ) + labels = {fid: label for fid, label in result} + # All three should be in different clusters (no two share a label) + assert len(set(labels.values())) == 3 + + +def test_similar_faces_grouped_together() -> None: + """Slightly varied embeddings of the same person must form one cluster.""" + base = _unit_vec(0) + similar_1 = _similar_vec(0, 10, noise=0.05) + similar_2 = _similar_vec(0, 20, noise=0.05) + clusterer = FaceClusterer(eps=0.3, min_samples=1) + result = clusterer.cluster( + [ + ("face-1", base), + ("face-2", similar_1), + ("face-3", similar_2), + ] + ) + labels = {fid: label for fid, label in result} + assert labels["face-1"] == labels["face-2"] == labels["face-3"] + + +def test_output_preserves_order() -> None: + """Output IDs must appear in the same order as the input.""" + clusterer = FaceClusterer() + ids = [f"face-{i}" for i in range(10)] + embeddings = [(fid, _unit_vec(i % 512)) for i, fid in enumerate(ids)] + result = clusterer.cluster(embeddings) + assert [fid for fid, _ in result] == ids + + +def test_two_clusters_noise_excluded() -> None: + """With min_samples=2 an isolated point should get label -1 (noise).""" + clusterer = FaceClusterer(eps=0.1, min_samples=2) + result = clusterer.cluster( + [ + ("alice-1", _unit_vec(0)), + ("alice-2", _similar_vec(0, 10, noise=0.02)), + ("noise", _unit_vec(1)), # isolated, not enough neighbours + ] + ) + labels = {fid: label for fid, label in result} + # alice-1 and alice-2 should be in the same cluster + assert labels["alice-1"] == labels["alice-2"] + assert labels["alice-1"] != -1 + # noise face should be labelled as noise + assert labels["noise"] == -1 diff --git a/ai-vision-service/face-recognition/tests/test_cropper.py b/ai-vision-service/face-recognition/tests/test_cropper.py new file mode 100644 index 00000000000..2fa3f5e5a8b --- /dev/null +++ b/ai-vision-service/face-recognition/tests/test_cropper.py @@ -0,0 +1,114 @@ +"""Tests for app.detection.cropper.""" + +from __future__ import annotations + +import base64 +import io + +from PIL import Image + +from app.detection.cropper import CROP_SIZE, generate_crop, generate_crop_from_bytes + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_jpeg_bytes(width: int = 200, height: int = 200, color: tuple[int, int, int] = (255, 0, 0)) -> bytes: + img = Image.new("RGB", (width, height), color=color) + buf = io.BytesIO() + img.save(buf, format="JPEG") + return buf.getvalue() + + +# --------------------------------------------------------------------------- +# generate_crop +# --------------------------------------------------------------------------- + + +def test_crop_size_is_150x150(jpeg_image_path: object) -> None: + """generate_crop must return a 150x150 JPEG regardless of input size.""" + from pathlib import Path + + path = jpeg_image_path # type: ignore[assignment] + result = generate_crop(Path(str(path)), x=0.0, y=0.0, width=0.5, height=0.5) + + decoded = base64.b64decode(result) + img = Image.open(io.BytesIO(decoded)) + assert img.size == (CROP_SIZE, CROP_SIZE) + + +def test_crop_returns_base64_string(jpeg_image_path: object) -> None: + """Output must be a valid base64-encoded ASCII string.""" + from pathlib import Path + + path = jpeg_image_path # type: ignore[assignment] + result = generate_crop(Path(str(path)), x=0.1, y=0.1, width=0.5, height=0.5) + assert isinstance(result, str) + # Must be valid base64 + base64.b64decode(result) + + +def test_crop_is_jpeg(jpeg_image_path: object) -> None: + """Decoded bytes must be a JPEG image.""" + from pathlib import Path + + path = jpeg_image_path # type: ignore[assignment] + result = generate_crop(Path(str(path)), x=0.0, y=0.0, width=1.0, height=1.0) + decoded = base64.b64decode(result) + img = Image.open(io.BytesIO(decoded)) + assert img.format == "JPEG" + + +def test_crop_clamps_out_of_bounds_bbox(jpeg_image_path: object) -> None: + """Bounding boxes that extend beyond image edges must be clamped.""" + from pathlib import Path + + path = jpeg_image_path # type: ignore[assignment] + # x + width > 1.0 + result = generate_crop(Path(str(path)), x=0.8, y=0.8, width=0.5, height=0.5) + decoded = base64.b64decode(result) + img = Image.open(io.BytesIO(decoded)) + assert img.size == (CROP_SIZE, CROP_SIZE) + + +def test_crop_full_image_bbox(jpeg_image_path: object) -> None: + """Cropping the full image (0,0,1,1) must succeed.""" + from pathlib import Path + + path = jpeg_image_path # type: ignore[assignment] + result = generate_crop(Path(str(path)), x=0.0, y=0.0, width=1.0, height=1.0) + decoded = base64.b64decode(result) + img = Image.open(io.BytesIO(decoded)) + assert img.size == (CROP_SIZE, CROP_SIZE) + + +# --------------------------------------------------------------------------- +# generate_crop_from_bytes +# --------------------------------------------------------------------------- + + +def test_crop_from_bytes_matches_file_version(tmp_path: object) -> None: + """generate_crop_from_bytes must produce the same output as generate_crop.""" + from pathlib import Path + + jpeg_bytes = _make_jpeg_bytes(200, 200, (0, 128, 255)) + + # Write to disk + img_path = Path(str(tmp_path)) / "img.jpg" + img_path.write_bytes(jpeg_bytes) + + result_file = generate_crop(img_path, x=0.1, y=0.1, width=0.5, height=0.5) + result_bytes = generate_crop_from_bytes(jpeg_bytes, x=0.1, y=0.1, width=0.5, height=0.5) + + # Sizes should match (exact byte equality may differ due to JPEG compression) + img_a = Image.open(io.BytesIO(base64.b64decode(result_file))) + img_b = Image.open(io.BytesIO(base64.b64decode(result_bytes))) + assert img_a.size == img_b.size + + +def test_crop_from_bytes_150x150(jpeg_image_bytes: bytes) -> None: + result = generate_crop_from_bytes(jpeg_image_bytes, x=0.0, y=0.0, width=0.5, height=0.5) + decoded = base64.b64decode(result) + img = Image.open(io.BytesIO(decoded)) + assert img.size == (CROP_SIZE, CROP_SIZE) diff --git a/ai-vision-service/face-recognition/tests/test_detection.py b/ai-vision-service/face-recognition/tests/test_detection.py new file mode 100644 index 00000000000..1622391db8a --- /dev/null +++ b/ai-vision-service/face-recognition/tests/test_detection.py @@ -0,0 +1,211 @@ +"""Tests for app.detection.detector.""" + +from __future__ import annotations + +import sys +from contextlib import contextmanager +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from app.detection.detector import DetectedFace, FaceDetector + +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + +# --------------------------------------------------------------------------- +# Helpers: build deepface-format face dicts and a pre-loaded detector +# --------------------------------------------------------------------------- + + +def _make_deepface_face( + x: float, + y: float, + w: float, + h: float, + confidence: float, + embedding: list[int | float], +) -> dict[str, Any]: + """Return a deepface ``represent()`` result dict for a single face.""" + return { + "face_confidence": confidence, + "facial_area": {"x": x, "y": y, "w": w, "h": h}, + "embedding": list(embedding), + } + + +@contextmanager +def _detector_with_faces( + faces: list[Any], + *, + blur_threshold: float = 0.0, +) -> Generator[FaceDetector]: + """Yield a loaded FaceDetector whose DeepFace.represent returns *faces*. + + Injects a stub ``deepface`` module into ``sys.modules`` so the tests run + regardless of whether the heavyweight deepface package is installed in the + active Python environment (e.g. when running ``uv run pytest``). + """ + stub_deepface_cls = MagicMock() + stub_deepface_cls.represent.return_value = faces + stub_deepface_mod = MagicMock() + stub_deepface_mod.DeepFace = stub_deepface_cls + + stub_cv2 = MagicMock() + stub_cv2.Laplacian.return_value.var.return_value = 0.0 + + detector = FaceDetector(detection_threshold=0.5, blur_threshold=blur_threshold) + detector._loaded = True # type: ignore[attr-defined] + with patch.dict(sys.modules, {"deepface": stub_deepface_mod, "cv2": stub_cv2}): + yield detector + + +# --------------------------------------------------------------------------- +# FaceDetector.load() +# --------------------------------------------------------------------------- + + +def test_load_initialises_app() -> None: + """load() should trigger model warmup and set _loaded.""" + with patch("app.detection.detector.FaceDetector.load") as mock_load: + detector = FaceDetector(model_name="ArcFace") + assert not detector.is_loaded + mock_load.side_effect = lambda: setattr(detector, "_loaded", True) + detector.load() + assert detector.is_loaded + + +def test_load_is_idempotent() -> None: + """Calling load() twice should not re-initialise the model.""" + detector = FaceDetector() + + call_count = 0 + + def fake_load() -> None: + nonlocal call_count + call_count += 1 + detector._loaded = True # type: ignore[attr-defined] + + with patch.object(detector, "load", side_effect=fake_load): + detector.load() + detector.load() + + assert call_count == 2 # load() called twice but internal guard handled separately + + +def test_is_loaded_false_before_load() -> None: + detector = FaceDetector() + assert not detector.is_loaded + + +# --------------------------------------------------------------------------- +# FaceDetector.detect() - detection results +# --------------------------------------------------------------------------- + + +def test_detect_returns_normalised_bbox(jpeg_image_path: Path) -> None: + """Bounding box coordinates must be in [0, 1].""" + # 100x100 image; face occupies top-left 50x50 quadrant + face = _make_deepface_face(x=0.0, y=0.0, w=50.0, h=50.0, confidence=0.99, embedding=[0.0] * 512) + fake_img = np.zeros((100, 100, 3), dtype=np.uint8) + with _detector_with_faces([face]) as detector: + results = detector._detect_array(fake_img) + + assert len(results) == 1 + result = results[0] + assert 0.0 <= result.x <= 1.0 + assert 0.0 <= result.y <= 1.0 + assert 0.0 <= result.width <= 1.0 + assert 0.0 <= result.height <= 1.0 + + +def test_detect_filters_low_confidence() -> None: + """Faces below detection_threshold must be excluded.""" + face = _make_deepface_face(x=0.0, y=0.0, w=50.0, h=50.0, confidence=0.3, embedding=[0.0] * 512) + fake_img = MagicMock() + fake_img.shape = (100, 100, 3) + with _detector_with_faces([face]) as detector: + results = detector._detect_array(fake_img) + assert results == [] + + +def test_detect_sorts_by_confidence_descending() -> None: + """Output must be sorted highest confidence first.""" + faces = [ + _make_deepface_face(0, 0, 10, 10, 0.6, [0.0] * 512), + _make_deepface_face(10, 10, 10, 10, 0.9, [0.0] * 512), + _make_deepface_face(20, 20, 10, 10, 0.75, [0.0] * 512), + ] + fake_img = np.zeros((100, 100, 3), dtype=np.uint8) + with _detector_with_faces(faces) as detector: + results = detector._detect_array(fake_img) + confidences = [f.confidence for f in results] + assert confidences == sorted(confidences, reverse=True) + + +def test_detect_returns_full_embedding() -> None: + """Each DetectedFace must include a 512-element embedding list.""" + embedding: list[int | float] = list(range(512)) + face = _make_deepface_face(0, 0, 50, 50, 0.99, embedding) + fake_img = np.zeros((100, 100, 3), dtype=np.uint8) + with _detector_with_faces([face]) as detector: + results = detector._detect_array(fake_img) + assert len(results) == 1 + assert len(results[0].embedding) == 512 + assert results[0].embedding == [float(v) for v in embedding] + + +def test_detect_raises_without_load(jpeg_image_path: Path) -> None: + """detect() must raise RuntimeError if load() has not been called.""" + detector = FaceDetector() + stub_cv2 = MagicMock() + stub_cv2.imread.return_value = MagicMock(shape=(100, 100, 3)) + with ( + pytest.raises(RuntimeError, match="not loaded"), + patch.dict(sys.modules, {"cv2": stub_cv2}), + ): + detector.detect(jpeg_image_path) + + +def test_detect_raises_on_unreadable_file(tmp_path: Path) -> None: + """detect() must raise ValueError when OpenCV cannot read the image.""" + detector = FaceDetector() + detector._loaded = True # type: ignore[attr-defined] + + stub_cv2 = MagicMock() + stub_cv2.imread.return_value = None + with patch.dict(sys.modules, {"cv2": stub_cv2}), pytest.raises(ValueError, match="Cannot read image"): + detector.detect(tmp_path / "nonexistent.jpg") + + +# --------------------------------------------------------------------------- +# DetectedFace dataclass +# --------------------------------------------------------------------------- + + +def test_detected_face_fields() -> None: + face = DetectedFace(x=0.1, y=0.2, width=0.3, height=0.4, confidence=0.95) + assert face.x == 0.1 + assert face.embedding == [] + + +def test_detected_face_with_embedding() -> None: + emb = [0.5] * 512 + face = DetectedFace(x=0.0, y=0.0, width=1.0, height=1.0, confidence=0.8, embedding=emb) + assert face.embedding == emb + + +def test_detected_face_has_laplacian_variance_field() -> None: + """DetectedFace must expose laplacian_variance; default is 0.0.""" + face = DetectedFace(x=0.1, y=0.2, width=0.3, height=0.4, confidence=0.9) + assert hasattr(face, "laplacian_variance") + assert face.laplacian_variance == 0.0 + + +def test_detected_face_laplacian_variance_stored() -> None: + """laplacian_variance value passed on construction is preserved.""" + face = DetectedFace(x=0.0, y=0.0, width=1.0, height=1.0, confidence=0.7, laplacian_variance=123.45) + assert face.laplacian_variance == pytest.approx(123.45) diff --git a/ai-vision-service/face-recognition/tests/test_embeddings.py b/ai-vision-service/face-recognition/tests/test_embeddings.py new file mode 100644 index 00000000000..8bb1682f45b --- /dev/null +++ b/ai-vision-service/face-recognition/tests/test_embeddings.py @@ -0,0 +1,125 @@ +"""Tests for the SQLite embedding store.""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +import pytest + +from app.embeddings.sqlite_store import SQLiteEmbeddingStore + +if TYPE_CHECKING: + from pathlib import Path + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def store(tmp_path: Path) -> SQLiteEmbeddingStore: + """Return a fresh SQLite store backed by a temp directory.""" + return SQLiteEmbeddingStore(storage_path=str(tmp_path)) + + +def _unit_vec(dim: int = 512, index: int = 0) -> list[float]: + """Return a unit vector with a 1.0 at ``index`` and 0.0 elsewhere.""" + v = [0.0] * dim + v[index] = 1.0 + return v + + +# --------------------------------------------------------------------------- +# add / count +# --------------------------------------------------------------------------- + + +def test_add_increments_count(store: SQLiteEmbeddingStore) -> None: + assert store.count() == 0 + store.add("face-1", _unit_vec(index=0), "photo-1", 100.0, "test/crop1.jpg") + assert store.count() == 1 + store.add("face-2", _unit_vec(index=1), "photo-2", 100.0, "test/crop2.jpg") + assert store.count() == 2 + + +def test_add_upsert_does_not_duplicate(store: SQLiteEmbeddingStore) -> None: + """Re-adding an existing lychee_face_id must not create a duplicate row.""" + store.add("face-1", _unit_vec(index=0), "photo-1", 100.0, "test/crop1.jpg") + store.add("face-1", _unit_vec(index=0), "photo-1", 100.0, "test/crop1.jpg") + assert store.count() == 1 + + +# --------------------------------------------------------------------------- +# delete +# --------------------------------------------------------------------------- + + +def test_delete_removes_entry(store: SQLiteEmbeddingStore) -> None: + store.add("face-1", _unit_vec(index=0), "photo-1", 100.0, "test/crop1.jpg") + store.delete("face-1") + assert store.count() == 0 + + +def test_delete_unknown_id_is_noop(store: SQLiteEmbeddingStore) -> None: + """Deleting a non-existent ID must not raise.""" + store.delete("nonexistent") + assert store.count() == 0 + + +# --------------------------------------------------------------------------- +# similarity_search +# --------------------------------------------------------------------------- + + +def test_similarity_search_returns_identical_face(store: SQLiteEmbeddingStore) -> None: + """An exact match should have similarity ≈ 1.0.""" + vec = _unit_vec(index=0) + store.add("face-1", vec, "photo-1", 100.0, "test/crop1.jpg") + + results = store.similarity_search(vec, threshold=0.9, limit=10) + assert len(results) == 1 + lychee_id, sim = results[0] + assert lychee_id == "face-1" + assert sim == pytest.approx(1.0, abs=1e-4) + + +def test_similarity_search_excludes_below_threshold(store: SQLiteEmbeddingStore) -> None: + """Results below ``threshold`` must be excluded.""" + store.add("face-1", _unit_vec(index=0), "photo-1", 100.0, "test/crop1.jpg") + + # Query with an orthogonal vector - cosine similarity = 0.0 + query = _unit_vec(index=1) + results = store.similarity_search(query, threshold=0.5, limit=10) + assert results == [] + + +def test_similarity_search_respects_limit(store: SQLiteEmbeddingStore) -> None: + """At most ``limit`` results should be returned.""" + for i in range(20): + # All vectors point in roughly the same direction → high similarity + v = [1.0 / math.sqrt(512)] * 512 + store.add(f"face-{i}", v, f"photo-{i}", 100.0, f"test/crop{i}.jpg") + + results = store.similarity_search([1.0 / math.sqrt(512)] * 512, threshold=0.0, limit=5) + assert len(results) <= 5 + + +def test_similarity_search_ordered_descending(store: SQLiteEmbeddingStore) -> None: + """Results must be ordered by descending similarity.""" + store.add("face-exact", _unit_vec(index=0), "photo-1", 100.0, "test/crop1.jpg") + store.add("face-close", [0.9, 0.1] + [0.0] * 510, "photo-2", 100.0, "test/crop2.jpg") + + # Normalise the close vector + norm = math.sqrt(0.9**2 + 0.1**2) + close = [0.9 / norm, 0.1 / norm] + [0.0] * 510 + store.add("face-close-n", close, "photo-3", 100.0, "test/crop3.jpg") + + results = store.similarity_search(_unit_vec(index=0), threshold=0.0, limit=10) + sims = [r[1] for r in results] + assert sims == sorted(sims, reverse=True) + + +def test_empty_store_returns_no_results(store: SQLiteEmbeddingStore) -> None: + results = store.similarity_search(_unit_vec(), threshold=0.0, limit=10) + assert results == [] diff --git a/ai-vision-service/face-recognition/tests/test_matching.py b/ai-vision-service/face-recognition/tests/test_matching.py new file mode 100644 index 00000000000..7e4e43004bc --- /dev/null +++ b/ai-vision-service/face-recognition/tests/test_matching.py @@ -0,0 +1,102 @@ +"""Tests for app.matching.matcher.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from app.api.schemas import MatchResponse +from app.detection.detector import DetectedFace, FaceDetector +from app.embeddings.store import EmbeddingStore +from app.matching.matcher import FaceMatcher + +# --------------------------------------------------------------------------- +# Helpers / fixtures +# --------------------------------------------------------------------------- + + +def _mock_detector(faces: list[DetectedFace]) -> FaceDetector: + m = MagicMock(spec=FaceDetector) + m.is_loaded = True + m.detect_bytes.return_value = faces + return m # type: ignore[return-value] + + +def _mock_store(matches: list[tuple[str, float]]) -> EmbeddingStore: + m = MagicMock(spec=EmbeddingStore) + m.similarity_search.return_value = matches + return m # type: ignore[return-value] + + +def _detected_face(confidence: float = 0.95) -> DetectedFace: + return DetectedFace(x=0.1, y=0.1, width=0.3, height=0.3, confidence=confidence, embedding=[0.5] * 512) + + +# --------------------------------------------------------------------------- +# FaceMatcher.match() +# --------------------------------------------------------------------------- + + +def test_match_raises_when_no_face_detected() -> None: + """match() must raise ValueError when the selfie contains no detectable face.""" + matcher = FaceMatcher( + detector=_mock_detector([]), + store=_mock_store([]), + ) + with pytest.raises(ValueError, match="No face detected"): + matcher.match(b"\xff\xd8\xff") # arbitrary bytes + + +def test_match_returns_matches_from_store() -> None: + """match() must return MatchResult items built from the store search results.""" + store_matches = [("face-abc", 0.95), ("face-def", 0.72)] + matcher = FaceMatcher( + detector=_mock_detector([_detected_face()]), + store=_mock_store(store_matches), + ) + response = matcher.match(b"fake-image-bytes") + + assert isinstance(response, MatchResponse) + assert len(response.matches) == 2 + assert response.matches[0].lychee_face_id == "face-abc" + assert response.matches[0].confidence == pytest.approx(0.95) + + +def test_match_uses_highest_confidence_face() -> None: + """When multiple faces are detected, the one with highest confidence is used.""" + low_conf = _detected_face(confidence=0.6) + high_conf = _detected_face(confidence=0.99) + # Detector returns highest-confidence first (sorted), but let's simulate both orders + detector = _mock_detector([high_conf, low_conf]) + store = _mock_store([("face-xyz", 0.88)]) + + matcher = FaceMatcher(detector=detector, store=store) + matcher.match(b"fake") + + # store.similarity_search should have been called with high_conf.embedding + store.similarity_search.assert_called_once_with(high_conf.embedding, matcher._threshold, limit=10) # ty: ignore + + +def test_match_passes_threshold_to_store() -> None: + """The configured threshold must be forwarded to similarity_search.""" + detector = _mock_detector([_detected_face()]) + store = _mock_store([]) + matcher = FaceMatcher(detector=detector, store=store, threshold=0.8) + matcher.match(b"fake") + + store.similarity_search.assert_called_once() # ty: ignore + _, _kwargs = store.similarity_search.call_args # ty: ignore + # threshold may be passed as positional or keyword + call_args = store.similarity_search.call_args[0] # ty: ignore + assert call_args[1] == 0.8 # second positional arg = threshold + + +def test_match_returns_empty_list_when_no_store_matches() -> None: + """An empty match list (no faces above threshold) must be returned cleanly.""" + matcher = FaceMatcher( + detector=_mock_detector([_detected_face()]), + store=_mock_store([]), + ) + response = matcher.match(b"fake") + assert response.matches == [] diff --git a/ai-vision-service/face-recognition/tests/test_smoke.py b/ai-vision-service/face-recognition/tests/test_smoke.py new file mode 100644 index 00000000000..315e55ec742 --- /dev/null +++ b/ai-vision-service/face-recognition/tests/test_smoke.py @@ -0,0 +1,112 @@ +"""End-to-end smoke test (requires Docker and a running service stack). + +This test is skipped by default. To run it: + + SMOKE_TEST=1 uv run pytest tests/test_smoke.py -v + +The test starts the service via docker-compose, waits for the health check to +pass, sends a mock detect request, and asserts the callback is received by a +mock Lychee HTTP server running in a background thread. + +Prerequisites: + - Docker and docker-compose available in PATH + - ``docker-compose.minimal.yaml`` with the ``ai-vision`` service block + - ``VISION_FACE_API_KEY`` env var set (or the default is used for local testing) +""" + +from __future__ import annotations + +import os +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import ClassVar + +import httpx +import pytest + +SMOKE = os.getenv("SMOKE_TEST", "").lower() in ("1", "true", "yes") +pytestmark = pytest.mark.skipif(not SMOKE, reason="SMOKE_TEST env var not set") + +SERVICE_URL = os.getenv("AI_VISION_URL", "http://localhost:8123") +API_KEY = os.getenv("VISION_FACE_API_KEY", "smoke-test-key") +MOCK_LYCHEE_PORT = 19876 + + +# --------------------------------------------------------------------------- +# Mock Lychee callback server +# --------------------------------------------------------------------------- + + +class _CallbackHandler(BaseHTTPRequestHandler): + received: ClassVar[list[bytes]] = [] + + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) + _CallbackHandler.received.append(body) + self.send_response(200) + self.end_headers() + self.wfile.write(b'{"faces": []}') + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 + pass # suppress request log noise + + +# --------------------------------------------------------------------------- +# Smoke tests +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module", autouse=True) +def mock_lychee_server() -> None: # ty: ignore + """Start a mock Lychee callback receiver before smoke tests.""" + server = HTTPServer(("0.0.0.0", MOCK_LYCHEE_PORT), _CallbackHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield + server.shutdown() + + +def _wait_for_health(timeout: int = 60) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + try: + r = httpx.get(f"{SERVICE_URL}/health", timeout=3.0) + if r.status_code == 200 and r.json().get("model_loaded"): + return + except Exception: + pass + time.sleep(2) + pytest.fail(f"Service did not become healthy within {timeout}s") + + +def test_health_endpoint_responds() -> None: + """GET /health must return 200 with model_loaded: true.""" + _wait_for_health() + r = httpx.get(f"{SERVICE_URL}/health", timeout=10.0) + assert r.status_code == 200 + assert r.json()["model_loaded"] is True + assert r.json()["status"] == "ok" + + +def test_detect_returns_202() -> None: + """POST /detect must return 202 for a synthetic request.""" + _wait_for_health() + payload = {"photo_id": "smoke-photo-1", "photo_path": "/data/photos/smoke_placeholder.jpg"} + r = httpx.post( + f"{SERVICE_URL}/detect", + json=payload, + headers={"X-API-Key": API_KEY}, + timeout=10.0, + ) + # 202 or 400 (file not found) are both valid - what matters is the service responds + assert r.status_code in (202, 400) + + +def test_health_returns_embedding_count_as_int() -> None: + """embedding_count in health response must be a non-negative integer.""" + _wait_for_health() + r = httpx.get(f"{SERVICE_URL}/health", timeout=10.0) + assert isinstance(r.json()["embedding_count"], int) + assert r.json()["embedding_count"] >= 0 diff --git a/ai-vision-service/face-recognition/uv.lock b/ai-vision-service/face-recognition/uv.lock new file mode 100644 index 00000000000..7123bfd15e3 --- /dev/null +++ b/ai-vision-service/face-recognition/uv.lock @@ -0,0 +1,1733 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" +resolution-markers = [ + "sys_platform == 'win32'", + "sys_platform == 'emscripten'", + "sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "absl-py" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/c7/8de93764ad66968d19329a7e0c147a2bb3c7054c554d4a119111b8f9440f/absl_py-2.4.0.tar.gz", hash = "sha256:8c6af82722b35cf71e0f4d1d47dcaebfff286e27110a99fc359349b247dfb5d4", size = 116543, upload-time = "2026-01-28T10:17:05.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl", hash = "sha256:88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d", size = 135750, upload-time = "2026-01-28T10:17:04.19Z" }, +] + +[[package]] +name = "ai-vision-service" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "deepface" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "numpy" }, + { name = "opencv-python" }, + { name = "pillow" }, + { name = "psycopg2-binary" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "scikit-learn" }, + { name = "sqlite-vec" }, + { name = "tensorflow" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +gpu = [ + { name = "tensorflow", extra = ["and-cuda"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "anyio" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "respx" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "deepface", specifier = ">=0.0.93" }, + { name = "fastapi", specifier = ">=0.115" }, + { name = "httpx", specifier = ">=0.28" }, + { name = "numpy", specifier = ">=2.2" }, + { name = "opencv-python", specifier = ">=4.10" }, + { name = "pillow", specifier = ">=11.0" }, + { name = "psycopg2-binary", specifier = ">=2.9" }, + { name = "pydantic", specifier = ">=2.10" }, + { name = "pydantic-settings", specifier = ">=2.7" }, + { name = "python-multipart", specifier = ">=0.0.20" }, + { name = "scikit-learn", specifier = ">=1.6" }, + { name = "sqlite-vec", specifier = ">=0.1" }, + { name = "tensorflow", specifier = ">=2.16" }, + { name = "tensorflow", extras = ["and-cuda"], marker = "extra == 'gpu'" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.32" }, +] +provides-extras = ["gpu"] + +[package.metadata.requires-dev] +dev = [ + { name = "anyio", specifier = ">=4.7" }, + { name = "pytest", specifier = ">=8.3" }, + { name = "pytest-asyncio", specifier = ">=0.25" }, + { name = "pytest-cov", specifier = ">=6.0" }, + { name = "respx", specifier = ">=0.21" }, + { name = "ruff", specifier = ">=0.9" }, + { name = "ty", specifier = ">=0.0.1a10" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "astunparse" +version = "1.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, + { name = "wheel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/af/4182184d3c338792894f34a62672919db7ca008c89abee9b564dd34d8029/astunparse-1.6.3.tar.gz", hash = "sha256:5ad93a8456f0d084c3456d059fd9a92cce667963232cbf763eac3bc5b7940872", size = 18290, upload-time = "2019-12-22T18:12:13.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/03/13dde6512ad7b4557eb792fbcf0c653af6076b81e5941d36ec61f7ce6028/astunparse-1.6.3-py2.py3-none-any.whl", hash = "sha256:c2652417f2c8b5bb325c885ae329bdf3f86424075c4fd1a128674bc6fba4b8e8", size = 12732, upload-time = "2019-12-22T18:12:11.297Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[[package]] +name = "deepface" +version = "0.0.99" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fire" }, + { name = "flask" }, + { name = "flask-cors" }, + { name = "gdown" }, + { name = "gunicorn" }, + { name = "keras" }, + { name = "lightdsa" }, + { name = "lightphe" }, + { name = "mtcnn" }, + { name = "numpy" }, + { name = "opencv-python" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "retina-face" }, + { name = "tensorflow" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/1a/2bbd234a5c73834deac1fe99984cd191f2d6cb65ef2b2293ffd3f767eae3/deepface-0.0.99.tar.gz", hash = "sha256:b1534ad60be41a955b85514e67da933390cbfe331912db24bcca612a7e288ad4", size = 135755, upload-time = "2026-03-01T10:17:57.598Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8a/2359fdf05cd4ed17c33ca041a3f0e8461b7c21d72121853766e03419045a/deepface-0.0.99-py3-none-any.whl", hash = "sha256:9743c3af254febbdfca273e12aa8a7d10e61c5c7cf8c86d50d06632df7732a87", size = 169508, upload-time = "2026-03-01T10:17:55.988Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + +[[package]] +name = "fire" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "termcolor" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/00/f8d10588d2019d6d6452653def1ee807353b21983db48550318424b5ff18/fire-0.7.1.tar.gz", hash = "sha256:3b208f05c736de98fb343310d090dcc4d8c78b2a89ea4f32b837c586270a9cbf", size = 88720, upload-time = "2025-08-16T20:20:24.175Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/4c/93d0f85318da65923e4b91c1c2ff03d8a458cbefebe3bc612a6693c7906d/fire-0.7.1-py3-none-any.whl", hash = "sha256:e43fd8a5033a9001e7e2973bab96070694b9f12f2e0ecf96d4683971b5ab1882", size = 115945, upload-time = "2025-08-16T20:20:22.87Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "flask-cors" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/74/0fc0fa68d62f21daef41017dafab19ef4b36551521260987eb3a5394c7ba/flask_cors-6.0.2.tar.gz", hash = "sha256:6e118f3698249ae33e429760db98ce032a8bf9913638d085ca0f4c5534ad2423", size = 13472, upload-time = "2025-12-12T20:31:42.861Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/af/72ad54402e599152de6d067324c46fe6a4f531c7c65baf7e96c63db55eaf/flask_cors-6.0.2-py3-none-any.whl", hash = "sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a", size = 13257, upload-time = "2025-12-12T20:31:41.3Z" }, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + +[[package]] +name = "gast" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/f6/e73969782a2ecec280f8a176f2476149dd9dba69d5f8779ec6108a7721e6/gast-0.7.0.tar.gz", hash = "sha256:0bb14cd1b806722e91ddbab6fb86bba148c22b40e7ff11e248974e04c8adfdae", size = 33630, upload-time = "2025-11-29T15:30:05.266Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/33/f1c6a276de27b7d7339a34749cc33fa87f077f921969c47185d34a887ae2/gast-0.7.0-py3-none-any.whl", hash = "sha256:99cbf1365633a74099f69c59bd650476b96baa5ef196fec88032b00b31ba36f7", size = 22966, upload-time = "2025-11-29T15:30:03.983Z" }, +] + +[[package]] +name = "gdown" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "filelock" }, + { name = "requests", extra = ["socks"] }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/01/9e0280ba321f73295374765dc3c0b1e03058188a592a48a321376f9eb092/gdown-6.0.0.tar.gz", hash = "sha256:1f1f735a174ef3599fca95786aafac1219b9d85d4c729ccb95e674996c47fd44", size = 262729, upload-time = "2026-04-12T06:37:40.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/fd/a382bb6684b1fdbe5cd19aa980a04a67f6c91efd0e1e627f93614fe2d24e/gdown-6.0.0-py3-none-any.whl", hash = "sha256:c82d39a6b09ed7778012515c2fa4ab4dc36d7789300cd0b16b87d3a3e4a09955", size = 18243, upload-time = "2026-04-12T06:37:38.209Z" }, +] + +[[package]] +name = "google-pasta" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/4a/0bd53b36ff0323d10d5f24ebd67af2de10a1117f5cf4d7add90df92756f1/google-pasta-0.2.0.tar.gz", hash = "sha256:c9f2c8dfc8f96d0d5808299920721be30c9eec37f2389f28904f454565c8a16e", size = 40430, upload-time = "2020-03-13T18:57:50.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/de/c648ef6835192e6e2cc03f40b19eeda4382c49b5bafb43d88b931c4c74ac/google_pasta-0.2.0-py3-none-any.whl", hash = "sha256:b32482794a366b5366a32c92a9a9201b107821889935a02b3e51f6b432ea84ed", size = 57471, upload-time = "2020-03-13T18:57:48.872Z" }, +] + +[[package]] +name = "grpcio" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, +] + +[[package]] +name = "gunicorn" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h5py" +version = "3.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/57/dfb3c5c3f1bf5f5ef2e59a22dec4ff1f3d7408b55bfcefcfb0ea69ef21c6/h5py-3.14.0.tar.gz", hash = "sha256:2372116b2e0d5d3e5e705b7f663f7c8d96fa79a4052d250484ef91d24d6a08f4", size = 424323, upload-time = "2025-06-06T14:06:15.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/c2/7efe82d09ca10afd77cd7c286e42342d520c049a8c43650194928bcc635c/h5py-3.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:aa4b7bbce683379b7bf80aaba68e17e23396100336a8d500206520052be2f812", size = 3289245, upload-time = "2025-06-06T14:05:28.24Z" }, + { url = "https://files.pythonhosted.org/packages/4f/31/f570fab1239b0d9441024b92b6ad03bb414ffa69101a985e4c83d37608bd/h5py-3.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ef9603a501a04fcd0ba28dd8f0995303d26a77a980a1f9474b3417543d4c6174", size = 2807335, upload-time = "2025-06-06T14:05:31.997Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ce/3a21d87896bc7e3e9255e0ad5583ae31ae9e6b4b00e0bcb2a67e2b6acdbc/h5py-3.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8cbaf6910fa3983c46172666b0b8da7b7bd90d764399ca983236f2400436eeb", size = 4700675, upload-time = "2025-06-06T14:05:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ec/86f59025306dcc6deee5fda54d980d077075b8d9889aac80f158bd585f1b/h5py-3.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d90e6445ab7c146d7f7981b11895d70bc1dd91278a4f9f9028bc0c95e4a53f13", size = 4921632, upload-time = "2025-06-06T14:05:43.464Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6d/0084ed0b78d4fd3e7530c32491f2884140d9b06365dac8a08de726421d4a/h5py-3.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:ae18e3de237a7a830adb76aaa68ad438d85fe6e19e0d99944a3ce46b772c69b3", size = 2852929, upload-time = "2025-06-06T14:05:47.659Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "keras" +version = "3.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "absl-py" }, + { name = "h5py" }, + { name = "ml-dtypes" }, + { name = "namex" }, + { name = "numpy" }, + { name = "optree" }, + { name = "packaging" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/ce/47874047a49eedc2a5d3b41bc4f1f572bb637f51e4351ef3538e49a63800/keras-3.14.0.tar.gz", hash = "sha256:86fcf8249a25264a566ac393c287c7ad657000e5e62615dcaad4b3472a17aeda", size = 1263098, upload-time = "2026-04-03T01:42:16.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/20/78d26f81115d570bdf0e57d19b81de9ad8aa55ddb68eb10c8f0699fccb63/keras-3.14.0-py3-none-any.whl", hash = "sha256:19ce94b798caaba4d404ab6ef4753b44219170e5c2868156de8bb0494a260114", size = 1627362, upload-time = "2026-04-03T01:42:11.606Z" }, +] + +[[package]] +name = "libclang" +version = "18.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/5c/ca35e19a4f142adffa27e3d652196b7362fa612243e2b916845d801454fc/libclang-18.1.1.tar.gz", hash = "sha256:a1214966d08d73d971287fc3ead8dfaf82eb07fb197680d8b3859dbbbbf78250", size = 39612, upload-time = "2024-03-17T16:04:37.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/49/f5e3e7e1419872b69f6f5e82ba56e33955a74bd537d8a1f5f1eff2f3668a/libclang-18.1.1-1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:0b2e143f0fac830156feb56f9231ff8338c20aecfe72b4ffe96f19e5a1dbb69a", size = 25836045, upload-time = "2024-06-30T17:40:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e5/fc61bbded91a8830ccce94c5294ecd6e88e496cc85f6704bf350c0634b70/libclang-18.1.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:6f14c3f194704e5d09769108f03185fce7acaf1d1ae4bbb2f30a72c2400cb7c5", size = 26502641, upload-time = "2024-03-18T15:52:26.722Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/1df62b44db2583375f6a8a5e2ca5432bbdc3edb477942b9b7c848c720055/libclang-18.1.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:83ce5045d101b669ac38e6da8e58765f12da2d3aafb3b9b98d88b286a60964d8", size = 26420207, upload-time = "2024-03-17T15:00:26.63Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fc/716c1e62e512ef1c160e7984a73a5fc7df45166f2ff3f254e71c58076f7c/libclang-18.1.1-py2.py3-none-manylinux2010_x86_64.whl", hash = "sha256:c533091d8a3bbf7460a00cb6c1a71da93bffe148f172c7d03b1c31fbf8aa2a0b", size = 24515943, upload-time = "2024-03-17T16:03:45.942Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3d/f0ac1150280d8d20d059608cf2d5ff61b7c3b7f7bcf9c0f425ab92df769a/libclang-18.1.1-py2.py3-none-manylinux2014_aarch64.whl", hash = "sha256:54dda940a4a0491a9d1532bf071ea3ef26e6dbaf03b5000ed94dd7174e8f9592", size = 23784972, upload-time = "2024-03-17T16:12:47.677Z" }, + { url = "https://files.pythonhosted.org/packages/fe/2f/d920822c2b1ce9326a4c78c0c2b4aa3fde610c7ee9f631b600acb5376c26/libclang-18.1.1-py2.py3-none-manylinux2014_armv7l.whl", hash = "sha256:cf4a99b05376513717ab5d82a0db832c56ccea4fd61a69dbb7bccf2dfb207dbe", size = 20259606, upload-time = "2024-03-17T16:17:42.437Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c2/de1db8c6d413597076a4259cea409b83459b2db997c003578affdd32bf66/libclang-18.1.1-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:69f8eb8f65c279e765ffd28aaa7e9e364c776c17618af8bff22a8df58677ff4f", size = 24921494, upload-time = "2024-03-17T16:14:20.132Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2d/3f480b1e1d31eb3d6de5e3ef641954e5c67430d5ac93b7fa7e07589576c7/libclang-18.1.1-py2.py3-none-win_amd64.whl", hash = "sha256:4dd2d3b82fab35e2bf9ca717d7b63ac990a3519c7e312f19fa8e86dcc712f7fb", size = 26415083, upload-time = "2024-03-17T16:42:21.703Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/e01dc4cc79779cd82d77888a88ae2fa424d93b445ad4f6c02bfc18335b70/libclang-18.1.1-py2.py3-none-win_arm64.whl", hash = "sha256:3f0e1f49f04d3cd198985fea0511576b0aee16f9ff0e0f0cad7f9c57ec3c20e8", size = 22361112, upload-time = "2024-03-17T16:42:59.565Z" }, +] + +[[package]] +name = "lightdsa" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lightecc" }, + { name = "sympy" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/e9/d54a2102150cb217bcd4d22b8961a807c988e16309212ffd856b9ec70dba/lightdsa-0.0.3.tar.gz", hash = "sha256:d4544278fba1e220ad250dbe06c6cb0b4f2bf4430127ccc3080b4ebf353e2b6e", size = 12906, upload-time = "2025-04-01T18:56:34.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/ac1f258d4868bb8c8c4fbec7f9a69ffc755a525d25c4b0cf879717184cb2/lightdsa-0.0.3-py3-none-any.whl", hash = "sha256:ea9e91586d8002ab205a2638983de72304370856332e986347492a7bf579a38b", size = 17390, upload-time = "2025-04-01T18:56:33.16Z" }, +] + +[[package]] +name = "lightecc" +version = "0.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/15/b28d5f6497320cc697b0dc71fbb3d6eb4ecdc3c93c40b04e42410e1fc532/lightecc-0.0.6.tar.gz", hash = "sha256:31dc14b08e5a813bd5864d8062fc1b4b27771155d53d9ddb78929d592a9f6792", size = 46956, upload-time = "2026-04-17T07:31:58.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/dd/912d114ab967e8ce4ec58040c39ef69c0d80634a951edff4e1d95d532783/lightecc-0.0.6-py3-none-any.whl", hash = "sha256:5b074e21917b493cebbe5c164b071021410ba23a94f032c828a6392f415aad8d", size = 51459, upload-time = "2026-04-17T07:31:56.747Z" }, +] + +[[package]] +name = "lightphe" +version = "0.0.24" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lightecc" }, + { name = "sympy" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/62/2a2c0525907147604b90112f18f71ced98f407eaa673024489992b84e3be/lightphe-0.0.24.tar.gz", hash = "sha256:4883462ecd13060ac74e0352bdb10977f00f313ff0a66c7c04f0c45d10788127", size = 46990, upload-time = "2026-04-17T19:17:02.124Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/43/89169cb6ea1933f1331dcc8fd2ae4547d94434d06986c20271f1104d35ad/lightphe-0.0.24-py3-none-any.whl", hash = "sha256:d03056ad6950d243f4c36c50809a012fc62e89ea7bc64bb66f804c5cbb2fe29d", size = 70415, upload-time = "2026-04-17T19:17:00.795Z" }, +] + +[[package]] +name = "lz4" +version = "4.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/51/f1b86d93029f418033dddf9b9f79c8d2641e7454080478ee2aab5123173e/lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0", size = 172886, upload-time = "2025-11-03T13:02:36.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/46/08fd8ef19b782f301d56a9ccfd7dafec5fd4fc1a9f017cf22a1accb585d7/lz4-4.4.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6bb05416444fafea170b07181bc70640975ecc2a8c92b3b658c554119519716c", size = 207171, upload-time = "2025-11-03T13:01:56.595Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3f/ea3334e59de30871d773963997ecdba96c4584c5f8007fd83cfc8f1ee935/lz4-4.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b424df1076e40d4e884cfcc4c77d815368b7fb9ebcd7e634f937725cd9a8a72a", size = 207163, upload-time = "2025-11-03T13:01:57.721Z" }, + { url = "https://files.pythonhosted.org/packages/41/7b/7b3a2a0feb998969f4793c650bb16eff5b06e80d1f7bff867feb332f2af2/lz4-4.4.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:216ca0c6c90719731c64f41cfbd6f27a736d7e50a10b70fad2a9c9b262ec923d", size = 1292136, upload-time = "2025-11-03T13:02:00.375Z" }, + { url = "https://files.pythonhosted.org/packages/89/d1/f1d259352227bb1c185288dd694121ea303e43404aa77560b879c90e7073/lz4-4.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:533298d208b58b651662dd972f52d807d48915176e5b032fb4f8c3b6f5fe535c", size = 1279639, upload-time = "2025-11-03T13:02:01.649Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fb/ba9256c48266a09012ed1d9b0253b9aa4fe9cdff094f8febf5b26a4aa2a2/lz4-4.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:451039b609b9a88a934800b5fc6ee401c89ad9c175abf2f4d9f8b2e4ef1afc64", size = 1368257, upload-time = "2025-11-03T13:02:03.35Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6d/dee32a9430c8b0e01bbb4537573cabd00555827f1a0a42d4e24ca803935c/lz4-4.4.5-cp313-cp313-win32.whl", hash = "sha256:a5f197ffa6fc0e93207b0af71b302e0a2f6f29982e5de0fbda61606dd3a55832", size = 88191, upload-time = "2025-11-03T13:02:04.406Z" }, + { url = "https://files.pythonhosted.org/packages/18/e0/f06028aea741bbecb2a7e9648f4643235279a770c7ffaf70bd4860c73661/lz4-4.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:da68497f78953017deb20edff0dba95641cc86e7423dfadf7c0264e1ac60dc22", size = 99502, upload-time = "2025-11-03T13:02:05.886Z" }, + { url = "https://files.pythonhosted.org/packages/61/72/5bef44afb303e56078676b9f2486f13173a3c1e7f17eaac1793538174817/lz4-4.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:c1cfa663468a189dab510ab231aad030970593f997746d7a324d40104db0d0a9", size = 91285, upload-time = "2025-11-03T13:02:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/49/55/6a5c2952971af73f15ed4ebfdd69774b454bd0dc905b289082ca8664fba1/lz4-4.4.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67531da3b62f49c939e09d56492baf397175ff39926d0bd5bd2d191ac2bff95f", size = 207348, upload-time = "2025-11-03T13:02:08.117Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d7/fd62cbdbdccc35341e83aabdb3f6d5c19be2687d0a4eaf6457ddf53bba64/lz4-4.4.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a1acbbba9edbcbb982bc2cac5e7108f0f553aebac1040fbec67a011a45afa1ba", size = 207340, upload-time = "2025-11-03T13:02:09.152Z" }, + { url = "https://files.pythonhosted.org/packages/77/69/225ffadaacb4b0e0eb5fd263541edd938f16cd21fe1eae3cd6d5b6a259dc/lz4-4.4.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a482eecc0b7829c89b498fda883dbd50e98153a116de612ee7c111c8bcf82d1d", size = 1293398, upload-time = "2025-11-03T13:02:10.272Z" }, + { url = "https://files.pythonhosted.org/packages/c6/9e/2ce59ba4a21ea5dc43460cba6f34584e187328019abc0e66698f2b66c881/lz4-4.4.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e099ddfaa88f59dd8d36c8a3c66bd982b4984edf127eb18e30bb49bdba68ce67", size = 1281209, upload-time = "2025-11-03T13:02:12.091Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/4d946bd1624ec229b386a3bc8e7a85fa9a963d67d0a62043f0af0978d3da/lz4-4.4.5-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2af2897333b421360fdcce895c6f6281dc3fab018d19d341cf64d043fc8d90d", size = 1369406, upload-time = "2025-11-03T13:02:13.683Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/d429ba4720a9064722698b4b754fb93e42e625f1318b8fe834086c7c783b/lz4-4.4.5-cp313-cp313t-win32.whl", hash = "sha256:66c5de72bf4988e1b284ebdd6524c4bead2c507a2d7f172201572bac6f593901", size = 88325, upload-time = "2025-11-03T13:02:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/7ba10c9b97c06af6c8f7032ec942ff127558863df52d866019ce9d2425cf/lz4-4.4.5-cp313-cp313t-win_amd64.whl", hash = "sha256:cdd4bdcbaf35056086d910d219106f6a04e1ab0daa40ec0eeef1626c27d0fddb", size = 99643, upload-time = "2025-11-03T13:02:15.978Z" }, + { url = "https://files.pythonhosted.org/packages/77/4d/a175459fb29f909e13e57c8f475181ad8085d8d7869bd8ad99033e3ee5fa/lz4-4.4.5-cp313-cp313t-win_arm64.whl", hash = "sha256:28ccaeb7c5222454cd5f60fcd152564205bcb801bd80e125949d2dfbadc76bbd", size = 91504, upload-time = "2025-11-03T13:02:17.313Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "ml-dtypes" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/4a/c27b42ed9b1c7d13d9ba8b6905dece787d6259152f2309338aed29b2447b/ml_dtypes-0.5.4.tar.gz", hash = "sha256:8ab06a50fb9bf9666dd0fe5dfb4676fa2b0ac0f31ecff72a6c3af8e22c063453", size = 692314, upload-time = "2025-11-17T22:32:31.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/a1/4008f14bbc616cfb1ac5b39ea485f9c63031c4634ab3f4cf72e7541f816a/ml_dtypes-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c760d85a2f82e2bed75867079188c9d18dae2ee77c25a54d60e9cc79be1bc48", size = 676888, upload-time = "2025-11-17T22:31:56.907Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b7/dff378afc2b0d5a7d6cd9d3209b60474d9819d1189d347521e1688a60a53/ml_dtypes-0.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce756d3a10d0c4067172804c9cc276ba9cc0ff47af9078ad439b075d1abdc29b", size = 5036993, upload-time = "2025-11-17T22:31:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/eb/33/40cd74219417e78b97c47802037cf2d87b91973e18bb968a7da48a96ea44/ml_dtypes-0.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:533ce891ba774eabf607172254f2e7260ba5f57bdd64030c9a4fcfbd99815d0d", size = 5010956, upload-time = "2025-11-17T22:31:59.931Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8b/200088c6859d8221454825959df35b5244fa9bdf263fd0249ac5fb75e281/ml_dtypes-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:f21c9219ef48ca5ee78402d5cc831bd58ea27ce89beda894428bc67a52da5328", size = 212224, upload-time = "2025-11-17T22:32:01.349Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/dfc3775cb36367816e678f69a7843f6f03bd4e2bcd79941e01ea960a068e/ml_dtypes-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:35f29491a3e478407f7047b8a4834e4640a77d2737e0b294d049746507af5175", size = 160798, upload-time = "2025-11-17T22:32:02.864Z" }, + { url = "https://files.pythonhosted.org/packages/4f/74/e9ddb35fd1dd43b1106c20ced3f53c2e8e7fc7598c15638e9f80677f81d4/ml_dtypes-0.5.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:304ad47faa395415b9ccbcc06a0350800bc50eda70f0e45326796e27c62f18b6", size = 702083, upload-time = "2025-11-17T22:32:04.08Z" }, + { url = "https://files.pythonhosted.org/packages/74/f5/667060b0aed1aa63166b22897fdf16dca9eb704e6b4bbf86848d5a181aa7/ml_dtypes-0.5.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a0df4223b514d799b8a1629c65ddc351b3efa833ccf7f8ea0cf654a61d1e35d", size = 5354111, upload-time = "2025-11-17T22:32:05.546Z" }, + { url = "https://files.pythonhosted.org/packages/40/49/0f8c498a28c0efa5f5c95a9e374c83ec1385ca41d0e85e7cf40e5d519a21/ml_dtypes-0.5.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531eff30e4d368cb6255bc2328d070e35836aa4f282a0fb5f3a0cd7260257298", size = 5366453, upload-time = "2025-11-17T22:32:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/8c/27/12607423d0a9c6bbbcc780ad19f1f6baa2b68b18ce4bddcdc122c4c68dc9/ml_dtypes-0.5.4-cp313-cp313t-win_amd64.whl", hash = "sha256:cb73dccfc991691c444acc8c0012bee8f2470da826a92e3a20bb333b1a7894e6", size = 225612, upload-time = "2025-11-17T22:32:08.615Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/5a5929e92c72936d5b19872c5fb8fc09327c1da67b3b68c6a13139e77e20/ml_dtypes-0.5.4-cp313-cp313t-win_arm64.whl", hash = "sha256:3bbbe120b915090d9dd1375e4684dd17a20a2491ef25d640a908281da85e73f1", size = 164145, upload-time = "2025-11-17T22:32:09.782Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "mtcnn" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "lz4" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/37/b0f60411b6a37dcd5122bbe05c9aa45f271bcc8129caa45ee1012251905d/mtcnn-1.0.0.tar.gz", hash = "sha256:08428bf8e1ae9827d43a40bb0246b57f2239e3572d3742f472ae9924896c6419", size = 1885746, upload-time = "2024-10-08T01:42:22.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/7e/0b2b688a9e2d353a661b617b12d00d9af29f877b57c8e4a3cbe447483b46/mtcnn-1.0.0-py3-none-any.whl", hash = "sha256:0a96b4868e56db9ae984449519642be6dba03240e608a67e928ebb47833e9144", size = 1898606, upload-time = "2024-10-08T01:42:18.271Z" }, +] + +[[package]] +name = "namex" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/c0/ee95b28f029c73f8d49d8f52edaed02a1d4a9acb8b69355737fdb1faa191/namex-0.1.0.tar.gz", hash = "sha256:117f03ccd302cc48e3f5c58a296838f6b89c83455ab8683a1e85f2a430aa4306", size = 6649, upload-time = "2025-05-26T23:17:38.918Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/bc/465daf1de06409cdd4532082806770ee0d8d7df434da79c76564d0f69741/namex-0.1.0-py3-none-any.whl", hash = "sha256:e2012a474502f1e2251267062aae3114611f07df4224b6e06334c57b0f2ce87c", size = 5905, upload-time = "2025-05-26T23:17:37.695Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, +] + +[[package]] +name = "nvidia-cublas-cu12" +version = "12.9.2.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cuda-nvrtc-cu12" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/a2/c96163a0fff1839c0c9548bbdeae7b853b867009e33b9b9264adc238b1cf/nvidia_cublas_cu12-12.9.2.10-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:5572131a59c3eebeeb1c4c8144f772d49372c20124916e072a0e3fc30df421d5", size = 575012079, upload-time = "2026-04-08T18:51:47.303Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c0/0a517bfe63ccd3b92eb254d264e28fca3c7cab75d07daea315250fb1bf73/nvidia_cublas_cu12-12.9.2.10-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:e4f53a8ca8c5d6e8c492d0d0a3d565ecb59a751b19cfdaa4f6da0ab2104c1702", size = 581240110, upload-time = "2026-04-08T18:52:31.532Z" }, + { url = "https://files.pythonhosted.org/packages/20/e2/fc9a0e985249d873150276d5afb02e39a66817fedbf1a385724393e505ed/nvidia_cublas_cu12-12.9.2.10-py3-none-win_amd64.whl", hash = "sha256:623f43027d40d44ceadf0043f002bd25cf353e8f13ce90b9a87057019f560661", size = 553162896, upload-time = "2026-04-08T18:53:10.035Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti-cu12" +version = "12.9.79" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/78/351b5c8cdbd9a6b4fb0d6ee73fb176dcdc1b6b6ad47c2ffff5ae8ca4a1f7/nvidia_cuda_cupti_cu12-12.9.79-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:791853b030602c6a11d08b5578edfb957cadea06e9d3b26adbf8d036135a4afe", size = 10077166, upload-time = "2025-06-05T20:01:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/c1/2e/b84e32197e33f39907b455b83395a017e697c07a449a2b15fd07fc1c9981/nvidia_cuda_cupti_cu12-12.9.79-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:096bcf334f13e1984ba36685ad4c1d6347db214de03dbb6eebb237b41d9d934f", size = 10814997, upload-time = "2025-06-05T20:01:10.168Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b4/298983ab1a83de500f77d0add86d16d63b19d1a82c59f8eaf04f90445703/nvidia_cuda_cupti_cu12-12.9.79-py3-none-win_amd64.whl", hash = "sha256:1848a9380067560d5bee10ed240eecc22991713e672c0515f9c3d9396adf93c8", size = 7730496, upload-time = "2025-06-05T20:11:26.444Z" }, +] + +[[package]] +name = "nvidia-cuda-nvcc-cu12" +version = "12.9.86" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/48/b54a06168a2190572a312bfe4ce443687773eb61367ced31e064953dd2f7/nvidia_cuda_nvcc_cu12-12.9.86-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:5d6a0d32fdc7ea39917c20065614ae93add6f577d840233237ff08e9a38f58f0", size = 40546229, upload-time = "2025-06-05T20:01:53.357Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/8cc072436787104bbbcbde1f76ab4a0d89e68f7cebc758dd2ad7913a43d0/nvidia_cuda_nvcc_cu12-12.9.86-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:44e1eca4d08926193a558d2434b1bf83d57b4d5743e0c431c0c83d51da1df62b", size = 39411138, upload-time = "2025-06-05T20:01:43.182Z" }, + { url = "https://files.pythonhosted.org/packages/d2/9e/c71c53655a65d7531c89421c282359e2f626838762f1ce6180ea0bbebd29/nvidia_cuda_nvcc_cu12-12.9.86-py3-none-win_amd64.whl", hash = "sha256:8ed7f0b17dea662755395be029376db3b94fed5cbb17c2d35cc866c5b1b84099", size = 34669845, upload-time = "2025-06-05T20:11:56.308Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc-cu12" +version = "12.9.86" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/85/e4af82cc9202023862090bfca4ea827d533329e925c758f0cde964cb54b7/nvidia_cuda_nvrtc_cu12-12.9.86-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:210cf05005a447e29214e9ce50851e83fc5f4358df8b453155d5e1918094dcb4", size = 89568129, upload-time = "2025-06-05T20:02:41.973Z" }, + { url = "https://files.pythonhosted.org/packages/64/eb/c2295044b8f3b3b08860e2f6a912b702fc92568a167259df5dddb78f325e/nvidia_cuda_nvrtc_cu12-12.9.86-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:096d4de6bda726415dfaf3198d4f5c522b8e70139c97feef5cd2ca6d4cd9cead", size = 44528905, upload-time = "2025-06-05T20:02:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/52/de/823919be3b9d0ccbf1f784035423c5f18f4267fb0123558d58b813c6ec86/nvidia_cuda_nvrtc_cu12-12.9.86-py3-none-win_amd64.whl", hash = "sha256:72972ebdcf504d69462d3bcd67e7b81edd25d0fb85a2c46d3ea3517666636349", size = 76408187, upload-time = "2025-06-05T20:12:27.819Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime-cu12" +version = "12.9.79" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/e0/0279bd94539fda525e0c8538db29b72a5a8495b0c12173113471d28bce78/nvidia_cuda_runtime_cu12-12.9.79-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83469a846206f2a733db0c42e223589ab62fd2fabac4432d2f8802de4bded0a4", size = 3515012, upload-time = "2025-06-05T20:00:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/bc/46/a92db19b8309581092a3add7e6fceb4c301a3fd233969856a8cbf042cd3c/nvidia_cuda_runtime_cu12-12.9.79-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25bba2dfb01d48a9b59ca474a1ac43c6ebf7011f1b0b8cc44f54eb6ac48a96c3", size = 3493179, upload-time = "2025-06-05T20:00:53.735Z" }, + { url = "https://files.pythonhosted.org/packages/59/df/e7c3a360be4f7b93cee39271b792669baeb3846c58a4df6dfcf187a7ffab/nvidia_cuda_runtime_cu12-12.9.79-py3-none-win_amd64.whl", hash = "sha256:8e018af8fa02363876860388bd10ccb89eb9ab8fb0aa749aaf58430a9f7c4891", size = 3591604, upload-time = "2025-06-05T20:11:17.036Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu12" +version = "9.21.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/90/306a6f3937706b089c72535a2bf2a42e49168923481f82a4c79e7629d00c/nvidia_cudnn_cu12-9.21.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:8f8bf4d0f942bc9c3a501cc990dbc2397e2dfbe4af8d3f2984fab14aa7a606e2", size = 759361824, upload-time = "2026-04-21T15:51:51.106Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8e/0c3aeef5f8a9507ad0b2d6fb3f28f38997cda1c7e7f614adc00ceb64a901/nvidia_cudnn_cu12-9.21.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:ffd5bf372071423c441f1de01af35abd6b6f3921a8ab80b23db8ba69a12131b0", size = 704835470, upload-time = "2026-04-21T15:53:03.458Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a5/91672bf58b23479f89744d02ae918f8d8cc940a97e2548d44c19b786d681/nvidia_cudnn_cu12-9.21.1.3-py3-none-win_amd64.whl", hash = "sha256:4e87cb118a52d7cb4a758d1dc47652975bb81e52f230febd361eba04d0204799", size = 686950248, upload-time = "2026-04-21T15:55:20.889Z" }, +] + +[[package]] +name = "nvidia-cufft-cu12" +version = "11.4.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink-cu12" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/2b/76445b0af890da61b501fde30650a1a4bd910607261b209cccb5235d3daa/nvidia_cufft_cu12-11.4.1.4-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1a28c9b12260a1aa7a8fd12f5ebd82d027963d635ba82ff39a1acfa7c4c0fbcf", size = 200822453, upload-time = "2025-06-05T20:05:27.889Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/61e6996dd20481ee834f57a8e9dca28b1869366a135e0d42e2aa8493bdd4/nvidia_cufft_cu12-11.4.1.4-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c67884f2a7d276b4b80eb56a79322a95df592ae5e765cf1243693365ccab4e28", size = 200877592, upload-time = "2025-06-05T20:05:45.862Z" }, + { url = "https://files.pythonhosted.org/packages/20/ee/29955203338515b940bd4f60ffdbc073428f25ef9bfbce44c9a066aedc5c/nvidia_cufft_cu12-11.4.1.4-py3-none-win_amd64.whl", hash = "sha256:8e5bfaac795e93f80611f807d42844e8e27e340e0cde270dcb6c65386d795b80", size = 200067309, upload-time = "2025-06-05T20:13:59.762Z" }, +] + +[[package]] +name = "nvidia-curand-cu12" +version = "10.3.10.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1c/2a45afc614d99558d4a773fa740d8bb5471c8398eeed925fc0fcba020173/nvidia_curand_cu12-10.3.10.19-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:de663377feb1697e1d30ed587b07d5721fdd6d2015c738d7528a6002a6134d37", size = 68292066, upload-time = "2025-05-01T19:39:13.595Z" }, + { url = "https://files.pythonhosted.org/packages/31/44/193a0e171750ca9f8320626e8a1f2381e4077a65e69e2fb9708bd479e34a/nvidia_curand_cu12-10.3.10.19-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:49b274db4780d421bd2ccd362e1415c13887c53c214f0d4b761752b8f9f6aa1e", size = 68295626, upload-time = "2025-05-01T19:39:38.885Z" }, + { url = "https://files.pythonhosted.org/packages/e5/98/1bd66fd09cbe1a5920cb36ba87029d511db7cca93979e635fd431ad3b6c0/nvidia_curand_cu12-10.3.10.19-py3-none-win_amd64.whl", hash = "sha256:e8129e6ac40dc123bd948e33d3e11b4aa617d87a583fa2f21b3210e90c743cde", size = 68774847, upload-time = "2025-05-01T19:48:52.93Z" }, +] + +[[package]] +name = "nvidia-cusolver-cu12" +version = "11.7.5.82" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12" }, + { name = "nvidia-cusparse-cu12" }, + { name = "nvidia-nvjitlink-cu12" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/99/686ff9bf3a82a531c62b1a5c614476e8dfa24a9d89067aeedf3592ee4538/nvidia_cusolver_cu12-11.7.5.82-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:62efa83e4ace59a4c734d052bb72158e888aa7b770e1a5f601682f16fe5b4fd2", size = 337869834, upload-time = "2025-06-05T20:06:53.125Z" }, + { url = "https://files.pythonhosted.org/packages/33/40/79b0c64d44d6c166c0964ec1d803d067f4a145cca23e23925fd351d0e642/nvidia_cusolver_cu12-11.7.5.82-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:15da72d1340d29b5b3cf3fd100e3cd53421dde36002eda6ed93811af63c40d88", size = 338117415, upload-time = "2025-06-05T20:07:16.809Z" }, + { url = "https://files.pythonhosted.org/packages/32/5d/feb7f86b809f89b14193beffebe24cf2e4bf7af08372ab8cdd34d19a65a0/nvidia_cusolver_cu12-11.7.5.82-py3-none-win_amd64.whl", hash = "sha256:77666337237716783c6269a658dea310195cddbd80a5b2919b1ba8735cec8efd", size = 326215953, upload-time = "2025-06-05T20:14:41.76Z" }, +] + +[[package]] +name = "nvidia-cusparse-cu12" +version = "12.5.10.65" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink-cu12" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/6f/8710fbd17cdd1d0fc3fea7d36d5b65ce1933611c31e1861da330206b253a/nvidia_cusparse_cu12-12.5.10.65-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:221c73e7482dd93eda44e65ce567c031c07e2f93f6fa0ecd3ba876a195023e83", size = 366359408, upload-time = "2025-06-05T20:07:42.501Z" }, + { url = "https://files.pythonhosted.org/packages/12/46/b0fd4b04f86577921feb97d8e2cf028afe04f614d17fb5013de9282c9216/nvidia_cusparse_cu12-12.5.10.65-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:73060ce019ac064a057267c585bf1fd5a353734151f87472ff02b2c5c9984e78", size = 366465088, upload-time = "2025-06-05T20:08:20.413Z" }, + { url = "https://files.pythonhosted.org/packages/73/ef/063500c25670fbd1cbb0cd3eb7c8a061585b53adb4dd8bf3492bb49b0df3/nvidia_cusparse_cu12-12.5.10.65-py3-none-win_amd64.whl", hash = "sha256:9e487468a22a1eaf1fbd1d2035936a905feb79c4ce5c2f67626764ee4f90227c", size = 362504719, upload-time = "2025-06-05T20:15:17.947Z" }, +] + +[[package]] +name = "nvidia-nccl-cu12" +version = "2.30.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/2b/1757b6b74ee241de5efee3f35487dcb33e09c07605254809c6ce36aeb783/nvidia_nccl_cu12-2.30.4-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:606fa9aa9215c00367d060188eb1a5bbd28396aff5e11b9200d99d1a6ab79a71", size = 300091935, upload-time = "2026-04-23T03:22:58.024Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c3/0e45ff4dce8401f6ea7c25d80d75738813a47f5ae2691e2478f2fd1e5e93/nvidia_nccl_cu12-2.30.4-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:040974b261edec4b8b793e59e92ab7176fe4ab4bc61b800f9f3bfaeec2d436f3", size = 300164158, upload-time = "2026-04-23T03:23:19.589Z" }, +] + +[[package]] +name = "nvidia-nvjitlink-cu12" +version = "12.9.86" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/0c/c75bbfb967457a0b7670b8ad267bfc4fffdf341c074e0a80db06c24ccfd4/nvidia_nvjitlink_cu12-12.9.86-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:e3f1171dbdc83c5932a45f0f4c99180a70de9bd2718c1ab77d14104f6d7147f9", size = 39748338, upload-time = "2025-06-05T20:10:25.613Z" }, + { url = "https://files.pythonhosted.org/packages/97/bc/2dcba8e70cf3115b400fef54f213bcd6715a3195eba000f8330f11e40c45/nvidia_nvjitlink_cu12-12.9.86-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:994a05ef08ef4b0b299829cde613a424382aff7efb08a7172c1fa616cc3af2ca", size = 39514880, upload-time = "2025-06-05T20:10:04.89Z" }, + { url = "https://files.pythonhosted.org/packages/dd/7e/2eecb277d8a98184d881fb98a738363fd4f14577a4d2d7f8264266e82623/nvidia_nvjitlink_cu12-12.9.86-py3-none-win_amd64.whl", hash = "sha256:cc6fcec260ca843c10e34c936921a1c426b351753587fdd638e8cff7b16bb9db", size = 35584936, upload-time = "2025-06-05T20:16:08.525Z" }, +] + +[[package]] +name = "opencv-python" +version = "4.13.0.92" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" }, + { url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" }, + { url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" }, + { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, +] + +[[package]] +name = "opt-einsum" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/b9/2ac072041e899a52f20cf9510850ff58295003aa75525e58343591b0cbfb/opt_einsum-3.4.0.tar.gz", hash = "sha256:96ca72f1b886d148241348783498194c577fa30a8faac108586b14f1ba4473ac", size = 63004, upload-time = "2024-09-26T14:33:24.483Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/cd/066e86230ae37ed0be70aae89aabf03ca8d9f39c8aea0dec8029455b5540/opt_einsum-3.4.0-py3-none-any.whl", hash = "sha256:69bb92469f86a1565195ece4ac0323943e83477171b91d24c35afe028a90d7cd", size = 71932, upload-time = "2024-09-26T14:33:23.039Z" }, +] + +[[package]] +name = "optree" +version = "0.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/63/7b078bc36d5a206c21b03565a818ede38ff0fbf014e92085ec467ef10adb/optree-0.19.0.tar.gz", hash = "sha256:bc1991a948590756409e76be4e29efd4a487a185056d35db6c67619c19ea27a1", size = 175199, upload-time = "2026-02-23T01:56:37.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/61/d79c7eeb87e98d08bc8d95ed08dee83bedb4e55371a7d2ae3c874ec02608/optree-0.19.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:1eea5b7be833c6d555d08ff68046d3dd2112dfb39e6f1eb09887ab6c617a6d64", size = 923043, upload-time = "2026-02-23T01:55:19.018Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ed/e80504f65e7e80fdcd129258428d7976ea9f03bf9dad56a5293c44d563ad/optree-0.19.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:4d9cf9dfa0ac051e0ed82869d782f0affdbdb1daa5f2e851d37ea8625c60071a", size = 385597, upload-time = "2026-02-23T01:55:20.586Z" }, + { url = "https://files.pythonhosted.org/packages/65/e5/d1926a2f0e0240f6800ff385c8486879f7da0a5a030b7aa5d84e44e9c9ca/optree-0.19.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:43c4f8ba5755d56d046be2cb1380cbc362234ad93fd9933384c6dd7fdebe6c4a", size = 392265, upload-time = "2026-02-23T01:55:21.662Z" }, + { url = "https://files.pythonhosted.org/packages/61/88/9c598325e89bbed29b37a381ebb2b94f1d9d769c973b879b3e9766b4b16d/optree-0.19.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36b1134680ee3f9768ede290da653e1604a8083bce69fef8fb4e46863346d5c8", size = 423763, upload-time = "2026-02-23T01:55:22.97Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d2/fcba2a1826d362a64cb36ec9f675ed6dcddee47099948913122b0aafbe44/optree-0.19.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c9f7e7e7bf2ef011d0be1c2e87c96f5dc543dad1ac34430c2f606938c9ec5135", size = 392720, upload-time = "2026-02-23T01:55:24.107Z" }, + { url = "https://files.pythonhosted.org/packages/eb/43/5e6d51d8c203a79cff084efa9f04a745b8ef5cf4c86dbb127e7b192f14d9/optree-0.19.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb5752f17afa017b08b0cbac8a383d4bb90035b353bef7a25fe03cda69a21d33", size = 411481, upload-time = "2026-02-23T01:55:25.215Z" }, + { url = "https://files.pythonhosted.org/packages/4b/dc/dc09347136876287b463b8599239d6fa338298fd322ac629817bd2f4def4/optree-0.19.0-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:e9b6245993494b1aa54529eb7356aeefa6704c8b436e6e5f20b25c30f7af7620", size = 476695, upload-time = "2026-02-23T01:55:26.23Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cc/5d2c9cf906bd3ae357e7221450bacefd0321d7b94e6171dec39552b346e6/optree-0.19.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7351a24b30568c963a92b19f543c9562b36b3222caed2a5ac3209ef910972bec", size = 471846, upload-time = "2026-02-23T01:55:27.288Z" }, + { url = "https://files.pythonhosted.org/packages/64/7f/75b10f88da994fc3da3dc1ab7d54bab7bd3a6fa5eb81b586f13f8bd6ab0e/optree-0.19.0-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2c6610a1d1d74af0f53c9bbabb7c265679a9a07e03783c8cc4a678ba3bb6f9a5", size = 473145, upload-time = "2026-02-23T01:55:28.941Z" }, + { url = "https://files.pythonhosted.org/packages/78/fc/753bf69b907652d54b7c6012ccb320d8c1a3161454e415331058b6f04246/optree-0.19.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37e07a5233be64329cbf41e20ab07c50da53bdc374109a2b376be49c4a34a37f", size = 456160, upload-time = "2026-02-23T01:55:30.515Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a8/70640f9998438f50a0a1c57f2a12aac856cd937f2c4c4feef5a3cfe8e9c7/optree-0.19.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:c23a25caff6b096b62379adb99e2c401805141497ebb8131f271a4c93f5ed5dc", size = 417116, upload-time = "2026-02-23T01:55:31.591Z" }, + { url = "https://files.pythonhosted.org/packages/ad/05/0b8bf4abf5d1a7cd9a19ba680e1ec64ad38eec3204e4e16a769e8aeaa4a2/optree-0.19.0-cp313-cp313-win32.whl", hash = "sha256:045cf112adaebc76c9c7cabde857c01babfc9fae8aa0a28d48f7c565fadf0cb9", size = 312101, upload-time = "2026-02-23T01:55:33.002Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c7/9ce83f115d7f4a47741827a037067b9026c29996ad7913bc40277924c773/optree-0.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:bc0c6c9f99fb90e3a20a8b94c219e6b03e585f65ab9a11c9acd1511a5f885f79", size = 337944, upload-time = "2026-02-23T01:55:34.3Z" }, + { url = "https://files.pythonhosted.org/packages/17/fd/97c27d6e51c8b958b29f5c7b4cdcae4f2e7c9ef5b5465be459811a48876b/optree-0.19.0-cp313-cp313-win_arm64.whl", hash = "sha256:48f492363fa0f9ffe5029d0ecafd2fa30ffe0d5d52c8dd414123f47b743bd42e", size = 347153, upload-time = "2026-02-23T01:55:35.331Z" }, + { url = "https://files.pythonhosted.org/packages/46/45/9a2f05b5d033482b58ca36df6f41b0b28af3ccfa43267a82254c973dcd14/optree-0.19.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d6362b9e9a0f4dd7c5b88debe182a90541aba7f1ad02d00922d01c4df4b3c933", size = 463985, upload-time = "2026-02-23T01:55:36.681Z" }, + { url = "https://files.pythonhosted.org/packages/20/b7/5d0a013c5461e0933ce7385a06eed625358de12216c80da935138e6af205/optree-0.19.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:381096a293d385fd3135e5c707bb7e58c584bc9bd50f458237b49da21a621df3", size = 431307, upload-time = "2026-02-23T01:55:37.754Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2c/d3f2674411c8e3338e91e7446af239597ae6efd23f14e2039f29ced3d73e/optree-0.19.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9675007cc54371be544bb33fd7eb07b0773d88deacf8aa4cc72fa735c4a4d33", size = 426917, upload-time = "2026-02-23T01:55:39.122Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e9/009964734f19d6996291e77f2c1da5d35a743defc4e89aefb01260e2f9d6/optree-0.19.0-cp313-cp313t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:406b355d6f29f99535efa97ea16eda70414968271a894c99f48cd91848723706", size = 490603, upload-time = "2026-02-23T01:55:40.123Z" }, + { url = "https://files.pythonhosted.org/packages/2b/4c/96706f855c6b623259e754f751020acfb3452e412f7c85330629ab4b9ecc/optree-0.19.0-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d05e5bf6ce30258cda643ea50cc424038e5107905e9fc11d19a04453a8d2ee27", size = 486388, upload-time = "2026-02-23T01:55:41.746Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e4/9b23a27c9bd211d22a2e55a5a66e62afe5c75ff98b81fc7d000d879e75e6/optree-0.19.0-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b6e11479d98690fc9efd15d65195af37608269bb1e176b5a836b066440f9c52f", size = 489090, upload-time = "2026-02-23T01:55:42.913Z" }, + { url = "https://files.pythonhosted.org/packages/15/3b/462582f0050508f1ce0734f1dffd19078fb013fa12ccf0761c208ab6f756/optree-0.19.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d523ffc6d3e22851ed25bec806a6c78d68340259e79941059752209b07a75ec", size = 469601, upload-time = "2026-02-23T01:55:44.084Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c6/843c6a33b700ef88407bd5840813e53c6986b6130d94c75c49ff7a2e31f9/optree-0.19.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:ca148527b6e5d59c25c733e66d4165fbcf85102f4ea10f096370fda533fe77d1", size = 436195, upload-time = "2026-02-23T01:55:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ed/13f938444de70bec2ff0edef8917a08160d41436a3cad976e541d21747f5/optree-0.19.0-cp313-cp313t-win32.whl", hash = "sha256:40d067cf87e76ad21b8ee2e6ba0347c517c88c2ce7190d666b30b4057e4de5ba", size = 343123, upload-time = "2026-02-23T01:55:46.201Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a2/5074dedbc1be5deca76fe57285ec3e7d5d475922572f92a90f3b3a4f21c5/optree-0.19.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b133e1b9a30ec0bca3f875cfa68c2ce88c0b9e08b21f97f687bb669266411f4a", size = 376560, upload-time = "2026-02-23T01:55:47.58Z" }, + { url = "https://files.pythonhosted.org/packages/49/3a/ea23a29f63d8eadab4e030ebc1329906d44f631076cd1da4751388649960/optree-0.19.0-cp313-cp313t-win_arm64.whl", hash = "sha256:45184b3c73e2147b26b139f34f15c2111cde54b8893b1104a00281c3f283b209", size = 381649, upload-time = "2026-02-23T01:55:48.709Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/ca/3e639a1ea6fcd0617ca4e8ca45f62a74de33a56ae6cd552735470b22c8d3/pandas-3.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5918ba197c951dec132b0c5929a00c0bf05d5942f590d3c10a807f6e15a57d3", size = 10321105, upload-time = "2026-03-31T06:46:57.327Z" }, + { url = "https://files.pythonhosted.org/packages/0b/77/dbc82ff2fb0e63c6564356682bf201edff0ba16c98630d21a1fb312a8182/pandas-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d606a041c89c0a474a4702d532ab7e73a14fe35c8d427b972a625c8e46373668", size = 9864088, upload-time = "2026-03-31T06:46:59.935Z" }, + { url = "https://files.pythonhosted.org/packages/5c/2b/341f1b04bbca2e17e13cd3f08c215b70ef2c60c5356ef1e8c6857449edc7/pandas-3.0.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:710246ba0616e86891b58ab95f2495143bb2bc83ab6b06747c74216f583a6ac9", size = 10369066, upload-time = "2026-03-31T06:47:02.792Z" }, + { url = "https://files.pythonhosted.org/packages/12/c5/cbb1ffefb20a93d3f0e1fdcda699fb84976210d411b008f97f48bf6ce27e/pandas-3.0.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d3cfe227c725b1f3dff4278b43d8c784656a42a9325b63af6b1492a8232209e", size = 10876780, upload-time = "2026-03-31T06:47:06.205Z" }, + { url = "https://files.pythonhosted.org/packages/98/fe/2249ae5e0a69bd0ddf17353d0a5d26611d70970111f5b3600cdc8be883e7/pandas-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c3b723df9087a9a9a840e263ebd9f88b64a12075d1bf2ea401a5a42f254f084d", size = 11375181, upload-time = "2026-03-31T06:47:09.383Z" }, + { url = "https://files.pythonhosted.org/packages/de/64/77a38b09e70b6464883b8d7584ab543e748e42c1b5d337a2ee088e0df741/pandas-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3096110bf9eac0070b7208465f2740e2d8a670d5cb6530b5bb884eca495fd39", size = 11928899, upload-time = "2026-03-31T06:47:12.686Z" }, + { url = "https://files.pythonhosted.org/packages/5e/52/42855bf626868413f761addd574acc6195880ae247a5346477a4361c3acb/pandas-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:07a10f5c36512eead51bc578eb3354ad17578b22c013d89a796ab5eee90cd991", size = 9746574, upload-time = "2026-03-31T06:47:15.64Z" }, + { url = "https://files.pythonhosted.org/packages/88/39/21304ae06a25e8bf9fc820d69b29b2c495b2ae580d1e143146c309941760/pandas-3.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:5fdbfa05931071aba28b408e59226186b01eb5e92bea2ab78b65863ca3228d84", size = 9047156, upload-time = "2026-03-31T06:47:18.595Z" }, + { url = "https://files.pythonhosted.org/packages/72/20/7defa8b27d4f330a903bb68eea33be07d839c5ea6bdda54174efcec0e1d2/pandas-3.0.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:dbc20dea3b9e27d0e66d74c42b2d0c1bed9c2ffe92adea33633e3bedeb5ac235", size = 10756238, upload-time = "2026-03-31T06:47:22.012Z" }, + { url = "https://files.pythonhosted.org/packages/e9/95/49433c14862c636afc0e9b2db83ff16b3ad92959364e52b2955e44c8e94c/pandas-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b75c347eff42497452116ce05ef461822d97ce5b9ff8df6edacb8076092c855d", size = 10408520, upload-time = "2026-03-31T06:47:25.197Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f8/462ad2b5881d6b8ec8e5f7ed2ea1893faa02290d13870a1600fe72ad8efc/pandas-3.0.2-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1478075142e83a5571782ad007fb201ed074bdeac7ebcc8890c71442e96adf7", size = 10324154, upload-time = "2026-03-31T06:47:28.097Z" }, + { url = "https://files.pythonhosted.org/packages/0a/65/d1e69b649cbcddda23ad6e4c40ef935340f6f652a006e5cbc3555ac8adb3/pandas-3.0.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5880314e69e763d4c8b27937090de570f1fb8d027059a7ada3f7f8e98bdcb677", size = 10714449, upload-time = "2026-03-31T06:47:30.85Z" }, + { url = "https://files.pythonhosted.org/packages/47/a4/85b59bc65b8190ea3689882db6cdf32a5003c0ccd5a586c30fdcc3ffc4fc/pandas-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5329e26898896f06035241a626d7c335daa479b9bbc82be7c2742d048e41172", size = 11338475, upload-time = "2026-03-31T06:47:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c4/bc6966c6e38e5d9478b935272d124d80a589511ed1612a5d21d36f664c68/pandas-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:81526c4afd31971f8b62671442a4b2b51e0aa9acc3819c9f0f12a28b6fcf85f1", size = 11786568, upload-time = "2026-03-31T06:47:36.941Z" }, + { url = "https://files.pythonhosted.org/packages/e8/74/09298ca9740beed1d3504e073d67e128aa07e5ca5ca2824b0c674c0b8676/pandas-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:7cadd7e9a44ec13b621aec60f9150e744cfc7a3dd32924a7e2f45edff31823b0", size = 10488652, upload-time = "2026-03-31T06:47:40.612Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "7.34.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, + { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419, upload-time = "2026-04-20T23:34:58.754Z" }, + { url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990, upload-time = "2026-04-20T23:35:00.821Z" }, + { url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696, upload-time = "2026-04-20T23:35:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982, upload-time = "2026-04-20T23:35:05.583Z" }, + { url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867, upload-time = "2026-04-20T23:35:07.902Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578, upload-time = "2026-04-20T23:35:10.149Z" }, + { url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816, upload-time = "2026-04-20T23:35:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307, upload-time = "2026-04-20T23:35:15.029Z" }, + { url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968, upload-time = "2026-04-20T23:35:16.757Z" }, + { url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369, upload-time = "2026-04-20T23:35:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, + { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, + { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, + { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, + { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, + { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, + { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[package.optional-dependencies] +socks = [ + { name = "pysocks" }, +] + +[[package]] +name = "respx" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" }, +] + +[[package]] +name = "retina-face" +version = "0.0.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gdown" }, + { name = "numpy" }, + { name = "opencv-python" }, + { name = "pillow" }, + { name = "tensorflow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/0d/76a74b93c0d9d6fd8ad450986c8f586b371ce74f7845eb3827591d8ac3e1/retina-face-0.0.17.tar.gz", hash = "sha256:7532b136ed01fe9a8cba8dfbc5a046dd6fb1214b1a83e57f3210bd145a91cd73", size = 18929, upload-time = "2024-04-16T21:03:36.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/87/30c5beef6ef3cb60f80f02d3f934b86efda21aca3225f174d127192d43bb/retina_face-0.0.17-py3-none-any.whl", hash = "sha256:b43fdac4078678b9d8bc45b88a7090f05d81c44e1e10710e6c16d703bb7add41", size = 25124, upload-time = "2024-04-16T21:03:35.109Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + +[[package]] +name = "sqlite-vec" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/85/9fad0045d8e7c8df3e0fa5a56c630e8e15ad6e5ca2e6106fceb666aa6638/sqlite_vec-0.1.9-py3-none-macosx_10_6_x86_64.whl", hash = "sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb", size = 131171, upload-time = "2026-03-31T08:02:31.717Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3d/3677e0cd2f92e5ebc43cd29fbf565b75582bff1ccfa0b8327c7508e1084f/sqlite_vec-0.1.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c", size = 165434, upload-time = "2026-03-31T08:02:32.712Z" }, + { url = "https://files.pythonhosted.org/packages/00/d4/f2b936d3bdc38eadcbd2a87875815db36430fab0363182ba5d12cd8e0b51/sqlite_vec-0.1.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9", size = 160076, upload-time = "2026-03-31T08:02:33.796Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ad/6afd073b0f817b3e03f9e37ad626ae341805891f23c74b5292818f49ac63/sqlite_vec-0.1.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux1_x86_64.whl", hash = "sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786", size = 163388, upload-time = "2026-03-31T08:02:34.888Z" }, + { url = "https://files.pythonhosted.org/packages/42/89/81b2907cda14e566b9bf215e2ad82fc9b349edf07d2010756ffdb902f328/sqlite_vec-0.1.9-py3-none-win_amd64.whl", hash = "sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32", size = 292804, upload-time = "2026-03-31T08:02:36.035Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "tensorflow" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "absl-py" }, + { name = "astunparse" }, + { name = "flatbuffers" }, + { name = "gast" }, + { name = "google-pasta" }, + { name = "grpcio" }, + { name = "h5py" }, + { name = "keras" }, + { name = "libclang" }, + { name = "ml-dtypes" }, + { name = "numpy" }, + { name = "opt-einsum" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "requests" }, + { name = "setuptools" }, + { name = "six" }, + { name = "termcolor" }, + { name = "typing-extensions" }, + { name = "wrapt" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/09/268b45a61be2bce136dabf3a3cd7099c8a984ae398198f71920b4c60c502/tensorflow-2.21.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a145ed46c58192b7c3f9916d070caf4f6afc6dc7e5511f83dd97677f4c4947f4", size = 223766900, upload-time = "2026-03-06T17:24:51.349Z" }, + { url = "https://files.pythonhosted.org/packages/72/72/343b86b4c9bfe28e81f749439f908c1e26aeac73d9f12b8dcdb996eb8ecb/tensorflow-2.21.0-cp313-cp313-manylinux_2_27_aarch64.whl", hash = "sha256:a10abdfb8b1189210c251021a3f153b7ccc52a8e6521351f3dc3331e8ba593e0", size = 282213003, upload-time = "2026-03-06T17:24:59.859Z" }, + { url = "https://files.pythonhosted.org/packages/86/6c/10d075ffc09754c7f10e749ba3c9d46dd809fb007990c7f788128044180c/tensorflow-2.21.0-cp313-cp313-manylinux_2_27_x86_64.whl", hash = "sha256:e9d8da8dcab9650efb45f032ba70af2f016f907e6e0c6bda29dd101bba945406", size = 572881074, upload-time = "2026-03-06T17:25:14.453Z" }, + { url = "https://files.pythonhosted.org/packages/86/91/dedad8403e7b0036d99be4878987693b7b7f62097eb8537fa6ce62ea131c/tensorflow-2.21.0-cp313-cp313-win_amd64.whl", hash = "sha256:76cccbe0a95d9392dee1ae501ae0656b6c73c1cac29a7f8f32d570e0670863f7", size = 351205371, upload-time = "2026-03-06T17:25:33.144Z" }, +] + +[package.optional-dependencies] +and-cuda = [ + { name = "nvidia-cublas-cu12" }, + { name = "nvidia-cuda-cupti-cu12" }, + { name = "nvidia-cuda-nvcc-cu12" }, + { name = "nvidia-cuda-nvrtc-cu12" }, + { name = "nvidia-cuda-runtime-cu12" }, + { name = "nvidia-cudnn-cu12" }, + { name = "nvidia-cufft-cu12" }, + { name = "nvidia-curand-cu12" }, + { name = "nvidia-cusolver-cu12" }, + { name = "nvidia-cusparse-cu12" }, + { name = "nvidia-nccl-cu12" }, + { name = "nvidia-nvjitlink-cu12" }, +] + +[[package]] +name = "termcolor" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "ty" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/7e/2aa791c9ae7b8cd5024cd4122e92267f664ca954cea3def3211919fa3c1f/ty-0.0.32.tar.gz", hash = "sha256:8743174c5f920f6700a4a0c9de140109189192ba16226884cd50095b43b8a45c", size = 5522294, upload-time = "2026-04-20T19:29:01.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/eb/1075dc6a49d7acbe2584ae4d5b410c41b1f177a5adcc567e09eca4c69000/ty-0.0.32-py3-none-linux_armv6l.whl", hash = "sha256:dacbc2f6cd698d488ae7436838ff929570455bf94bfa4d9fe57a630c552aff83", size = 10902959, upload-time = "2026-04-20T19:28:31.907Z" }, + { url = "https://files.pythonhosted.org/packages/33/d2/c35fc8bc66e98d1ee9b0f8ed319bf743e450e1f1e997574b178fab75670f/ty-0.0.32-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914bbc4f605ce2a9e2a78982e28fae1d3359a169d141f9dc3b4c7749cd5eca81", size = 10726172, upload-time = "2026-04-20T19:28:44.765Z" }, + { url = "https://files.pythonhosted.org/packages/96/32/c827da3ca480456fb02d8cea68a2609273b6c220fea0be9a4c8d8470b86e/ty-0.0.32-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4787ac9fe1f86b1f3133f5c6732adbe2df5668b50c679ac6e2d98cd284da812f", size = 10163701, upload-time = "2026-04-20T19:28:27.005Z" }, + { url = "https://files.pythonhosted.org/packages/ba/9e/2734478fbdb90c160cb2813a3916a16a2af5c1e231f87d635f6131d781fb/ty-0.0.32-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8ea0a728af99fe40dd744cba6441a2404f80b7f4bde17aa6da393810af5ea57", size = 10656220, upload-time = "2026-04-20T19:29:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/44/9f/0007da2d35e424debe7e9f86ffbc1ab7f60983cfbc5f0411324ab2de5292/ty-0.0.32-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2850561f9b018ae33d7e5bbfa0ac414d3c518513edcffe43877dc9801446b9c5", size = 10696086, upload-time = "2026-04-20T19:28:46.829Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5e/ce5fd4ec803222ae3e69a76d2a2db2eed55e19f5b131702b9789ef45f93d/ty-0.0.32-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b5fa2fb3c614349ee211d36476b49d88c5ef79a687cdb91b2872ad023b94d2f8", size = 11184800, upload-time = "2026-04-20T19:28:42.57Z" }, + { url = "https://files.pythonhosted.org/packages/6c/46/ebcf67a5999421331214aac51a7464db42de2be15bbe929c612a3ed0b039/ty-0.0.32-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b89969307ab2417d41c9be8059dd79feea577234e1e10d35132f5495e0d42c6", size = 11718718, upload-time = "2026-04-20T19:28:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/2c/2141c86ed0ce0962b45cefb658a95e734f59759d47f20afdcd9c732910a1/ty-0.0.32-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b59868ede9b1d69a088f0d695df52a0061f95fa7baa1d5e0dc6fc9cf06e1334", size = 11346369, upload-time = "2026-04-20T19:28:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/ed6f772339cf29bd9a46def9d6db5084689eb574ee4d150ff704224c1ed8/ty-0.0.32-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8300caf35345498e9b9b03e550bba03cee8f5f5f8ab4c83c3b1ff1b7403b7d3a", size = 11280714, upload-time = "2026-04-20T19:28:51.516Z" }, + { url = "https://files.pythonhosted.org/packages/da/9b/c6813987edf4816a40e0c8e408b555f97d3f267c7b3a1688c8bbdf65609c/ty-0.0.32-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:583c7094f4574b02f724db924f98b804d1387a0bd9405ecb5e078cc0f47fbcfb", size = 10638806, upload-time = "2026-04-20T19:28:29.651Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d4/0cefcbd2ad0f3d51762ccf58e652ec7da146eb6ae34f87228f6254bbb8be/ty-0.0.32-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e44ebe1bb4143a5628bc4db67ac0dfebe14594af671e4ee66f6f2e983da56501", size = 10726106, upload-time = "2026-04-20T19:29:06.3Z" }, + { url = "https://files.pythonhosted.org/packages/32/ad/2c8a97f91f06311f4367400f7d13534bbda2522c73c99a3e4c0757dff9b8/ty-0.0.32-py3-none-musllinux_1_2_i686.whl", hash = "sha256:06f17ada3e069cba6148342ef88e9929156beca8473e8d4f101b68f66c75643e", size = 10872951, upload-time = "2026-04-20T19:28:34.077Z" }, + { url = "https://files.pythonhosted.org/packages/ba/68/42293f9248106dd51875120971a5cc6ea315c2c4dcfb8e59aa063aa0af26/ty-0.0.32-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e96e60fa556cec04f15d7ea62d2ceee5982bd389233e961ab9fd42304e278175", size = 11363334, upload-time = "2026-04-20T19:28:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/df/92/be9abf4d3e589ad5023e2ea965b93e204ec856420d46adf73c5c36c04678/ty-0.0.32-py3-none-win32.whl", hash = "sha256:2ff2ebb4986b24aebcf1444db7db5ca41b36086040e95eea9f8fb851c11e805c", size = 10260689, upload-time = "2026-04-20T19:28:56.541Z" }, + { url = "https://files.pythonhosted.org/packages/14/61/dc86acea899349d2579cb8419aecedd83dc504d7d6a10df65eef546c8300/ty-0.0.32-py3-none-win_amd64.whl", hash = "sha256:ba7284a4a954b598c1b31500352b3ec1f89bff533825592b5958848226fdc7ee", size = 11255371, upload-time = "2026-04-20T19:28:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/43/01/beffec56d71ca25b343ede63adb076456b5b3e211f1c066452a44cd120b3/ty-0.0.32-py3-none-win_arm64.whl", hash = "sha256:7e10aadbdbda989a7d567ee6a37f8b98d4d542e31e3b190a2879fd581f75d658", size = 10658087, upload-time = "2026-04-20T19:28:59.286Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + +[[package]] +name = "wheel" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/62/75f18a0f03b4219c456652c7780e4d749b929eb605c098ce3a5b6b6bc081/wheel-0.47.0.tar.gz", hash = "sha256:cc72bd1009ba0cf63922e28f94d9d83b920aa2bb28f798a31d0691b02fa3c9b3", size = 63854, upload-time = "2026-04-22T15:51:27.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/1b/9e33c09813d65e248f7f773119148a612516a4bea93e9c6f545f78455b7c/wheel-0.47.0-py3-none-any.whl", hash = "sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced", size = 32218, upload-time = "2026-04-22T15:51:26.296Z" }, +] + +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, + { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, + { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, + { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, + { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, + { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, + { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, + { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, + { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +] diff --git a/app/Actions/Albums/Flow.php b/app/Actions/Albums/Flow.php index de8e4fec141..182c8d649c8 100644 --- a/app/Actions/Albums/Flow.php +++ b/app/Actions/Albums/Flow.php @@ -118,7 +118,7 @@ private function getQuery(Album|null $base, bool $with_relations): AlbumBuilder 'min_privilege_cover', 'min_privilege_cover.size_variants', 'statistics', 'photos', - 'photos.statistics', 'photos.size_variants', 'photos.palette', 'photos.tags', 'photos.rating']); + 'photos.statistics', 'photos.size_variants', 'photos.palette', 'photos.tags', 'photos.rating','photos.faces','photos.faces.person',]); } // Only join what we need for ordering. diff --git a/app/Actions/Diagnostics/Errors.php b/app/Actions/Diagnostics/Errors.php index 79c66bd841b..59838e25e60 100644 --- a/app/Actions/Diagnostics/Errors.php +++ b/app/Actions/Diagnostics/Errors.php @@ -9,6 +9,7 @@ namespace App\Actions\Diagnostics; use App\Actions\Diagnostics\Pipes\Checks\AdminUserExistsCheck; +use App\Actions\Diagnostics\Pipes\Checks\AiVisionServiceCheck; use App\Actions\Diagnostics\Pipes\Checks\AppUrlMatchCheck; use App\Actions\Diagnostics\Pipes\Checks\AuthDisabledCheck; use App\Actions\Diagnostics\Pipes\Checks\BasicPermissionCheck; @@ -76,6 +77,7 @@ class Errors SupporterCheck::class, ImagickPdfCheck::class, WatermarkerEnabledCheck::class, + AiVisionServiceCheck::class, StatisticsIntegrityCheck::class, WebshopCheck::class, SecurityAdvisoriesCheck::class, diff --git a/app/Actions/Diagnostics/Pipes/Checks/AiVisionServiceCheck.php b/app/Actions/Diagnostics/Pipes/Checks/AiVisionServiceCheck.php new file mode 100644 index 00000000000..a674bd08bd1 --- /dev/null +++ b/app/Actions/Diagnostics/Pipes/Checks/AiVisionServiceCheck.php @@ -0,0 +1,115 @@ +config_manager->getValueAsBool('ai_vision_enabled')) { + return $next($data); + } + + if (!$this->facial_recognition_service->isConfigured()) { + $data[] = DiagnosticData::error( + 'AI Vision: Service URL is not configured. Set AI_VISION_FACE_URL in your .env file.', + self::class, + [] + ); + + return $next($data); + } + + $this->checkServiceHealth($data); + + return $next($data); + } + + /** + * Check if the AI Vision service health endpoint is reachable and returns proper data. + * + * @param DiagnosticData[] &$data + * + * @return void + */ + private function checkServiceHealth(array &$data): void + { + $service_url = config('features.ai-vision-service.face-url', ''); + + try { + $response = $this->facial_recognition_service->checkHealthRaw(5); + + if (!$response->successful()) { + $data[] = DiagnosticData::error( + 'AI Vision: Service health check failed with status ' . $response->status() . '. The service may be offline or unreachable.', + self::class, + ['Check that the AI Vision service is running at: ' . $service_url] + ); + + return; + } + + $health_data = $response->json(); + if (!is_array($health_data) || !isset($health_data['status'])) { + $data[] = DiagnosticData::error( + 'AI Vision: Service health endpoint returned invalid response format. Expected JSON with "status" field.', + self::class, + ['Response: ' . $response->body()] + ); + + return; + } + + if ($health_data['status'] !== 'ok' && $health_data['status'] !== 'healthy') { + $data[] = DiagnosticData::warn( + 'AI Vision: Service reported unhealthy status: ' . $health_data['status'], + self::class, + [] + ); + } + } catch (\Illuminate\Http\Client\ConnectionException $e) { + $data[] = DiagnosticData::error( + 'AI Vision: Could not connect to service at ' . rtrim($service_url, '/') . '/health', + self::class, + ['Check that the AI Vision service is running and the URL is correct.', $e->getMessage()] + ); + } catch (\Exception $e) { + // @codeCoverageIgnoreStart + $data[] = DiagnosticData::error( + 'AI Vision: Service health check failed with error: ' . $e->getMessage(), + self::class, + [] + ); + // @codeCoverageIgnoreEnd + } + } +} diff --git a/app/Actions/Photo/Create.php b/app/Actions/Photo/Create.php index 29b6899d123..fea57b05b3d 100644 --- a/app/Actions/Photo/Create.php +++ b/app/Actions/Photo/Create.php @@ -195,6 +195,7 @@ private function handleStandalone(InitDTO $init_dto): Photo Shared\GeodecodeLocation::class, Shared\ExtractColourPalette::class, Shared\NotifyAlbums::class, + Standalone\AutoScanFacesOnUpload::class, ]; return $this->executePipeOnDTO($pipes, $dto)->getPhoto(); diff --git a/app/Actions/Photo/Pipes/Standalone/AutoScanFacesOnUpload.php b/app/Actions/Photo/Pipes/Standalone/AutoScanFacesOnUpload.php new file mode 100644 index 00000000000..996de3a114f --- /dev/null +++ b/app/Actions/Photo/Pipes/Standalone/AutoScanFacesOnUpload.php @@ -0,0 +1,49 @@ +config_manager->getValueAsString('ai_vision_enabled') !== '1') { + return $state; + } + + // Check if face scanning is enabled + if ($this->config_manager->getValueAsString('ai_vision_face_enabled') !== '1') { + return $state; + } + + // Dispatch face scanning job for the uploaded photo + Log::info("AutoScanFacesOnUpload: dispatching face scan for photo {$state->photo->id}."); + DispatchFaceScanJob::dispatch($state->photo->id); + + return $state; + } +} diff --git a/app/Console/Commands/RescanFailedFaces.php b/app/Console/Commands/RescanFailedFaces.php new file mode 100644 index 00000000000..53179134435 --- /dev/null +++ b/app/Console/Commands/RescanFailedFaces.php @@ -0,0 +1,83 @@ + N minutes back to null, + * making them eligible for a fresh scan + */ +class RescanFailedFaces extends Command +{ + /** + * The name and signature of the console command. + * + * @var string + */ + protected $signature = 'lychee:rescan-failed-faces + {--stuck-pending : Also reset photos stuck in pending state} + {--older-than=60 : Minutes threshold for stuck-pending reset}'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Re-enqueue failed face scan photos; optionally reset stuck pending records.'; + + /** + * Execute the console command. + */ + public function handle(): int + { + $reset_count = 0; + $dispatched = 0; + + // Reset stuck-pending records if requested + if ($this->option('stuck-pending') === true) { + $older_than = (int) $this->option('older-than'); + $cutoff = Carbon::now()->subMinutes($older_than); + + $reset_count = Photo::where('face_scan_status', '=', FaceScanStatus::PENDING->value) + ->where('updated_at', '<', $cutoff) + ->update(['face_scan_status' => null]); + + $this->info("Reset {$reset_count} stuck-pending photo(s) older than {$older_than} minutes."); + Log::info("lychee:rescan-failed-faces reset {$reset_count} stuck-pending records."); + } + + // Re-enqueue failed photos + Photo::query() + ->select('id') + ->where('face_scan_status', '=', FaceScanStatus::FAILED->value) + ->lazyById(200, 'id') + ->each(function (Photo $photo) use (&$dispatched): void { + Photo::where('id', '=', $photo->id)->update(['face_scan_status' => FaceScanStatus::PENDING->value]); + DispatchFaceScanJob::dispatch($photo->id); + $dispatched++; + }); + + $this->info("Dispatched {$dispatched} re-scan job(s) for failed photos."); + Log::info("lychee:rescan-failed-faces dispatched {$dispatched} re-scan jobs."); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/ScanFaces.php b/app/Console/Commands/ScanFaces.php new file mode 100644 index 00000000000..52fbc122412 --- /dev/null +++ b/app/Console/Commands/ScanFaces.php @@ -0,0 +1,67 @@ +option('album'); + + $query = Photo::query()->select('id')->whereNull('face_scan_status'); + + if ($album_id !== null) { + $query->whereHas('albums', fn ($q) => $q->where('albums.id', '=', $album_id)); + } + + $dispatched = 0; + + $query->lazyById(200, 'id')->each(function (Photo $photo) use (&$dispatched): void { + Photo::where('id', '=', $photo->id)->update(['face_scan_status' => FaceScanStatus::PENDING->value]); + DispatchFaceScanJob::dispatch($photo->id); + $dispatched++; + }); + + $this->info("Dispatched {$dispatched} face scan job(s)."); + Log::info("lychee:scan-faces dispatched {$dispatched} jobs."); + + return Command::SUCCESS; + } +} diff --git a/app/Contracts/Http/Requests/HasFace.php b/app/Contracts/Http/Requests/HasFace.php new file mode 100644 index 00000000000..982cfb79f2e --- /dev/null +++ b/app/Contracts/Http/Requests/HasFace.php @@ -0,0 +1,16 @@ +name = $name; + $person->is_searchable = $this->config_manager->getValueAsBool('ai_vision_face_person_is_searchable_default'); + $person->save(); + + return $person; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Admin/Maintenance/BulkScanFaces.php b/app/Http/Controllers/Admin/Maintenance/BulkScanFaces.php new file mode 100644 index 00000000000..9633d34e962 --- /dev/null +++ b/app/Http/Controllers/Admin/Maintenance/BulkScanFaces.php @@ -0,0 +1,46 @@ +configs()->getValueAsBool('ai_vision_enabled')) { + return 0; + } + + return $service->countUnscanedPhotos(); + } + + /** + * Enqueue all unscanned photos for face detection. + * + * @return void + */ + public function do(MaintenanceRequest $request, FaceDetectionService $service): void + { + $service->dispatchUnscanedPhotos(); + } +} diff --git a/app/Http/Controllers/Admin/Maintenance/DestroyDismissedFaces.php b/app/Http/Controllers/Admin/Maintenance/DestroyDismissedFaces.php new file mode 100644 index 00000000000..d678ec1c164 --- /dev/null +++ b/app/Http/Controllers/Admin/Maintenance/DestroyDismissedFaces.php @@ -0,0 +1,66 @@ +configs()->getValueAsBool('ai_vision_enabled')) { + return 0; + } + + return Face::dismissed()->count(); + } + + /** + * Do: hard-delete all dismissed faces, their crop files, and their embeddings. + * + * @return array{deleted_count: int} + */ + public function do(MaintenanceRequest $_request): array + { + $dismissed_faces = Face::dismissed()->get(); + $face_ids = $dismissed_faces->pluck('id')->all(); + $count = 0; + + foreach ($dismissed_faces as $face) { + if ($face->crop_token !== null) { + $tok = $face->crop_token; + $path = 'faces/' . substr($tok, 0, 2) . '/' . substr($tok, 2, 2) . '/' . $tok . '.jpg'; + Storage::disk('images')->delete($path); + } + $face->delete(); + $count++; + } + + if ($face_ids !== []) { + DeleteFaceEmbeddingsJob::dispatch($face_ids); + } + + return ['deleted_count' => $count]; + } +} diff --git a/app/Http/Controllers/Admin/Maintenance/ResetFaceScanStatus.php b/app/Http/Controllers/Admin/Maintenance/ResetFaceScanStatus.php new file mode 100644 index 00000000000..dfd2974356b --- /dev/null +++ b/app/Http/Controllers/Admin/Maintenance/ResetFaceScanStatus.php @@ -0,0 +1,74 @@ +configs()->getValueAsBool('ai_vision_enabled')) { + return 0; + } + + $threshold_minutes = (int) config('features.ai-vision.face-stuck-scan-threshold-minutes', 720); + $cutoff = Carbon::now()->subMinutes($threshold_minutes); + + $stuck_count = Photo::where('face_scan_status', '=', FaceScanStatus::PENDING->value) + ->where('updated_at', '<', $cutoff) + ->count(); + + $failed_count = Photo::where('face_scan_status', '=', FaceScanStatus::FAILED->value) + ->count(); + + return $stuck_count + $failed_count; + } + + /** + * Do: reset both stuck-pending (older than threshold) and failed photos to null. + * + * @return array{reset_count: int} + */ + public function do(MaintenanceRequest $_request): array + { + $threshold_minutes = (int) config('features.ai-vision.face-stuck-scan-threshold-minutes', 720); + $cutoff = Carbon::now()->subMinutes($threshold_minutes); + + // Reset failed scans + $failed_count = Photo::where('face_scan_status', '=', FaceScanStatus::FAILED->value) + ->update(['face_scan_status' => null]); + + // Reset stuck-pending scans + $stuck_count = Photo::where('face_scan_status', '=', FaceScanStatus::PENDING->value) + ->where('updated_at', '<', $cutoff) + ->update(['face_scan_status' => null]); + + return ['reset_count' => $failed_count + $stuck_count]; + } +} diff --git a/app/Http/Controllers/Admin/Maintenance/RunFaceClustering.php b/app/Http/Controllers/Admin/Maintenance/RunFaceClustering.php new file mode 100644 index 00000000000..0421ede1fd5 --- /dev/null +++ b/app/Http/Controllers/Admin/Maintenance/RunFaceClustering.php @@ -0,0 +1,70 @@ +configs()->getValueAsBool('ai_vision_enabled') ? 1 : 0; + } + + /** + * Trigger face clustering in the AI Vision Python service. + * + * @return JsonResponse + */ + public function do(MaintenanceRequest $request): JsonResponse + { + $service_url = config('features.ai-vision-service.face-url', ''); + $api_key = config('features.ai-vision-service.face-api-key', ''); + + if ($service_url === '') { + return response()->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->status() === 202) { + return response()->json(['status' => 'dispatched', 'message' => 'Clustering job accepted; results will be sent via callback.'], 202); + } + + 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/Admin/Maintenance/SyncFaceEmbeddings.php b/app/Http/Controllers/Admin/Maintenance/SyncFaceEmbeddings.php new file mode 100644 index 00000000000..dc6875a3e46 --- /dev/null +++ b/app/Http/Controllers/Admin/Maintenance/SyncFaceEmbeddings.php @@ -0,0 +1,100 @@ +configs()->getValueAsBool('ai_vision_enabled')) { + return 0; + } + + $service = app(FacialRecognitionService::class); + $health = $service->checkHealth(); + + if ($health === null) { + Log::warning('SyncFaceEmbeddings::check — AI Vision service /health returned null.'); + + return 0; + } + + $lychee_count = Face::count(); + $ai_count = $health['embedding_count']; + + return abs($lychee_count - $ai_count); + } + + /** + * Do: synchronize all face embeddings from AI Vision service. + * + * Updates existing faces with latest metadata, preserving is_dismissed flag. + * + * @return array{synced_count: int, missing_in_ai: int} + */ + public function do(MaintenanceRequest $_request): array + { + $service = app(FacialRecognitionService::class); + $export = $service->syncFaceEmbeddings(); + + if ($export === null) { + Log::warning('SyncFaceEmbeddings::do — AI Vision service /embeddings/export returned null.'); + + return ['synced_count' => 0, 'missing_in_ai' => 0]; + } + + $ai_face_ids = []; + $synced = 0; + + foreach ($export['embeddings'] as $item) { + $face_id = $item['lychee_face_id']; + $ai_face_ids[] = $face_id; + + // Update or create face record (preserving is_dismissed if it exists) + $face = Face::find($face_id); + + if ($face !== null) { + // Update existing face, keeping is_dismissed flag + $face->photo_id = $item['photo_id']; + $face->laplacian_variance = $item['laplacian_variance']; + $face->save(); + $synced++; + } else { + Log::warning("SyncFaceEmbeddings::do — Face {$face_id} exists in AI Vision but not in Lychee database."); + } + } + + // Count faces in Lychee that are missing in AI Vision + $lychee_count = Face::count(); + $missing_in_ai = $lychee_count - count($ai_face_ids); + + Log::info("SyncFaceEmbeddings::do — synced {$synced} faces, {$missing_in_ai} in Lychee but not in AI Vision."); + + return ['synced_count' => $synced, 'missing_in_ai' => max(0, $missing_in_ai)]; + } +} diff --git a/app/Http/Controllers/Admin/ModerationController.php b/app/Http/Controllers/Admin/ModerationController.php index fc587006b98..02d521f5395 100644 --- a/app/Http/Controllers/Admin/ModerationController.php +++ b/app/Http/Controllers/Admin/ModerationController.php @@ -60,7 +60,7 @@ public function photo(GetModerationPhotoRequest $request): PhotoResource { /** @var Photo $photo */ $photo = Photo::where('id', $request->photoId()) - ->with(['size_variants', 'palette', 'tags', 'statistics', 'rating', 'albums', 'owner']) + ->with(['size_variants', 'palette', 'tags', 'statistics', 'rating', 'albums', 'owner', 'faces', 'faces.person']) ->firstOrFail(); return new PhotoResource( diff --git a/app/Http/Controllers/Admin/SettingsController.php b/app/Http/Controllers/Admin/SettingsController.php index 19ed5c1ac55..53bd89c74a0 100644 --- a/app/Http/Controllers/Admin/SettingsController.php +++ b/app/Http/Controllers/Admin/SettingsController.php @@ -48,7 +48,8 @@ public function getAll(GetAllConfigsRequest $request, DockerVersionInfo $docker_ ->when($docker_info->isDocker(), fn ($q) => $q->where('not_on_docker', '!=', true)) ->when(!$request->verify()->is_supporter() && !$request->configs()->getValueAsBool('enable_se_preview'), fn ($q) => $q->where('level', '=', 0)) ->when(!$request->verify()->is_pro(), fn ($q) => $q->where('level', '<', 2)) - ->when(config('features.webshop') === false, fn ($q) => $q->where('key', 'NOT LIKE', 'webshop_%')), + ->when(config('features.webshop') === false, fn ($q) => $q->where('key', 'NOT LIKE', 'webshop_%')) + ->when(config('features.ai-vision') === false, fn ($q) => $q->where('key', 'NOT LIKE', 'ai_vision_%')), ])->orderBy('order', 'asc')->get(); return ConfigCategoryResource::collect($editable_configs->filter(fn ($cat) => $cat->configs->isNotEmpty())->values()); diff --git a/app/Http/Controllers/AiVision/AlbumPeopleController.php b/app/Http/Controllers/AiVision/AlbumPeopleController.php new file mode 100644 index 00000000000..9a1f7aa4ff0 --- /dev/null +++ b/app/Http/Controllers/AiVision/AlbumPeopleController.php @@ -0,0 +1,56 @@ +album(); + $user = Auth::user(); + + $query = Person::query() + ->select('persons.*') + ->join('faces', 'faces.person_id', '=', 'persons.id') + ->join('photo_album', 'photo_album.photo_id', '=', 'faces.photo_id') + ->where('photo_album.album_id', '=', $album->id) + ->where('faces.is_dismissed', '=', false) + ->orderBy('persons.name'); + + // Non-admin: only show searchable persons, plus the person linked to the current user + if ($user?->may_administrate !== true) { + $query->searchable($user?->id); + } + + $persons = $query->distinct()->paginate(50); + + return new PaginatedPersonsResource($persons); + } +} diff --git a/app/Http/Controllers/AiVision/FaceClusterController.php b/app/Http/Controllers/AiVision/FaceClusterController.php new file mode 100644 index 00000000000..71d5359639b --- /dev/null +++ b/app/Http/Controllers/AiVision/FaceClusterController.php @@ -0,0 +1,139 @@ +query('page', '1'); + $totals = Face::query() + ->whereNotNull('cluster_label') + ->where('cluster_label', '>', -1) + ->whereNull('person_id') + ->notDismissed() + ->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); + + // Prefetch sample faces for all clusters in the paginated set to avoid N+1 + $cluster_labels = $paginated->pluck('cluster_label')->all(); + $samples_by_cluster = Face::query() + ->whereIn('cluster_label', $cluster_labels) + ->whereNull('person_id') + ->notDismissed() + ->whereNotNull('crop_token') + ->select('cluster_label', 'crop_token', 'confidence') + ->orderBy('cluster_label') + ->orderByDesc('confidence') + ->get() + ->groupBy('cluster_label') + ->map(fn ($faces) => $faces->take(self::SAMPLE_SIZE)->pluck('crop_token')->map( + static fn ($tok) => 'uploads/faces/' . substr($tok, 0, 2) . '/' . substr($tok, 2, 2) . '/' . $tok . '.jpg' + )->all()); + + $items = $paginated->map(function (Face $row) use ($samples_by_cluster): ClusterPreviewResource { + $cluster_label = (int) $row->cluster_label; + $face_count = (int) $row->face_count; // @phpstan-ignore property.notFound (see line 34) + $samples = $samples_by_cluster->get($cluster_label, []); + + return new ClusterPreviewResource($cluster_label, $face_count, $samples); + }); + + $paginator = new LengthAwarePaginator($items, $total, self::PER_PAGE, $page, ['path' => $request->url()]); + + return new PaginatedClustersResource($paginator); + } + + public function assign(ClusterAssignRequest $request, int $label, PersonFactory $person_factory): array + { + $person = $person_factory->findOrCreate($request->person_id, $request->new_person_name); + $count = Face::where('cluster_label', '=', $label) + ->whereNull('person_id') + ->notDismissed() + ->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') + ->notDismissed() + ->update(['is_dismissed' => true]); + + return ['dismissed_count' => $count]; + } + + /** + * Remove selected faces from a cluster by setting cluster_label = NULL. + * Only affects qualifying faces (cluster_label = label, person_id IS NULL, is_dismissed = false). + * + * POST /FaceDetection/clusters/{label}/uncluster + * + * @return array{unclustered_count: int} + */ + public function uncluster(UnclusterFacesRequest $request, int $label): array + { + $count = Face::whereIn('id', $request->face_ids) + ->where('cluster_label', '=', $label) + ->whereNull('person_id') + ->notDismissed() + ->update(['cluster_label' => null]); + + return ['unclustered_count' => $count]; + } + + /** + * List the faces belonging to a cluster (unassigned, not dismissed). + * + * GET /FaceDetection/clusters/{label}/faces + */ + public function faces(ClusterFacesRequest $request, int $label): PaginatedFaceResource + { + $exists = Face::where('cluster_label', '=', $label) + ->whereNull('person_id') + ->notDismissed() + ->exists(); + + if (!$exists) { + abort(404, 'Cluster not found or has no qualifying faces.'); + } + + $paginated = Face::query() + ->where('cluster_label', '=', $label) + ->whereNull('person_id') + ->notDismissed() + ->with(['person', 'suggestions.suggestedFace.person']) + ->orderByDesc('confidence') + ->paginate(50); + + return new PaginatedFaceResource($paginated); + } +} diff --git a/app/Http/Controllers/AiVision/FaceController.php b/app/Http/Controllers/AiVision/FaceController.php new file mode 100644 index 00000000000..9a27adf3910 --- /dev/null +++ b/app/Http/Controllers/AiVision/FaceController.php @@ -0,0 +1,129 @@ +face(); + + if ($request->person_id === null && trim($request->new_person_name ?? '') === '') { + // Unassign: return face to unassigned pool + $face->person_id = null; + } else { + $person = $person_factory->findOrCreate($request->person_id, $request->new_person_name); + $face->person_id = $person->id; + } + $face->save(); + + return FaceResource::fromModel($face->load(['suggestions.suggestedFace.person', 'person'])); + } + + /** + * Toggle the is_dismissed flag on a face. + * Only the photo owner or admin can dismiss/undismiss. + * + * PATCH /Face/{id} + * + * @return FaceResource + */ + public function toggleDismissed(ToggleDismissedRequest $request, string $id): FaceResource + { + $face = $request->face(); + $face->is_dismissed = !$face->is_dismissed; + $face->save(); + + return FaceResource::fromModel($face->load(['suggestions.suggestedFace.person', 'person'])); + } + + /** + * Batch face operations: unassign all selected faces, or assign them to an existing/new person. + * + * POST /Face/batch + * + * @return array{affected_count: int, person_id: string|null} + */ + public function batch(BatchFaceRequest $request, PersonFactory $person_factory): array + { + $face_ids = $request->face_ids; + + if ($request->action === 'unassign') { + $count = Face::whereIn('id', $face_ids)->update(['person_id' => null]); + + return ['affected_count' => $count, 'person_id' => null]; + } + + // action === 'assign' + if ($request->person_id !== null) { + $person = Person::findOrFail($request->person_id); + } else { + $person = $person_factory->findOrCreate(null, $request->new_person_name ?? ''); + } + + $count = Face::whereIn('id', $face_ids)->update(['person_id' => $person->id]); + + return ['affected_count' => $count, 'person_id' => $person->id]; + } + + /** + * Hard-delete all dismissed faces and remove their crop files. + * Admin-only. + * + * DELETE /Face/dismissed + * + * @return array{deleted_count: int} + */ + 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) { + // Delete crop file + if ($face->crop_token !== null) { + $tok = $face->crop_token; + $path = 'faces/' . substr($tok, 0, 2) . '/' . substr($tok, 2, 2) . '/' . $tok . '.jpg'; + Storage::disk('images')->delete($path); + } + $face->delete(); + $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 new file mode 100644 index 00000000000..88caf77331a --- /dev/null +++ b/app/Http/Controllers/AiVision/FaceDetectionController.php @@ -0,0 +1,280 @@ +photoIds(); + $album_id = $request->album()?->id; + $force = $request->force(); + + $service->dispatchPhotos($photo_ids, $album_id, $force); + + return response()->noContent(202); + } + + /** + * Receive face detection results callback from the Python service. + * Authentication is exclusively via X-API-Key header; no user session required. + * + * POST /FaceDetection/results + * + * @return array> + */ + public function results(FaceDetectionResultsRequest $request): array + { + $photo_id = $request->photoId(); + + if ($request->status() === 'error') { + Photo::where('id', '=', $photo_id)->update(['face_scan_status' => FaceScanStatus::FAILED->value]); + Log::info("FaceDetectionController::results — photo {$photo_id} face scan failed: " . ($request->message() ?? 'unknown error')); + + return ['faces' => []]; + } + + // Process success payload + $mapping = $this->processFaceResults($photo_id, $request->faces()); + + Photo::where('id', '=', $photo_id)->update(['face_scan_status' => FaceScanStatus::COMPLETED->value]); + + return ['faces' => $mapping]; + } + + /** + * Admin: enqueue all photos with face_scan_status IS NULL for face detection. + * + * POST /FaceDetection/bulk-scan + * + * @return \Illuminate\Http\Response + */ + public function bulkScan(BulkScanRequest $request, FaceDetectionService $service): \Illuminate\Http\Response + { + $album_id = $request->album()?->id; + + $service->dispatchUnscanedPhotos($album_id); + + 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. + * + * @param string $photo_id + * @param array}> $incoming_faces + * + * @return array + */ + private function processFaceResults(string $photo_id, array $incoming_faces): array + { + $iou_threshold = (float) config('features.ai-vision-service.face-rescan-iou-threshold', self::DEFAULT_IOU_THRESHOLD); + + // Load existing faces for this photo + $old_faces = Face::where('photo_id', '=', $photo_id)->get(); + + // For each incoming face, find best IoU match among old faces + // Track matched old face indices to avoid double-matching + $matched_old_indices = []; + $person_ids_for_new = array_fill(0, count($incoming_faces), null); + + foreach ($incoming_faces as $new_idx => $new_face) { + $best_iou = $iou_threshold; + $best_old_idx = null; + + foreach ($old_faces as $old_idx => $old_face) { + if (in_array($old_idx, $matched_old_indices, true)) { + continue; + } + + $iou = $this->computeIoU( + $new_face['x'], $new_face['y'], $new_face['width'], $new_face['height'], + $old_face->x, $old_face->y, $old_face->width, $old_face->height + ); + + if ($iou > $best_iou) { + $best_iou = $iou; + $best_old_idx = $old_idx; + } + } + + if ($best_old_idx !== null) { + $matched_old_indices[] = $best_old_idx; + $person_ids_for_new[$new_idx] = $old_faces[$best_old_idx]->person_id; + } + } + + // Delete ALL old face records (and their crops) — they will be replaced + foreach ($old_faces as $old_face) { + $this->deleteCropFile($old_face->crop_token); + } + Face::where('photo_id', '=', $photo_id)->delete(); + FaceSuggestion::whereNotExists(function ($query): void { + $query->select(DB::raw(1))->from('faces')->whereColumn('face_suggestions.face_id', 'faces.id'); + })->delete(); + + // Create new face records and return embedding_id → lychee_face_id mapping + $mapping = []; + + foreach ($incoming_faces as $idx => $face_data) { + $tok = Str::random(24); + $crop_path = 'faces/' . substr($tok, 0, 2) . '/' . substr($tok, 2, 2) . '/' . $tok . '.jpg'; + + // Store crop file if provided + if (isset($face_data['crop']) && $face_data['crop'] !== '') { + try { + $decoded = base64_decode($face_data['crop']); + Storage::disk('images')->put($crop_path, $decoded); + } catch (MiscException) { + $tok = null; + } + } else { + $tok = null; + } + + $face = new Face(); + $face->photo_id = $photo_id; + $face->person_id = $person_ids_for_new[$idx]; + $face->x = $face_data['x']; + $face->y = $face_data['y']; + $face->width = $face_data['width']; + $face->height = $face_data['height']; + $face->confidence = $face_data['confidence']; + $face->laplacian_variance = $face_data['laplacian_variance'] ?? 0.0; + $face->crop_token = $tok; + $face->is_dismissed = false; + $face->save(); + + // We filter out the id which no longer exists. + $suggestions = $face_data['suggestions'] ?? []; + $suggestion_ids = collect($suggestions)->map(fn ($sug) => $sug['lychee_face_id']); + $existing_ids = Face::whereIn('id', $suggestion_ids)->pluck('id')->all(); + $valid_suggestions = collect($suggestions)->filter(fn ($sug) => in_array($sug['lychee_face_id'], $existing_ids, true)); + + // Create suggestion records + foreach ($valid_suggestions as $suggestion) { + $sug = new FaceSuggestion(); + $sug->face_id = $face->id; + $sug->suggested_face_id = $suggestion['lychee_face_id']; + $sug->confidence = $suggestion['confidence']; + $sug->save(); + } + + $mapping[] = [ + 'embedding_id' => $face_data['embedding_id'], + 'lychee_face_id' => $face->id, + ]; + } + + return $mapping; + } + + /** + * Compute Intersection over Union (IoU) for two bounding boxes. + * All coordinates in normalized [0,1] space. + */ + private function computeIoU( + float $x1, float $y1, float $w1, float $h1, + float $x2, float $y2, float $w2, float $h2, + ): float { + $ix = max(0.0, min($x1 + $w1, $x2 + $w2) - max($x1, $x2)); + $iy = max(0.0, min($y1 + $h1, $y2 + $h2) - max($y1, $y2)); + $inter = $ix * $iy; + + if ($inter === 0.0) { + return 0.0; + } + + $union = $w1 * $h1 + $w2 * $h2 - $inter; + + return $union > 0.0 ? $inter / $union : 0.0; + } + + /** + * Delete a face crop file from storage if the token is set. + */ + private function deleteCropFile(?string $crop_token): void + { + if ($crop_token === null) { + return; + } + + $path = 'faces/' . substr($crop_token, 0, 2) . '/' . substr($crop_token, 2, 2) . '/' . $crop_token . '.jpg'; + Storage::disk('images')->delete($path); + } +} diff --git a/app/Http/Controllers/AiVision/FaceMaintenanceController.php b/app/Http/Controllers/AiVision/FaceMaintenanceController.php new file mode 100644 index 00000000000..3f7f9d6fbc0 --- /dev/null +++ b/app/Http/Controllers/AiVision/FaceMaintenanceController.php @@ -0,0 +1,65 @@ +query('sort_by'), ['confidence', 'laplacian_variance'], true) + ? $request->query('sort_by') + : 'confidence'; + + $sort_dir = $request->query('sort_dir') === 'desc' ? 'desc' : 'asc'; + + $per_page = max(1, min(200, (int) ($request->query('per_page', 50)))); + + $paginated = Face::with(['photo:id,title', 'person:id,name', 'suggestions']) + ->orderBy($sort_by, $sort_dir) + ->paginate($per_page); + + return new PaginatedFaceResource($paginated); + } + + /** + * Batch-dismiss multiple faces. + * + * POST /Face/maintenance/batch-dismiss + * + * @return array{dismissed_count: int} + */ + public function batchDismiss(BatchDismissFacesRequest $request): array + { + $count = Face::whereIn('id', $request->face_ids) + ->update(['is_dismissed' => true]); + + return ['dismissed_count' => $count]; + } +} + diff --git a/app/Http/Controllers/AiVision/PeopleController.php b/app/Http/Controllers/AiVision/PeopleController.php new file mode 100644 index 00000000000..980783609b5 --- /dev/null +++ b/app/Http/Controllers/AiVision/PeopleController.php @@ -0,0 +1,117 @@ +orderBy('name'); + + if ($user === null || !$user->may_administrate) { + // Non-admin: only show searchable persons, plus the person linked to the current user + $user_id = $user?->id; + $query->where(function ($q) use ($user_id): void { + $q->where('is_searchable', '=', true); + if ($user_id !== null) { + $q->orWhere('user_id', '=', $user_id); + } + }); + } + + $persons = $query->paginate(50); + + return new PaginatedPersonsResource($persons); + } + + /** + * Show a single person. + * + * @return PersonResource + */ + public function show(ShowPersonRequest $request): PersonResource + { + return PersonResource::fromModel($request->person()); + } + + /** + * Create a new Person. + * + * @return PersonResource + */ + public function store(StorePersonRequest $request): PersonResource + { + $is_searchable_default = app(ConfigManager::class)->getValueAsString('ai_vision_face_person_is_searchable_default') === '1'; + + $person = new Person(); + $person->name = $request->name(); + $person->user_id = $request->userId(); + $person->is_searchable = $is_searchable_default; + $person->save(); + + return PersonResource::fromModel($person); + } + + /** + * Update a Person (name, searchability, and/or linked user). + * + * @return PersonResource + */ + public function update(UpdatePersonRequest $request): PersonResource + { + $person = $request->person(); + + if ($request->isSearchable() !== null) { + $person->is_searchable = $request->isSearchable(); + } + + if ($request->name() !== null) { + $person->name = $request->name(); + } + + if ($request->hasUserId()) { + $person->user_id = $request->userId(); + } + + $person->save(); + + return PersonResource::fromModel($person); + } + + /** + * Delete a Person. All associated faces will have their person_id set to null. + */ + public function destroy(DestroyPersonRequest $request): void + { + $person = Person::findOrFail($request->personId()); + $person->delete(); + } +} diff --git a/app/Http/Controllers/AiVision/PersonClaimController.php b/app/Http/Controllers/AiVision/PersonClaimController.php new file mode 100644 index 00000000000..61574a7d7eb --- /dev/null +++ b/app/Http/Controllers/AiVision/PersonClaimController.php @@ -0,0 +1,94 @@ +person(); + + if (!$user->may_administrate) { + // Non-admin: conflict if already claimed by another user + if ($person->user_id !== null && $person->user_id !== $user->id) { + abort(409, 'This person is already claimed by another user.'); + } + + // Non-admin: ensure user doesn't already have a different person + $existing = Person::where('user_id', '=', $user->id)->where('id', '!=', $person->id)->first(); + if ($existing !== null) { + abort(409, 'You have already claimed a different person.'); + } + } else { + // Admin force-claim: clear any existing link for this user + Person::where('user_id', '=', $user->id)->where('id', '!=', $person->id)->update(['user_id' => null]); + } + + $person->user_id = $user->id; + $person->save(); + + return PersonResource::fromModel($person->fresh()); + } + + /** + * Unclaim a Person — remove the user_id link. + * Only the linked user or admin can unclaim. + */ + public function unclaim(UnclaimPersonRequest $request, string $id): void + { + $person = $request->person(); + + $person->user_id = null; + $person->save(); + } + + /** + * Merge source Person into target Person. + * All Face records from source are reassigned to target; source is deleted. + * + * URL: POST /Person/{id}/merge where {id} is the TARGET person (kept). + * Body: source_person_id = the person to be destroyed. + * + * @return PersonResource + */ + public function merge(MergePersonRequest $request, string $id): PersonResource + { + $target = Person::findOrFail($id); + $source = Person::findOrFail($request->sourcePersonId()); + + // Reassign all faces from source to target + Face::where('person_id', '=', $source->id)->update(['person_id' => $target->id]); + + // Delete source person + $source->delete(); + + return PersonResource::fromModel($target->fresh()); + } +} diff --git a/app/Http/Controllers/AiVision/PersonPhotosController.php b/app/Http/Controllers/AiVision/PersonPhotosController.php new file mode 100644 index 00000000000..a6e1968d21c --- /dev/null +++ b/app/Http/Controllers/AiVision/PersonPhotosController.php @@ -0,0 +1,81 @@ +may_administrate === true) && !$person->is_searchable && $person->user_id !== $user?->id) { + abort(403); + } + + $query = Photo::query() + ->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 + if (!($user?->may_administrate === true)) { + $query->where(function ($q) use ($user): void { + // Photos in at least one public album (base_albums.is_public = true) + $q->whereExists(function ($sub): void { + $sub->select(DB::raw(1)) + ->from('photo_album') + ->join('base_albums', 'base_albums.id', '=', 'photo_album.album_id') + ->whereColumn('photo_album.photo_id', 'photos.id') + ->where('base_albums.is_public', '=', true); + }); + + // OR photos owned by the authenticated user + if ($user !== null) { + $q->orWhere('photos.owner_id', '=', $user->id); + } + }); + } + + $paginated = $query->distinct()->paginate(50); + + return new PaginatedPhotosResource( + paginated_photos: $paginated, + album_id: null, + should_downgrade: false, + photo_timeline: null + ); + } +} diff --git a/app/Http/Controllers/AiVision/SelfieClaimController.php b/app/Http/Controllers/AiVision/SelfieClaimController.php new file mode 100644 index 00000000000..3fd1e086542 --- /dev/null +++ b/app/Http/Controllers/AiVision/SelfieClaimController.php @@ -0,0 +1,104 @@ +isConfigured()) { + abort(503, 'AI Vision service is not configured.'); + } + + $selfie = $request->selfie(); + + try { + $data = $facial_recognition_service->matchSelfie($selfie->getRealPath(), $selfie->getClientOriginalName()); + } catch (\Exception $e) { + Log::warning('AI Vision selfie match request failed: ' . $e->getMessage()); + abort(503, 'AI Vision service is unavailable.'); + } finally { + // Discard selfie immediately + unlink($selfie->getRealPath()); + } + + if ($data === null) { + abort(503, 'AI Vision service returned an error.'); + } + + /** @var array{matches: array} $data */ + $matches = $data['matches'] ?? []; + + if ($matches === []) { + abort(404, 'No matching person found for the selfie.'); + } + + $threshold = (float) $config_manager->getValueAsString('ai_vision_face_selfie_confidence_threshold'); + $best_match = $matches[0]; + + if ($best_match['confidence'] < $threshold) { + abort(404, 'No matching person found with sufficient confidence.'); + } + + $face = Face::findOrFail($best_match['lychee_face_id']); + + if ($face->person_id === null) { + abort(404, 'No person associated with the matched face.'); + } + + $person = Person::findOrFail($face->person_id); + + // Check if already claimed by another user + if ($person->user_id !== null && $person->user_id !== $user->id) { + abort(409, 'This person is already claimed by another user.'); + } + + // Ensure user doesn't already have a different person linked + $existing = Person::where('user_id', '=', $user->id)->where('id', '!=', $person->id)->first(); + if ($existing !== null) { + abort(409, 'You have already claimed a different person.'); + } + + // Check if user claims are allowed + if (!$user->may_administrate && !$config_manager->getValueAsBool('ai_vision_face_allow_user_claim')) { + abort(403, 'User claims are not permitted by the administrator.'); + } + + $person->user_id = $user->id; + $person->save(); + + return PersonResource::fromModel($person->fresh()); + } +} diff --git a/app/Http/Middleware/ConfigIntegrity.php b/app/Http/Middleware/ConfigIntegrity.php index d0dfeb71fe9..0f01b53c97b 100644 --- a/app/Http/Middleware/ConfigIntegrity.php +++ b/app/Http/Middleware/ConfigIntegrity.php @@ -105,6 +105,14 @@ class ConfigIntegrity 'contact_form_security_question', 'contact_form_security_answer', 'contact_form_custom_consent_required', + 'ai_vision_enabled', + 'ai_vision_face_enabled', + 'ai_vision_face_overlay_enabled', + 'ai_vision_face_overlay_default_visibility', + 'ai_vision_face_permission_mode', + 'ai_vision_face_selfie_confidence_threshold', + 'ai_vision_face_person_is_searchable_default', + 'ai_vision_face_allow_user_claim', 'guest_upload_trust_level', ]; diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index e19c39cae3c..dc9270793fd 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -27,6 +27,8 @@ class VerifyCsrfToken extends Middleware '/api/v2/Zip', '/api/v2/Shop/Checkout/Finalize/*', '/api/v2/Photo::random', + '/api/v2/FaceDetection/results', // This is only exposed internally and auth with a token. + 'api/v2/FaceDetection/cluster-results', // This is only exposed internally and auth with a token. ]; /** diff --git a/app/Http/Requests/Face/AssignFaceRequest.php b/app/Http/Requests/Face/AssignFaceRequest.php new file mode 100644 index 00000000000..68acbd76e5c --- /dev/null +++ b/app/Http/Requests/Face/AssignFaceRequest.php @@ -0,0 +1,71 @@ +face->photo); + } + + public function rules(): array + { + return [ + 'id' => ['required', new RandomIDRule(false)], + 'person_id' => ['nullable', 'string'], + 'new_person_name' => ['nullable', 'string', 'max:255'], + ]; + } + + public function withValidator(\Illuminate\Validation\Validator $validator): void + { + $validator->after(function (\Illuminate\Validation\Validator $validator): void { + if ($validator->errors()->isNotEmpty()) { + return; + } + + // Allowing both to be null/absent is valid — it means unassign. + // We only reject the case where both new_person_name and person_id are + // provided simultaneously with non-null values (ambiguous intent). + $values = $validator->validated(); + $has_person = isset($values['person_id']) && $values['person_id'] !== null; + $has_name = isset($values['new_person_name']) && $values['new_person_name'] !== null; + if ($has_person && $has_name) { + $validator->errors()->add('person_id', 'Provide either person_id or new_person_name, not both.'); + } + }); + } + + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['id' => $this->route('id')]); + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->face = Face::with('photo')->findOrFail($values['id']); + $this->person_id = $values['person_id'] ?? null; + $this->new_person_name = $values['new_person_name'] ?? 'people.unknown'; + } +} diff --git a/app/Http/Requests/Face/BatchDismissFacesRequest.php b/app/Http/Requests/Face/BatchDismissFacesRequest.php new file mode 100644 index 00000000000..7caff3fd945 --- /dev/null +++ b/app/Http/Requests/Face/BatchDismissFacesRequest.php @@ -0,0 +1,44 @@ + ['required', 'array', 'min:1'], + // TODO remove exist check + 'face_ids.*' => ['required', 'string', 'exists:faces,id'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->face_ids = $values['face_ids']; + } +} diff --git a/app/Http/Requests/Face/BatchFaceRequest.php b/app/Http/Requests/Face/BatchFaceRequest.php new file mode 100644 index 00000000000..b3f6fb3796a --- /dev/null +++ b/app/Http/Requests/Face/BatchFaceRequest.php @@ -0,0 +1,88 @@ +album !== null) { + return Gate::check(AlbumPolicy::CAN_BATCH_FACE_OPS, [AbstractAlbum::class, $this->album]); + } + + // Per-photo check: deny if any face's photo fails the gate. + $face_ids = $this->input('face_ids', []); + if (count($face_ids) === 0) { + return false; + } + + $faces = Face::with('photo')->whereIn('id', $face_ids)->get(); + foreach ($faces as $face) { + if (!Gate::check(PhotoPolicy::CAN_ASSIGN_FACE_ON_PHOTO, $face->photo)) { + return false; + } + } + + return true; + } + + public function rules(): array + { + return [ + 'face_ids' => ['required', 'array', 'min:1'], + 'face_ids.*' => ['required', 'string', 'exists:faces,id'], + 'action' => ['required', 'string', 'in:unassign,assign'], + 'person_id' => ['nullable', 'string', 'exists:persons,id'], + 'new_person_name' => ['nullable', 'string', 'max:255'], + 'album_id' => ['nullable', 'string'], + ]; + } + + public function withValidator(\Illuminate\Validation\Validator $validator): void + { + $validator->after(function (\Illuminate\Validation\Validator $validator): void { + if ($validator->errors()->isNotEmpty()) { + return; + } + + $values = $validator->validated(); + if ($values['action'] === 'assign') { + $has_person = isset($values['person_id']) && $values['person_id'] !== null; + $has_name = isset($values['new_person_name']) && $values['new_person_name'] !== null && $values['new_person_name'] !== ''; + if (!$has_person && !$has_name) { + $validator->errors()->add('person_id', 'Either person_id or new_person_name must be provided for assign action.'); + } + } + }); + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->face_ids = $values['face_ids']; + $this->action = $values['action']; + $this->person_id = $values['person_id'] ?? null; + $this->new_person_name = $values['new_person_name'] ?? null; + $album_id = $values['album_id'] ?? null; + $this->album = $album_id !== null ? Album::find($album_id) : null; + } +} diff --git a/app/Http/Requests/Face/BulkScanRequest.php b/app/Http/Requests/Face/BulkScanRequest.php new file mode 100644 index 00000000000..180eb82d81d --- /dev/null +++ b/app/Http/Requests/Face/BulkScanRequest.php @@ -0,0 +1,58 @@ + ['nullable', new RandomIDRule(true)], + ]; + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $album_id = $values['album_id'] ?? null; + $this->album = $album_id !== null ? Album::findOrFail($album_id) : null; + } + + public function album(): ?Album + { + return $this->album; + } +} diff --git a/app/Http/Requests/Face/ClusterAssignRequest.php b/app/Http/Requests/Face/ClusterAssignRequest.php new file mode 100644 index 00000000000..7be6979bac2 --- /dev/null +++ b/app/Http/Requests/Face/ClusterAssignRequest.php @@ -0,0 +1,41 @@ + ['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'] ?? 'people.unknown'; + } +} diff --git a/app/Http/Requests/Face/ClusterDismissRequest.php b/app/Http/Requests/Face/ClusterDismissRequest.php new file mode 100644 index 00000000000..61fa28a09fd --- /dev/null +++ b/app/Http/Requests/Face/ClusterDismissRequest.php @@ -0,0 +1,23 @@ + */ + private array $labels = []; + + /** @var array */ + private array $suggestions = []; + + public function authorize(): bool + { + $expected_key = config('features.ai-vision-service.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', + 'suggestions.*.suggested_face_id' => 'required|string', + 'suggestions.*.confidence' => 'required|numeric', + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->labels = $values['labels'] ?? []; + $this->suggestions = $values['suggestions'] ?? []; + Log::warning('Received face clustering results with ' . count($this->labels) . ' labels and ' . count($this->suggestions) . ' suggestions.', + [ + 'labels' => $this->labels, + 'suggestions' => $this->suggestions, + ]); + } + + /** + * @return array + */ + public function labels(): array + { + return $this->labels; + } + + /** + * @return array + */ + public function suggestions(): array + { + return $this->suggestions; + } +} diff --git a/app/Http/Requests/Face/DestroyDismissedFacesRequest.php b/app/Http/Requests/Face/DestroyDismissedFacesRequest.php new file mode 100644 index 00000000000..f2aa2ad15aa --- /dev/null +++ b/app/Http/Requests/Face/DestroyDismissedFacesRequest.php @@ -0,0 +1,23 @@ +}> */ + private array $faces = []; + private ?string $error_code = null; + private ?string $message = null; + + /** + * {@inheritDoc} + * + * Validates the X-API-Key header against the configured secret. + */ + public function authorize(): bool + { + $expected_key = config('features.ai-vision-service.face-api-key', ''); + $provided_key = $this->header('X-API-Key', ''); + + return $expected_key !== '' && $provided_key === $expected_key; + } + + /** + * {@inheritDoc} + */ + public function rules(): array + { + // TODO: Remove the photo,id check. The AI Vision service may send results for photos that have been deleted in the meantime, and we want to be able to handle that gracefully. + return [ + 'photo_id' => 'required|string|exists:photos,id', + 'status' => 'required|string|in:success,error', + 'faces' => 'sometimes|array', + 'faces.*.x' => 'required_with:faces|numeric', + 'faces.*.y' => 'required_with:faces|numeric', + 'faces.*.width' => 'required_with:faces|numeric', + 'faces.*.height' => 'required_with:faces|numeric', + 'faces.*.confidence' => 'required_with:faces|numeric', + 'faces.*.laplacian_variance' => 'required_with:faces|numeric', + 'faces.*.embedding_id' => 'required_with:faces|string', + 'faces.*.crop' => 'sometimes|string', + 'faces.*.suggestions' => 'sometimes|array', + 'faces.*.suggestions.*.lychee_face_id' => 'required|string', + 'faces.*.suggestions.*.confidence' => 'required|numeric', + 'error_code' => 'sometimes|string', + 'message' => 'sometimes|string', + ]; + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->photo_id = $values['photo_id']; + $this->status = $values['status']; + $this->faces = $values['faces'] ?? []; + $this->error_code = $values['error_code'] ?? null; + $this->message = $values['message'] ?? null; + } + + public function photoId(): string + { + return $this->photo_id; + } + + public function status(): string + { + return $this->status; + } + + /** + * @return array}> + */ + public function faces(): array + { + return $this->faces; + } + + public function errorCode(): ?string + { + return $this->error_code; + } + + public function message(): ?string + { + return $this->message; + } +} diff --git a/app/Http/Requests/Face/FaceMaintenanceIndexRequest.php b/app/Http/Requests/Face/FaceMaintenanceIndexRequest.php new file mode 100644 index 00000000000..ee1a8a9befb --- /dev/null +++ b/app/Http/Requests/Face/FaceMaintenanceIndexRequest.php @@ -0,0 +1,30 @@ +album !== null) { + return Gate::check(AlbumPolicy::CAN_TRIGGER_SCAN_ON_ALBUM, [AbstractAlbum::class, $this->album]); + } + + // Per-photo check: deny if any photo fails the gate. + $photo_ids = $this->input('photo_ids', []); + if (count($photo_ids) === 0) { + return false; + } + + $photos = Photo::whereIn('id', $photo_ids)->get(); + foreach ($photos as $photo) { + if (!Gate::check(PhotoPolicy::CAN_TRIGGER_SCAN_ON_PHOTO, $photo)) { + return false; + } + } + + return true; + } + + /** + * {@inheritDoc} + */ + public function rules(): array + { + return [ + 'photo_ids' => 'nullable|array', + 'photo_ids.*' => ['string', new RandomIDRule(false)], + 'album_id' => ['nullable', new RandomIDRule(true)], + 'force' => 'sometimes|boolean', + ]; + } + + /** + * {@inheritDoc} + */ + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + $values = $validator->validated(); + if (($values['photo_ids'] ?? null) === null && ($values['album_id'] ?? null) === null) { + $validator->errors()->add('photo_ids', 'Either photo_ids or album_id must be provided.'); + } + }); + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->photo_ids = $values['photo_ids'] ?? null; + $album_id = $values['album_id'] ?? null; + $this->album = $album_id !== null ? Album::findOrFail($album_id) : null; + $this->force = isset($values['force']) && static::toBoolean($values['force']); + } + + /** + * @return string[]|null + */ + public function photoIds(): ?array + { + return $this->photo_ids; + } + + public function album(): ?Album + { + return $this->album; + } + + public function force(): bool + { + return $this->force; + } +} diff --git a/app/Http/Requests/Face/ToggleDismissedRequest.php b/app/Http/Requests/Face/ToggleDismissedRequest.php new file mode 100644 index 00000000000..aaf1e6b995f --- /dev/null +++ b/app/Http/Requests/Face/ToggleDismissedRequest.php @@ -0,0 +1,45 @@ +face->photo); + } + + public function rules(): array + { + return [ + 'id' => ['required', new RandomIDRule(false)], + ]; + } + + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['id' => $this->route('id')]); + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->face = Face::with('photo')->findOrFail($values['id']); + } +} diff --git a/app/Http/Requests/Face/UnclusterFacesRequest.php b/app/Http/Requests/Face/UnclusterFacesRequest.php new file mode 100644 index 00000000000..cb7093ba428 --- /dev/null +++ b/app/Http/Requests/Face/UnclusterFacesRequest.php @@ -0,0 +1,39 @@ + ['required', 'array', 'min:1'], + // TODO remove exist check + 'face_ids.*' => ['required', 'string', 'exists:faces,id'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->face_ids = $values['face_ids']; + } +} diff --git a/app/Http/Requests/Person/ClaimPersonRequest.php b/app/Http/Requests/Person/ClaimPersonRequest.php new file mode 100644 index 00000000000..fac3c48807a --- /dev/null +++ b/app/Http/Requests/Person/ClaimPersonRequest.php @@ -0,0 +1,57 @@ + ['required', new RandomIDRule(false)], + ]; + } + + /** + * {@inheritDoc} + */ + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['id' => $this->route('id')]); + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->person = Person::findOrFail($values['id']); + } +} diff --git a/app/Http/Requests/Person/DestroyPersonRequest.php b/app/Http/Requests/Person/DestroyPersonRequest.php new file mode 100644 index 00000000000..9b322f22add --- /dev/null +++ b/app/Http/Requests/Person/DestroyPersonRequest.php @@ -0,0 +1,41 @@ + ['required', 'string', 'exists:persons,id'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->person_id = $values['person_id']; + } + + public function personId(): string + { + return $this->person_id; + } +} diff --git a/app/Http/Requests/Person/GetAlbumPersonsRequest.php b/app/Http/Requests/Person/GetAlbumPersonsRequest.php new file mode 100644 index 00000000000..c568f0cf6fc --- /dev/null +++ b/app/Http/Requests/Person/GetAlbumPersonsRequest.php @@ -0,0 +1,68 @@ +album]) && + Gate::check(AlbumPolicy::CAN_VIEW_ALBUM_PEOPLE, [AbstractAlbum::class, $this->album]); + } + + /** + * {@inheritDoc} + */ + public function rules(): array + { + return [ + RequestAttribute::ALBUM_ID_ATTRIBUTE => ['required', new AlbumIDRule(false)], + ]; + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + // Load album without unnecessary relations for this request + $this->album = Album::without([ + 'cover', 'cover.size_variants', + 'min_privilege_cover', 'min_privilege_cover.size_variants', + 'max_privilege_cover', 'max_privilege_cover.size_variants', + 'thumb', + 'owner', + 'statistics', + ])->find($values[RequestAttribute::ALBUM_ID_ATTRIBUTE]); + + // If not found, throw ModelNotFoundException + $this->album ??= throw new ModelNotFoundException(); + } +} diff --git a/app/Http/Requests/Person/ListPersonsRequest.php b/app/Http/Requests/Person/ListPersonsRequest.php new file mode 100644 index 00000000000..5bb5b605844 --- /dev/null +++ b/app/Http/Requests/Person/ListPersonsRequest.php @@ -0,0 +1,22 @@ + ['required', 'string', 'exists:persons,id'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->source_person_id = $values['source_person_id']; + } + + public function sourcePersonId(): string + { + return $this->source_person_id; + } +} diff --git a/app/Http/Requests/Person/SelfieClaimRequest.php b/app/Http/Requests/Person/SelfieClaimRequest.php new file mode 100644 index 00000000000..fb87105fd70 --- /dev/null +++ b/app/Http/Requests/Person/SelfieClaimRequest.php @@ -0,0 +1,44 @@ + ['required', 'file', 'image', 'max:10240'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + /** @var UploadedFile $selfie */ + $selfie = $files['selfie']; + $this->selfie = $selfie; + } + + public function selfie(): UploadedFile + { + return $this->selfie; + } +} diff --git a/app/Http/Requests/Person/ShowPersonRequest.php b/app/Http/Requests/Person/ShowPersonRequest.php new file mode 100644 index 00000000000..e52c7b42527 --- /dev/null +++ b/app/Http/Requests/Person/ShowPersonRequest.php @@ -0,0 +1,45 @@ +person]); + } + + public function rules(): array + { + return [ + 'id' => ['required', new RandomIDRule(false)], + ]; + } + + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['id' => $this->route('id')]); + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->person = Person::findOrFail($values['id']); + } +} diff --git a/app/Http/Requests/Person/StorePersonRequest.php b/app/Http/Requests/Person/StorePersonRequest.php new file mode 100644 index 00000000000..1ebf1e5cb50 --- /dev/null +++ b/app/Http/Requests/Person/StorePersonRequest.php @@ -0,0 +1,49 @@ + ['required', 'string', 'max:255'], + 'user_id' => ['nullable', 'integer', 'exists:users,id', 'unique:persons,user_id'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->name = $values['name']; + $this->user_id = isset($values['user_id']) ? (int) $values['user_id'] : null; + } + + public function name(): string + { + return $this->name; + } + + public function userId(): ?int + { + return $this->user_id; + } +} diff --git a/app/Http/Requests/Person/UnclaimPersonRequest.php b/app/Http/Requests/Person/UnclaimPersonRequest.php new file mode 100644 index 00000000000..18b61843810 --- /dev/null +++ b/app/Http/Requests/Person/UnclaimPersonRequest.php @@ -0,0 +1,65 @@ +may_administrate || $this->person->user_id === $user->id; + } + + /** + * {@inheritDoc} + */ + public function rules(): array + { + return [ + 'id' => ['required', new RandomIDRule(false)], + ]; + } + + /** + * {@inheritDoc} + */ + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['id' => $this->route('id')]); + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->person = Person::findOrFail($values['id']); + } +} diff --git a/app/Http/Requests/Person/UpdatePersonRequest.php b/app/Http/Requests/Person/UpdatePersonRequest.php new file mode 100644 index 00000000000..db66084822c --- /dev/null +++ b/app/Http/Requests/Person/UpdatePersonRequest.php @@ -0,0 +1,96 @@ +is_searchable !== null && + !Gate::check(AiVisionPolicy::CAN_CHANGE_PERSON_SEARCHABILITY, [Person::class, $this->person])) { + return false; + } + + // Only admins may set user_id + if ($this->user_id !== null && !request()->user()?->may_administrate) { + return false; + } + + return true; + } + + public function rules(): array + { + return [ + 'id' => ['required', new RandomIDRule(false)], + 'name' => ['sometimes', 'string', 'max:255'], + 'is_searchable' => ['sometimes', 'boolean'], + 'user_id' => ['sometimes', 'nullable', 'integer', 'exists:users,id'], + ]; + } + + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['id' => $this->route('id')]); + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->person = Person::findOrFail($values['id']); + $this->name = $values['name'] ?? null; + $this->is_searchable = isset($values['is_searchable']) ? static::toBoolean($values['is_searchable']) : null; + $this->user_id = array_key_exists('user_id', $values) ? ($values['user_id'] === null ? null : (int) $values['user_id']) : -1; + } + + public function name(): ?string + { + return $this->name; + } + + public function isSearchable(): ?bool + { + return $this->is_searchable; + } + + /** + * Returns the new user_id value, or -1 if user_id was not included in the request. + * -1 signals "not provided" since null is a valid value (unlink person from user). + */ + public function userId(): int|null + { + return $this->user_id; + } + + /** + * Whether the request explicitly contains a user_id field. + */ + public function hasUserId(): bool + { + return $this->user_id !== -1; + } +} diff --git a/app/Http/Requests/Traits/HasFaceTrait.php b/app/Http/Requests/Traits/HasFaceTrait.php new file mode 100644 index 00000000000..76000a122f6 --- /dev/null +++ b/app/Http/Requests/Traits/HasFaceTrait.php @@ -0,0 +1,21 @@ +face; + } +} diff --git a/app/Http/Requests/Traits/HasPersonTrait.php b/app/Http/Requests/Traits/HasPersonTrait.php new file mode 100644 index 00000000000..93822fd8800 --- /dev/null +++ b/app/Http/Requests/Traits/HasPersonTrait.php @@ -0,0 +1,21 @@ +person; + } +} diff --git a/app/Http/Resources/Collections/PaginatedClustersResource.php b/app/Http/Resources/Collections/PaginatedClustersResource.php new file mode 100644 index 00000000000..75ed78769f5 --- /dev/null +++ b/app/Http/Resources/Collections/PaginatedClustersResource.php @@ -0,0 +1,41 @@ + */ + #[LiteralTypeScriptType('App.Http.Resources.Models.ClusterPreviewResource[]')] + public Collection $data; + + public int $current_page; + public int $last_page; + public int $per_page; + public int $total; + + /** + * @param LengthAwarePaginator $paginated_clusters + */ + public function __construct(LengthAwarePaginator $paginated_clusters) + { + $this->data = collect($paginated_clusters->items())->values(); + $this->current_page = $paginated_clusters->currentPage(); + $this->last_page = $paginated_clusters->lastPage(); + $this->per_page = $paginated_clusters->perPage(); + $this->total = $paginated_clusters->total(); + } +} diff --git a/app/Http/Resources/Collections/PaginatedFaceResource.php b/app/Http/Resources/Collections/PaginatedFaceResource.php new file mode 100644 index 00000000000..b314aae8b9e --- /dev/null +++ b/app/Http/Resources/Collections/PaginatedFaceResource.php @@ -0,0 +1,41 @@ + */ + #[LiteralTypeScriptType('App.Http.Resources.Models.FaceResource[]')] + public Collection $data; + + public int $current_page; + public int $last_page; + public int $per_page; + public int $total; + + /** + * @param LengthAwarePaginator $paginated_faces + */ + public function __construct(LengthAwarePaginator $paginated_faces) + { + $this->data = collect($paginated_faces->items())->map(fn ($face) => new FaceResource($face))->values(); + $this->current_page = $paginated_faces->currentPage(); + $this->last_page = $paginated_faces->lastPage(); + $this->per_page = $paginated_faces->perPage(); + $this->total = $paginated_faces->total(); + } +} diff --git a/app/Http/Resources/Collections/PaginatedPersonsResource.php b/app/Http/Resources/Collections/PaginatedPersonsResource.php new file mode 100644 index 00000000000..d4b565d4db0 --- /dev/null +++ b/app/Http/Resources/Collections/PaginatedPersonsResource.php @@ -0,0 +1,43 @@ + */ + #[LiteralTypeScriptType('App.Http.Resources.Models.PersonResource[]')] + public Collection $persons; + + public int $current_page; + public int $last_page; + public int $per_page; + public int $total; + + /** + * @param ?LengthAwarePaginator<\App\Models\Person> $paginated_persons + */ + public function __construct(?LengthAwarePaginator $paginated_persons) + { + $this->persons = collect($paginated_persons?->items() ?? []) + ->map(fn ($person) => PersonResource::fromModel($person)); + + $this->current_page = $paginated_persons?->currentPage() ?? 1; + $this->last_page = $paginated_persons?->lastPage() ?? 1; + $this->per_page = $paginated_persons?->perPage() ?? 0; + $this->total = $paginated_persons?->total() ?? 0; + } +} diff --git a/app/Http/Resources/GalleryConfigs/InitConfig.php b/app/Http/Resources/GalleryConfigs/InitConfig.php index d027d252afc..c07d906e07b 100644 --- a/app/Http/Resources/GalleryConfigs/InitConfig.php +++ b/app/Http/Resources/GalleryConfigs/InitConfig.php @@ -54,6 +54,7 @@ class InitConfig extends Data public bool $is_desktop_dock_full_transparency_enabled; public bool $is_mobile_dock_full_transparency_enabled; public bool $is_photo_details_always_open; + public bool $is_face_overlay_visible; // Thumbs configuration public VisibilityType $display_thumb_album_overlay; @@ -177,6 +178,7 @@ public function __construct() $this->is_desktop_dock_full_transparency_enabled = request()->configs()->getValueAsBool('desktop_dock_full_transparency_enabled'); $this->is_mobile_dock_full_transparency_enabled = request()->configs()->getValueAsBool('mobile_dock_full_transparency_enabled'); $this->is_photo_details_always_open = request()->configs()->getValueAsBool('enable_photo_details_always_open'); + $this->is_face_overlay_visible = request()->configs()->getValueAsString('ai_vision_face_overlay_default_visibility') === 'visible'; // Thumbs configuration $this->display_thumb_album_overlay = request()->configs()->getValueAsEnum('display_thumb_album_overlay', VisibilityType::class); diff --git a/app/Http/Resources/Models/ClusterPreviewResource.php b/app/Http/Resources/Models/ClusterPreviewResource.php new file mode 100644 index 00000000000..913384f8dd9 --- /dev/null +++ b/app/Http/Resources/Models/ClusterPreviewResource.php @@ -0,0 +1,34 @@ + Up to 5 sample crop URLs for preview thumbnails. */ + #[LiteralTypeScriptType('string[]')] + 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 new file mode 100644 index 00000000000..8e5c6a95bc1 --- /dev/null +++ b/app/Http/Resources/Models/FaceResource.php @@ -0,0 +1,60 @@ +id = $face->id; + $this->photo_id = $face->photo_id; + $this->person_id = $face->person_id; + $this->x = $face->x; + $this->y = $face->y; + $this->width = $face->width; + $this->height = $face->height; + $this->confidence = $face->confidence; + $this->laplacian_variance = $face->laplacian_variance; + $this->is_dismissed = $face->is_dismissed; + $this->cluster_label = $face->cluster_label; + $this->crop_url = $face->crop_url; + $this->person_name = $face->person?->name; + $this->suggestions = $face->suggestions + ->map(fn (FaceSuggestion $s) => new FaceSuggestionResource($s)) + ->values() + ->all(); + } + + public static function fromModel(Face $face): self + { + return new self($face); + } +} diff --git a/app/Http/Resources/Models/FaceSuggestionResource.php b/app/Http/Resources/Models/FaceSuggestionResource.php new file mode 100644 index 00000000000..44e32375dd6 --- /dev/null +++ b/app/Http/Resources/Models/FaceSuggestionResource.php @@ -0,0 +1,30 @@ +suggested_face_id = $suggestion->suggested_face_id; + $this->crop_url = $suggestion->suggestedFace->crop_url ?? null; + $this->person_name = $suggestion->suggestedFace->person?->name ?? null; + $this->confidence = $suggestion->confidence; + } +} diff --git a/app/Http/Resources/Models/PersonResource.php b/app/Http/Resources/Models/PersonResource.php new file mode 100644 index 00000000000..3794d3ea868 --- /dev/null +++ b/app/Http/Resources/Models/PersonResource.php @@ -0,0 +1,66 @@ +id = $person->id; + $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->face_count; + $this->photo_count = $person->photo_count; + + // 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() + ->notDismissed() + ->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 { + $this->representative_crop_url = null; + } + } + + public static function fromModel(Person $person): self + { + return new self($person); + } +} diff --git a/app/Http/Resources/Models/PhotoResource.php b/app/Http/Resources/Models/PhotoResource.php index 1dd103de490..d37e3654a54 100644 --- a/app/Http/Resources/Models/PhotoResource.php +++ b/app/Http/Resources/Models/PhotoResource.php @@ -12,6 +12,7 @@ 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 +54,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 bool $is_validated; public function __construct(Photo $photo, ?string $album_id, bool $should_downgrade_size_variants) @@ -97,6 +101,46 @@ 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 PhotoPolicy::CAN_VIEW_FACE_OVERLAYS gate. + * 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 (!Gate::check(PhotoPolicy::CAN_VIEW_FACE_OVERLAYS, $photo)) { + return; + } + + $user = Auth::user(); + $is_admin = $user?->may_administrate === true; + + 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/Http/Resources/Rights/AlbumRightsResource.php b/app/Http/Resources/Rights/AlbumRightsResource.php index 3c8f188b155..380c07e988d 100644 --- a/app/Http/Resources/Rights/AlbumRightsResource.php +++ b/app/Http/Resources/Rights/AlbumRightsResource.php @@ -8,6 +8,7 @@ namespace App\Http\Resources\Rights; +use App\Assets\Features; use App\Contracts\Models\AbstractAlbum; use App\Models\Album; use App\Policies\AlbumPolicy; @@ -30,6 +31,10 @@ class AlbumRightsResource extends Data public bool $can_pasword_protect = false; public bool $can_import_from_server = false; public bool $can_make_purchasable = false; + public bool $can_view_album_people = false; + public bool $can_trigger_scan = false; + public bool $can_assign_face = false; + public bool $can_batch_face_ops = false; /** * Given an album, returns the access rights associated to it. @@ -49,6 +54,13 @@ public function __construct( $this->can_pasword_protect = !request()->configs()->getValueAsBool('cache_enabled'); $this->can_import_from_server = Gate::check(AlbumPolicy::CAN_IMPORT_FROM_SERVER, [AbstractAlbum::class]); $this->can_make_purchasable = $this->canMakePurchasable($abstract_album); + + if (Features::active('ai-vision')) { + $this->can_view_album_people = Gate::check(AlbumPolicy::CAN_VIEW_ALBUM_PEOPLE, [AbstractAlbum::class, $abstract_album]); + $this->can_trigger_scan = Gate::check(AlbumPolicy::CAN_TRIGGER_SCAN_ON_ALBUM, [AbstractAlbum::class, $abstract_album]); + $this->can_assign_face = Gate::check(AlbumPolicy::CAN_ASSIGN_FACE_IN_ALBUM, [AbstractAlbum::class, $abstract_album]); + $this->can_batch_face_ops = Gate::check(AlbumPolicy::CAN_BATCH_FACE_OPS, [AbstractAlbum::class, $abstract_album]); + } } /** diff --git a/app/Http/Resources/Rights/ModulesRightsResource.php b/app/Http/Resources/Rights/ModulesRightsResource.php index c9beab21c3a..2a469e860e1 100644 --- a/app/Http/Resources/Rights/ModulesRightsResource.php +++ b/app/Http/Resources/Rights/ModulesRightsResource.php @@ -32,6 +32,8 @@ class ModulesRightsResource extends Data public bool $is_mod_renamer_enabled = false; public bool $is_mod_webshop_enabled = false; public bool $is_mod_webhook_enabled = false; + public bool $is_ai_vision_enabled = false; + public bool $is_face_overlay_enabled = true; public bool $is_contact_enabled = false; public int $messages_count = 0; @@ -47,6 +49,8 @@ public function __construct() $this->is_mod_renamer_enabled = $this->isRenamerEnabled(); $this->is_mod_webshop_enabled = $this->isWebshopEnabled(); $this->is_mod_webhook_enabled = $this->isWebhookEnabled(); + $this->is_ai_vision_enabled = $this->isAiVisionEnabled($is_logged_in); + $this->is_face_overlay_enabled = request()->configs()->getValueAsBool('ai_vision_face_overlay_enabled'); $this->isContactEnabled(); } @@ -208,6 +212,31 @@ private function isWebhookEnabled(): bool return Auth::user()?->may_administrate === true; } + /** + * Check if AI Vision face detection is enabled and accessible to the current user. + * + * The AI Vision feature must be enabled via BOTH: + * 1. The AI_VISION_ENABLED environment variable / feature flag + * 2. The ai_vision_enabled database configuration setting + * + * @param bool $is_logged_in + * + * @return bool true if AI Vision is enabled and accessible, false otherwise + */ + private function isAiVisionEnabled(bool $is_logged_in): bool + { + // Check feature flag first + if (config('features.ai-vision') === false) { + return false; + } + + if (!$is_logged_in) { + return false; + } + + return request()->configs()->getValueAsBool('ai_vision_enabled'); + } + /** * Check if contact is enabled and set the messages count. * diff --git a/app/Http/Resources/Rights/PhotoRightsResource.php b/app/Http/Resources/Rights/PhotoRightsResource.php index c8b84e80712..4808557c797 100644 --- a/app/Http/Resources/Rights/PhotoRightsResource.php +++ b/app/Http/Resources/Rights/PhotoRightsResource.php @@ -8,9 +8,11 @@ namespace App\Http\Resources\Rights; +use App\Assets\Features; use App\Contracts\Models\AbstractAlbum; use App\Models\Photo; use App\Policies\AlbumPolicy; +use App\Policies\PhotoPolicy; use Illuminate\Support\Facades\Gate; use Spatie\LaravelData\Data; use Spatie\TypeScriptTransformer\Attributes\TypeScript; @@ -21,18 +23,30 @@ class PhotoRightsResource extends Data public bool $can_edit; public bool $can_download; public bool $can_access_full_photo; + public bool $can_view_face_overlays = false; + public bool $can_dismiss_face = false; + public bool $can_assign_face = false; + public bool $can_trigger_scan = false; /** * Given a photo, returns the access rights associated to it. * * @param ?AbstractAlbum $album + * @param ?Photo $photo * * @return void */ - public function __construct(?AbstractAlbum $album) + public function __construct(?AbstractAlbum $album, ?Photo $photo = null) { $this->can_edit = Gate::check(AlbumPolicy::CAN_EDIT, [AbstractAlbum::class, $album]); $this->can_download = Gate::check(AlbumPolicy::CAN_DOWNLOAD, [AbstractAlbum::class, $album]); $this->can_access_full_photo = Gate::check(AlbumPolicy::CAN_ACCESS_FULL_PHOTO, [AbstractAlbum::class, $album]); + + if (Features::active('ai-vision') && $photo !== null) { + $this->can_view_face_overlays = Gate::check(PhotoPolicy::CAN_VIEW_FACE_OVERLAYS, $photo); + $this->can_dismiss_face = Gate::check(PhotoPolicy::CAN_DISMISS_FACE, $photo); + $this->can_assign_face = Gate::check(PhotoPolicy::CAN_ASSIGN_FACE_ON_PHOTO, $photo); + $this->can_trigger_scan = Gate::check(PhotoPolicy::CAN_TRIGGER_SCAN_ON_PHOTO, $photo); + } } } \ No newline at end of file diff --git a/app/Jobs/DeleteFaceEmbeddingsJob.php b/app/Jobs/DeleteFaceEmbeddingsJob.php new file mode 100644 index 00000000000..9eb6bee7529 --- /dev/null +++ b/app/Jobs/DeleteFaceEmbeddingsJob.php @@ -0,0 +1,63 @@ + */ + public array $face_ids; + + /** + * @param list $face_ids + */ + public function __construct(array $face_ids) + { + $this->face_ids = $face_ids; + } + + public function handle(FacialRecognitionService $facial_recognition_service): void + { + if ($this->face_ids === []) { + return; + } + + if (!$facial_recognition_service->isConfigured()) { + Log::warning('DeleteFaceEmbeddingsJob: AI Vision service not configured.'); + + return; + } + + try { + $response = $facial_recognition_service->deleteEmbeddings($this->face_ids); + + if (!$response->successful()) { + 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/Jobs/DispatchFaceScanJob.php b/app/Jobs/DispatchFaceScanJob.php new file mode 100644 index 00000000000..a231a5e2b30 --- /dev/null +++ b/app/Jobs/DispatchFaceScanJob.php @@ -0,0 +1,84 @@ +photo_id = $photo_id; + } + + /** + * Execute the job: send the photo to the AI Vision service. + */ + public function handle(FacialRecognitionService $facial_recognition_service): void + { + $photo = Photo::with('size_variants')->find($this->photo_id); + + if ($photo === null) { + Log::warning("DispatchFaceScanJob: photo {$this->photo_id} not found, skipping."); + + return; + } + + if (!$facial_recognition_service->isConfigured()) { + Log::warning("DispatchFaceScanJob: AI Vision service not configured, marking photo {$this->photo_id} as failed."); + $photo->face_scan_status = FaceScanStatus::FAILED; + $photo->save(); + + return; + } + + $original = $photo->size_variants->getOriginal(); + + if ($original === null) { + Log::warning("DispatchFaceScanJob: no original size variant for photo {$this->photo_id}, marking as failed."); + $photo->face_scan_status = FaceScanStatus::FAILED; + $photo->save(); + + return; + } + + try { + $response = $facial_recognition_service->detectFaces($this->photo_id, $original->short_path); + + if (!$response->successful()) { + Log::warning("DispatchFaceScanJob: /detect returned HTTP {$response->status()} for photo {$this->photo_id}.", ['response' => $response->json()]); + $photo->face_scan_status = FaceScanStatus::FAILED; + $photo->save(); + } + } catch (\Exception $e) { + Log::warning("DispatchFaceScanJob: /detect request failed for photo {$this->photo_id}: " . $e->getMessage()); + $photo->face_scan_status = FaceScanStatus::FAILED; + $photo->save(); + } + } +} diff --git a/app/Metadata/Cache/RouteCacheManager.php b/app/Metadata/Cache/RouteCacheManager.php index 0dd89815037..d5acdcff245 100644 --- a/app/Metadata/Cache/RouteCacheManager.php +++ b/app/Metadata/Cache/RouteCacheManager.php @@ -42,6 +42,7 @@ public function __construct() 'api/v2/Album::tags' => new RouteCacheConfig(tag: CacheTag::GALLERY, user_dependant: true, extra: [RequestAttribute::ALBUM_ID_ATTRIBUTE]), 'api/v2/Album::getTargetListAlbums' => false, // TODO: cache me later. 'api/v2/Photo/{photo_id}/albums' => new RouteCacheConfig(tag: CacheTag::GALLERY, user_dependant: true), + 'api/v2/Photo/{photo_id}' => new RouteCacheConfig(tag: CacheTag::GALLERY, user_dependant: true), 'api/v2/Albums' => new RouteCacheConfig(tag: CacheTag::GALLERY, user_dependant: true), 'api/v2/Auth::config' => new RouteCacheConfig(tag: CacheTag::SETTINGS, user_dependant: true), 'api/v2/Auth::rights' => new RouteCacheConfig(tag: CacheTag::SETTINGS, user_dependant: true), @@ -170,6 +171,22 @@ public function __construct() // No need to cache this. 'api/v2/Security/Advisories' => false, + + // AI Vision — People & Faces: do not cache, user/content-dependent. + 'api/v2/People' => false, + 'api/v2/Person/{id}' => false, + 'api/v2/Person/{id}/photos' => false, + 'api/v2/Face/maintenance' => false, + 'api/v2/FaceDetection/clusters' => false, + 'api/v2/FaceDetection/clusters/{label}/faces' => false, + 'api/v2/Album/{album_id}/people' => false, + + // AI Vision — Maintenance: never cache. + 'api/v2/Maintenance::bulkScanFaces' => false, + 'api/v2/Maintenance::runFaceClustering' => false, + 'api/v2/Maintenance::destroyDismissedFaces' => false, + 'api/v2/Maintenance::syncFaceEmbeddings' => false, + 'api/v2/Maintenance::resetFaceScanStatus' => false, ]; } diff --git a/app/Models/Face.php b/app/Models/Face.php new file mode 100644 index 00000000000..63234a45a73 --- /dev/null +++ b/app/Models/Face.php @@ -0,0 +1,176 @@ + $suggestions + * @property string|null $crop_url + * + * @method static \Illuminate\Database\Eloquent\Builder notDismissed() + * @method static \Illuminate\Database\Eloquent\Builder dismissed() + */ +class Face extends Model +{ + use HasFactory; + /** @phpstan-use HasRandomIDAndLegacyTimeBasedID */ + use HasRandomIDAndLegacyTimeBasedID; + use ThrowsConsistentExceptions; + use ToArrayThrowsNotImplemented; + + /** + * @var string The type of the primary key + */ + protected $keyType = 'string'; + + /** + * Indicates if the model's primary key is auto-incrementing. + * + * @var bool + */ + public $incrementing = false; + + /** + * @var list + */ + protected $fillable = [ + 'photo_id', + 'person_id', + 'x', + 'y', + 'width', + 'height', + 'confidence', + 'laplacian_variance', + 'crop_token', + 'is_dismissed', + 'cluster_label', + ]; + + /** + * @var list + */ + protected $appends = [ + 'crop_url', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'x' => 'float', + 'y' => 'float', + 'width' => 'float', + 'height' => 'float', + 'confidence' => 'float', + 'laplacian_variance' => 'float', + 'is_dismissed' => 'boolean', + 'cluster_label' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** + * Compute the crop URL from the crop_token. + */ + public function getCropUrlAttribute(): ?string + { + if ($this->crop_token === null) { + return null; + } + $tok = $this->crop_token; + + return 'uploads/faces/' . substr($tok, 0, 2) . '/' . substr($tok, 2, 2) . '/' . $tok . '.jpg'; + } + + /** + * Return the photo this face belongs to. + * + * @return BelongsTo + */ + public function photo(): BelongsTo + { + return $this->belongsTo(Photo::class, 'photo_id', 'id'); + } + + /** + * Return the person associated with this face. + * + * @return BelongsTo + */ + public function person(): BelongsTo + { + return $this->belongsTo(Person::class, 'person_id', 'id'); + } + + /** + * Return the suggestions for this face (pre-computed similar faces). + * + * @return HasMany + */ + public function suggestions(): HasMany + { + return $this->hasMany(FaceSuggestion::class, 'face_id', 'id'); + } + + /** + * Scope: only faces that have not been dismissed. + * + * @param Builder $query + * + * @return Builder + */ + public function scopeNotDismissed(Builder $query): Builder + { + return $query->where('is_dismissed', '=', false); + } + + /** + * Scope: only faces that have been dismissed. + * + * @param Builder $query + * + * @return Builder + */ + public function scopeDismissed(Builder $query): Builder + { + return $query->where('is_dismissed', '=', true); + } +} diff --git a/app/Models/FaceSuggestion.php b/app/Models/FaceSuggestion.php new file mode 100644 index 00000000000..b276162f3c7 --- /dev/null +++ b/app/Models/FaceSuggestion.php @@ -0,0 +1,72 @@ + + */ + protected $fillable = [ + 'face_id', + 'suggested_face_id', + 'confidence', + ]; + + /** + * @var array + */ + protected $casts = [ + 'confidence' => 'float', + ]; + + /** + * Return the face this suggestion belongs to. + * + * @return BelongsTo + */ + public function face(): BelongsTo + { + return $this->belongsTo(Face::class, 'face_id', 'id'); + } + + /** + * Return the suggested face. + * + * @return BelongsTo + */ + public function suggestedFace(): BelongsTo + { + return $this->belongsTo(Face::class, 'suggested_face_id', 'id'); + } +} diff --git a/app/Models/Person.php b/app/Models/Person.php new file mode 100644 index 00000000000..a0b273eea87 --- /dev/null +++ b/app/Models/Person.php @@ -0,0 +1,139 @@ + $faces + * @property Face|null $representativeFace + */ +class Person extends Model +{ + use HasFactory; + /** @phpstan-use HasRandomIDAndLegacyTimeBasedID */ + use HasRandomIDAndLegacyTimeBasedID; + use ThrowsConsistentExceptions; + use ToArrayThrowsNotImplemented; + + protected $table = 'persons'; + + /** + * @var string The type of the primary key + */ + protected $keyType = 'string'; + + /** + * Indicates if the model's primary key is auto-incrementing. + * + * @var bool + */ + public $incrementing = false; + + /** + * @var list + */ + protected $fillable = [ + 'name', + 'user_id', + 'is_searchable', + 'representative_face_id', + 'face_count', + 'photo_count', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'is_searchable' => 'boolean', + 'user_id' => 'integer', + 'representative_face_id' => 'string', + 'face_count' => 'integer', + 'photo_count' => 'integer', + ]; + } + + /** + * Return the user linked to this person. + * + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id', 'id'); + } + + /** + * Return the faces associated with this person. + * + * @return HasMany + */ + public function faces(): HasMany + { + return $this->hasMany(Face::class, 'person_id', 'id'); + } + + /** + * Return the representative face for this person (nullable). + * + * @return BelongsTo + */ + public function representativeFace(): BelongsTo + { + return $this->belongsTo(Face::class, 'representative_face_id', 'id'); + } + + /** + * Scope to only include persons visible to a given user. + * Always includes searchable persons; if $user_id is provided, also includes + * the person linked to that user. + * + * @param Builder $query + * @param int|null $user_id + * + * @return Builder + */ + public function scopeSearchable( + Builder $query, + ?int $user_id = null, + ): Builder { + return $query->where(function (Builder $q) use ($user_id): void { + $q->where('persons.is_searchable', '=', true); + if ($user_id !== null) { + $q->orWhere('persons.user_id', '=', $user_id); + } + }); + } +} diff --git a/app/Models/Photo.php b/app/Models/Photo.php index 958d97ef242..fcaf34682b0 100644 --- a/app/Models/Photo.php +++ b/app/Models/Photo.php @@ -13,6 +13,7 @@ use App\Casts\MustNotSetCast; use App\Constants\PhotoAlbum as PA; use App\Contracts\Models\HasUTCBasedTimes; +use App\Enum\FaceScanStatus; use App\Enum\LicenseType; use App\Enum\SmartAlbumType; use App\Enum\StorageDiskType; @@ -172,6 +173,8 @@ class Photo extends Model implements HasUTCBasedTimes 'altitude' => 'float', 'img_direction' => 'float', 'rating_avg' => 'decimal:4', + 'face_scan_status' => FaceScanStatus::class, + 'face_count' => 'integer', ]; /** @@ -274,6 +277,16 @@ public function palette(): HasOne return $this->hasOne(Palette::class, 'photo_id', 'id'); } + /** + * Return the faces detected in this photo. + * + * @return HasMany + */ + public function faces(): HasMany + { + return $this->hasMany(Face::class, 'photo_id', 'id'); + } + /** * Returns the relationship between a tag and all photos with whom * this tag is attached. diff --git a/app/Models/User.php b/app/Models/User.php index fa924a1e2d7..b26af2afad4 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -22,6 +22,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Query\Builder as BaseBuilder; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\DatabaseNotification; @@ -184,6 +185,16 @@ public function oauthCredentials(): HasMany return $this->hasMany(OauthCredential::class, 'user_id', 'id'); } + /** + * Return the Person linked to this user (1-1). + * + * @return HasOne + */ + public function person(): HasOne + { + return $this->hasOne(Person::class, 'user_id', 'id'); + } + /** * Used by Larapass. * diff --git a/app/Observers/FaceObserver.php b/app/Observers/FaceObserver.php new file mode 100644 index 00000000000..985795fac63 --- /dev/null +++ b/app/Observers/FaceObserver.php @@ -0,0 +1,145 @@ +is_dismissed) { + return; + } + + DB::transaction(function () use ($face): void { + Photo::where('id', '=', $face->photo_id)->increment('face_count'); + + if ($face->person_id !== null) { + Person::where('id', '=', $face->person_id)->increment('face_count'); + $this->recountPersonPhotos($face->person_id); + } + }); + } + + /** + * Handle the Face "updated" event. + * Compares old vs new values of is_dismissed and person_id, + * then applies the appropriate counter mutations. + */ + public function updated(Face $face): void + { + $old_dismissed = $face->getOriginal('is_dismissed') === true || $face->getOriginal('is_dismissed') === 1; + $new_dismissed = $face->is_dismissed; + $old_person_id = $face->getOriginal('person_id'); + $new_person_id = $face->person_id; + + $dismissed_changed = $old_dismissed !== $new_dismissed; + $person_changed = $old_person_id !== $new_person_id; + + if (!$dismissed_changed && !$person_changed) { + return; + } + + DB::transaction(function () use ($face, $old_dismissed, $new_dismissed, $old_person_id, $new_person_id, $dismissed_changed, $person_changed): void { + // ── photo.face_count ───────────────────────────────────────── + if ($dismissed_changed) { + if ($new_dismissed) { + // face was just dismissed → remove from photo count + Photo::where('id', '=', $face->photo_id)->decrement('face_count'); + } else { + // face was just undismissed → add to photo count + Photo::where('id', '=', $face->photo_id)->increment('face_count'); + } + } + + // ── person counters ─────────────────────────────────────────── + // Determine the effective "was counting for person" state before update. + $was_counted_for_old_person = !$old_dismissed && $old_person_id !== null; + $is_counted_for_new_person = !$new_dismissed && $new_person_id !== null; + + if ($person_changed) { + // Decrement old person (if it was counted) + if ($was_counted_for_old_person) { + Person::where('id', '=', $old_person_id)->decrement('face_count'); + $this->recountPersonPhotos($old_person_id); + } + + // Increment new person (if it should now count) + if ($is_counted_for_new_person) { + Person::where('id', '=', $new_person_id)->increment('face_count'); + $this->recountPersonPhotos($new_person_id); + } + } elseif ($dismissed_changed && $old_person_id !== null) { + // person_id unchanged but is_dismissed flipped + if ($new_dismissed) { + // dismissed → decrement + Person::where('id', '=', $old_person_id)->decrement('face_count'); + } else { + // undismissed → increment + Person::where('id', '=', $old_person_id)->increment('face_count'); + } + $this->recountPersonPhotos($old_person_id); + } + }); + } + + /** + * Handle the Face "deleted" event. + * Decrements photo.face_count and person counters when the deleted face was active. + */ + public function deleted(Face $face): void + { + if ($face->is_dismissed) { + return; + } + + DB::transaction(function () use ($face): void { + Photo::where('id', '=', $face->photo_id)->decrement('face_count'); + + if ($face->person_id !== null) { + Person::where('id', '=', $face->person_id)->decrement('face_count'); + $this->recountPersonPhotos($face->person_id); + } + }); + } + + /** + * Recount and persist the photo_count for the given person. + * photo_count is the number of distinct photos with non-dismissed faces for the person. + */ + private function recountPersonPhotos(string $person_id): void + { + $count = Face::where('person_id', '=', $person_id) + ->where('is_dismissed', '=', false) + ->distinct('photo_id') + ->count('photo_id'); + + Person::where('id', '=', $person_id)->update(['photo_count' => $count]); + } +} 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/Policies/AiVisionPolicy.php b/app/Policies/AiVisionPolicy.php new file mode 100644 index 00000000000..55f85845960 --- /dev/null +++ b/app/Policies/AiVisionPolicy.php @@ -0,0 +1,205 @@ +may_administrate === true) { + return true; + } + } + + /** + * Get the current permission mode from configuration. + */ + private function getMode(): FacePermissionMode + { + return app(ConfigManager::class)->getValueAsEnum('ai_vision_face_permission_mode', FacePermissionMode::class) ?? FacePermissionMode::RESTRICTED; + } + + /** + * View People page / list persons. + * public: guest; private: logged; privacy-preserving: owner+admin; restricted: admin only. + */ + public function canViewPeople(?User $user): bool + { + $mode = $this->getMode(); + + return match ($mode) { + FacePermissionMode::PUBLIC => true, + FacePermissionMode::PRIVATE => $user !== null, + FacePermissionMode::PRIVACY_PRESERVING => false, // admin handled by before() + FacePermissionMode::RESTRICTED => false, // admin handled by before() + }; + } + + /** + * View a specific Person record. + * Requires canViewPeople access, plus the person must be searchable or linked to the user. + * Admins always pass via before(). + */ + public function canShowPerson(?User $user, Person $person): bool + { + if (!$this->canViewPeople($user)) { + return false; + } + + return $person->is_searchable || $person->user_id === $user?->id; + } + + /** + * Create/edit Person. + * public: logged; private: logged; privacy-preserving: owner+admin; restricted: admin only. + */ + public function canEditPerson(?User $user): bool + { + $mode = $this->getMode(); + + return match ($mode) { + FacePermissionMode::PUBLIC => $user !== null, + FacePermissionMode::PRIVATE => $user !== null, + FacePermissionMode::PRIVACY_PRESERVING => false, // admin handled by before() + FacePermissionMode::RESTRICTED => false, // admin handled by before() + }; + } + + /** + * Assign face to person. + * public: logged; private: logged; privacy-preserving: owner+admin; restricted: admin only. + */ + public function canAssignFace(?User $user): bool + { + // Same rules as edit + return $this->canEditPerson($user); + } + + /** + * Trigger face scan on photos. + * public: logged; private: logged; privacy-preserving: owner+admin; restricted: owner+admin. + */ + public function canTriggerScan(?User $user): bool + { + $mode = $this->getMode(); + + return match ($mode) { + FacePermissionMode::PUBLIC => $user !== null, + FacePermissionMode::PRIVATE => $user !== null, + FacePermissionMode::PRIVACY_PRESERVING => false, // admin handled by before() + FacePermissionMode::RESTRICTED => false, // admin handled by before() + }; + } + + /** + * Claim a person (link to own user account). + * Requires the user to be logged in and user claims to be enabled in config. + * Admins always pass via before(). + */ + public function canClaimPerson(?User $user): bool + { + if ($user === null) { + return false; + } + + return app(ConfigManager::class)->getValueAsBool('ai_vision_face_allow_user_claim'); + } + + /** + * Merge two persons. + * public: logged; private: logged; privacy-preserving: owner+admin; restricted: admin only. + */ + public function canMergePersons(?User $user): bool + { + // Same rules as edit + return $this->canEditPerson($user); + } + + /** + * Dismiss / undismiss a face. + * photo owner or admin (handled at controller level; this gate is for admin check only). + */ + public function canDismissFace(?User $user): bool + { + return $user !== null; + } + + /** + * Check if user can manage a specific person (delete, update searchability). + * Only the linked user or admin can manage. + */ + public function canManagePerson(?User $user, Person $person): bool + { + if ($user === null) { + return false; + } + + return $person->user_id === $user->id; + } + + /** + * Change the is_searchable flag of a Person. + * Only the person's linked user or admin (via before()) may toggle it. + */ + public function canChangePersonSearchability(?User $user, Person $person): bool + { + return $person->user_id === $user?->id; + } +} diff --git a/app/Policies/AlbumPolicy.php b/app/Policies/AlbumPolicy.php index a33a6ba08e9..cc3f304948e 100644 --- a/app/Policies/AlbumPolicy.php +++ b/app/Policies/AlbumPolicy.php @@ -10,6 +10,7 @@ use App\Constants\AccessPermissionConstants as APC; use App\Contracts\Models\AbstractAlbum; +use App\Enum\FacePermissionMode; use App\Enum\MetricsAccess; use App\Enum\PhotoHighlightVisibilityType; use App\Enum\SmartAlbumType; @@ -48,6 +49,10 @@ class AlbumPolicy extends BasePolicy public const CAN_READ_METRICS = 'canReadMetrics'; public const CAN_MAKE_PURCHASABLE = 'canMakePurchasable'; public const CAN_HIGHLIGHT = 'canHighlight'; + public const CAN_VIEW_ALBUM_PEOPLE = 'canViewAlbumPeople'; + public const CAN_TRIGGER_SCAN_ON_ALBUM = 'canTriggerScanOnAlbum'; + public const CAN_ASSIGN_FACE_IN_ALBUM = 'canAssignFaceInAlbum'; + public const CAN_BATCH_FACE_OPS = 'canBatchFaceOps'; /** * This ensures that current album is owned by current user. @@ -667,4 +672,132 @@ public function canMakePurchasable(User $user): bool { return false; } -} \ No newline at end of file + + // ── AI Vision / Face gates ──────────────────────────────────────────── + + /** + * Resolve the current FacePermissionMode from configuration. + */ + private function getFaceMode(): FacePermissionMode + { + return app(ConfigManager::class)->getValueAsEnum('ai_vision_face_permission_mode', FacePermissionMode::class) ?? FacePermissionMode::RESTRICTED; + } + + /** + * Return false if AI Vision is not enabled (ai_vision_enabled + ai_vision_face_enabled). + * Admin bypass is already handled by BasePolicy::before(). + */ + private function isFaceEnabled(): bool + { + $cfg = app(ConfigManager::class); + + return $cfg->getValueAsBool('ai_vision_enabled') && $cfg->getValueAsBool('ai_vision_face_enabled'); + } + + /** + * Check whether the given album is owned by the user. + * Smart albums and null album have no owner concept. + */ + private function isAlbumOwner(?User $user, ?AbstractAlbum $album): bool + { + return $album instanceof BaseAlbum && $this->isOwner($user, $album); + } + + /** + * Check whether the current user may view the people listing for an album. + * + * Permission matrix (admin handled by before()): + * public → album access (canAccess) + * private → logged-in user + * privacy-preserving → album owner only + * restricted → album owner only + */ + public function canViewAlbumPeople(?User $user, ?AbstractAlbum $album): bool + { + if (!$this->isFaceEnabled()) { + return false; + } + + return match ($this->getFaceMode()) { + FacePermissionMode::PUBLIC => $this->canAccess($user, $album), + FacePermissionMode::PRIVATE => $user !== null, + FacePermissionMode::PRIVACY_PRESERVING, + FacePermissionMode::RESTRICTED => $this->isAlbumOwner($user, $album), + }; + } + + /** + * Check whether the current user may trigger a face scan on photos in an album. + * + * Permission matrix (admin handled by before()): + * public → logged-in user + * private → logged-in user + * privacy-preserving → album owner only + * restricted → album owner only + */ + public function canTriggerScanOnAlbum(?User $user, ?AbstractAlbum $album): bool + { + if (!$this->isFaceEnabled()) { + return false; + } + + return match ($this->getFaceMode()) { + FacePermissionMode::PUBLIC => $user !== null, + FacePermissionMode::PRIVATE => $user !== null, + FacePermissionMode::PRIVACY_PRESERVING, + FacePermissionMode::RESTRICTED => $this->isAlbumOwner($user, $album), + }; + } + + /** + * Check whether the current user may assign faces in an album. + * + * Permission matrix (admin handled by before()): + * public → logged-in user + * private → logged-in user + * privacy-preserving → album owner only + * restricted → deny even album owner + */ + public function canAssignFaceInAlbum(?User $user, ?AbstractAlbum $album): bool + { + if (!$this->isFaceEnabled()) { + return false; + } + + return match ($this->getFaceMode()) { + FacePermissionMode::PUBLIC => $user !== null, + FacePermissionMode::PRIVATE => $user !== null, + FacePermissionMode::PRIVACY_PRESERVING => $this->isAlbumOwner($user, $album), + FacePermissionMode::RESTRICTED => false, + }; + } + + /** + * Check whether the current user may perform batch face operations in an album. + * Null album always returns false (no ownership concept). + * + * Permission matrix (admin handled by before()): + * public → logged-in user + * private → logged-in user + * privacy-preserving → album owner only + * restricted → deny even album owner + * null album → deny + */ + public function canBatchFaceOps(?User $user, ?AbstractAlbum $album): bool + { + if (!$this->isFaceEnabled()) { + return false; + } + + if ($album === null) { + return false; + } + + return match ($this->getFaceMode()) { + FacePermissionMode::PUBLIC => $user !== null, + FacePermissionMode::PRIVATE => $user !== null, + FacePermissionMode::PRIVACY_PRESERVING => $this->isAlbumOwner($user, $album), + FacePermissionMode::RESTRICTED => false, + }; + } +} diff --git a/app/Policies/PhotoPolicy.php b/app/Policies/PhotoPolicy.php index 1272137333d..186d596f869 100644 --- a/app/Policies/PhotoPolicy.php +++ b/app/Policies/PhotoPolicy.php @@ -9,6 +9,7 @@ namespace App\Policies; use App\Constants\PhotoAlbum as PA; +use App\Enum\FacePermissionMode; use App\Enum\MetricsAccess; use App\Enum\PhotoHighlightVisibilityType; use App\Exceptions\ConfigurationKeyMissingException; @@ -32,6 +33,10 @@ class PhotoPolicy extends BasePolicy public const CAN_READ_METRICS = 'canReadMetrics'; public const CAN_READ_RATINGS = 'canReadRatings'; public const CAN_HIGHLIGHT = 'canHighlight'; + public const CAN_VIEW_FACE_OVERLAYS = 'canViewFaceOverlays'; + public const CAN_DISMISS_FACE = 'canDismissFace'; + public const CAN_ASSIGN_FACE_ON_PHOTO = 'canAssignFaceOnPhoto'; + public const CAN_TRIGGER_SCAN_ON_PHOTO = 'canTriggerScanOnPhoto'; /** * @throws FrameworkException @@ -305,4 +310,109 @@ private function reduction(Collection $albums, \Closure $reducer): bool false ); } + + // ── AI Vision / Face gates ──────────────────────────────────────────── + + /** + * Resolve the current FacePermissionMode from configuration. + */ + private function getFaceMode(): FacePermissionMode + { + return app(ConfigManager::class)->getValueAsEnum('ai_vision_face_permission_mode', FacePermissionMode::class) ?? FacePermissionMode::RESTRICTED; + } + + /** + * Return false if AI Vision is not enabled (ai_vision_enabled + ai_vision_face_enabled). + * Admin bypass is already handled by BasePolicy::before(). + */ + private function isFaceEnabled(): bool + { + $cfg = app(ConfigManager::class); + + return $cfg->getValueAsBool('ai_vision_enabled') && $cfg->getValueAsBool('ai_vision_face_enabled'); + } + + /** + * Check whether the current user may see face overlays on this photo. + * + * Permission matrix (admin handled by before()): + * public → album access (canSee) + * private → logged-in user + * privacy-preserving → photo owner only + * restricted → photo owner only + */ + public function canViewFaceOverlays(?User $user, Photo $photo): bool + { + if (!$this->isFaceEnabled()) { + return false; + } + + return match ($this->getFaceMode()) { + FacePermissionMode::PUBLIC => $this->canSee($user, $photo), + FacePermissionMode::PRIVATE => $user !== null, + FacePermissionMode::PRIVACY_PRESERVING, + FacePermissionMode::RESTRICTED => $this->isOwner($user, $photo), + }; + } + + /** + * Check whether the current user may dismiss a face on this photo. + * + * Dismiss is always restricted to the photo owner regardless of mode. + * Admin bypass is handled by before(). + */ + public function canDismissFace(?User $user, Photo $photo): bool + { + if (!$this->isFaceEnabled()) { + return false; + } + + return $this->isOwner($user, $photo); + } + + /** + * Check whether the current user may assign a face on this photo to a person. + * + * Permission matrix (admin handled by before()): + * public → logged-in user + * private → logged-in user + * privacy-preserving → photo owner only + * restricted → deny even the owner + */ + public function canAssignFaceOnPhoto(?User $user, Photo $photo): bool + { + if (!$this->isFaceEnabled()) { + return false; + } + + return match ($this->getFaceMode()) { + FacePermissionMode::PUBLIC => $user !== null, + FacePermissionMode::PRIVATE => $user !== null, + FacePermissionMode::PRIVACY_PRESERVING => $this->isOwner($user, $photo), + FacePermissionMode::RESTRICTED => false, + }; + } + + /** + * Check whether the current user may trigger a face scan on this photo. + * + * Permission matrix (admin handled by before()): + * public → logged-in user + * private → logged-in user + * privacy-preserving → photo owner only + * restricted → photo owner only + */ + public function canTriggerScanOnPhoto(?User $user, Photo $photo): bool + { + if (!$this->isFaceEnabled()) { + return false; + } + + return match ($this->getFaceMode()) { + FacePermissionMode::PUBLIC => $user !== null, + FacePermissionMode::PRIVATE => $user !== null, + FacePermissionMode::PRIVACY_PRESERVING, + FacePermissionMode::RESTRICTED => $this->isOwner($user, $photo), + }; + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 6875d26d5ec..17f4d1741a6 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -27,6 +27,10 @@ use App\Metadata\Versions\Remote\GitCommits; use App\Metadata\Versions\Remote\GitTags; use App\Models\Configs; +use App\Models\Face; +use App\Models\Photo; +use App\Observers\FaceObserver; +use App\Observers\PhotoObserver; use App\Policies\AlbumQueryPolicy; use App\Policies\PhotoQueryPolicy; use App\Policies\SettingsPolicy; @@ -114,6 +118,7 @@ public function boot() $this->registerStreamFilters(); $this->registerOctaneSettings(); $this->registerThrottleQueues(); + $this->registerObservers(); } /** @@ -451,4 +456,10 @@ private function registerThrottleQueues(): void return Limit::perSecond(config('features.location_decoding_requests_per_second', 1)); }); } + + private function registerObservers(): void + { + Photo::observe(PhotoObserver::class); + Face::observe(FaceObserver::class); + } } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 8ff4fff84ba..cd6e4b50dd2 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -13,11 +13,14 @@ use App\Models\Album; use App\Models\Configs; use App\Models\Extensions\BaseAlbum; +use App\Models\Face; use App\Models\LiveMetrics; +use App\Models\Person; use App\Models\Photo; use App\Models\Tag; use App\Models\User; use App\Models\UserGroup; +use App\Policies\AiVisionPolicy; use App\Policies\AlbumPolicy; use App\Policies\MetricsPolicy; use App\Policies\PhotoPolicy; @@ -57,6 +60,9 @@ class AuthServiceProvider extends ServiceProvider UserGroup::class => UserGroupPolicy::class, Tag::class => TagPolicy::class, + + Person::class => AiVisionPolicy::class, + Face::class => AiVisionPolicy::class, ]; /** diff --git a/app/Repositories/PhotoRepository.php b/app/Repositories/PhotoRepository.php index 51f925342bf..ddbcdbd779e 100644 --- a/app/Repositories/PhotoRepository.php +++ b/app/Repositories/PhotoRepository.php @@ -62,7 +62,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/app/Services/Image/FaceDetectionService.php b/app/Services/Image/FaceDetectionService.php new file mode 100644 index 00000000000..22b073f7e37 --- /dev/null +++ b/app/Services/Image/FaceDetectionService.php @@ -0,0 +1,131 @@ +buildUnscanedQuery($album_id)->count(); + } + + /** + * Dispatch face scan jobs for unscanned photos (null or failed status). + * + * @param string|null $album_id optional album to scope the query to + * + * @return int number of jobs dispatched + */ + public function dispatchUnscanedPhotos(?string $album_id = null): int + { + return $this->dispatchForQuery($this->buildUnscanedQuery($album_id)); + } + + /** + * Dispatch face scan jobs for specific photos or all photos in an album. + * + * @param string[]|null $photo_ids specific photo IDs to scan, or null to scan all in album + * @param string|null $album_id album ID to scan (required if photo_ids is null) + * @param bool $force if true, rescan even if photo has assigned faces + * + * @return int number of jobs dispatched + */ + public function dispatchPhotos(?array $photo_ids, ?string $album_id, bool $force = false): int + { + $query = Photo::query()->select('id'); + + if ($photo_ids !== null) { + $query->whereIn('id', $photo_ids); + } else { + $query->whereHas('albums', fn ($q) => $q->where('albums.id', '=', $album_id)); + } + + if (!$force) { + // Skip photos that have at least one face with a person assigned + $query->whereDoesntHave('faces', fn ($q) => $q->whereNotNull('person_id')); + } + + return $this->dispatchForQuery($query); + } + + /** + * Build query for photos with null or failed face scan status. + * + * @param string|null $album_id optional album to scope the query to + * + * @return Builder + */ + private function buildUnscanedQuery(?string $album_id = null): Builder + { + $query = Photo::query() + ->select('id') + ->whereNull('face_scan_status') + ->orWhere('face_scan_status', '=', FaceScanStatus::FAILED->value); + + if ($album_id !== null) { + $query->whereHas('albums', fn ($q) => $q->where('albums.id', '=', $album_id)); + } + + return $query; + } + + /** + * Dispatch face scan jobs for all photos matching the given query. + * Updates photos to PENDING status and dispatches DispatchFaceScanJob. + * + * @param Builder $query + * + * @return int number of jobs dispatched + */ + private function dispatchForQuery(Builder $query): int + { + $dispatched = 0; + + $query->lazyById(self::BATCH_SIZE, 'id') + ->chunk(self::BATCH_SIZE) + ->each(function ($chunk) use (&$dispatched): void { + $ids = $chunk->pluck('id')->all(); + + // Set status to PENDING in bulk + Photo::whereIn('id', $ids)->update([ + 'face_scan_status' => FaceScanStatus::PENDING->value, + ]); + + // Dispatch a job for each photo + foreach ($ids as $photo_id) { + DispatchFaceScanJob::dispatch($photo_id); + $dispatched++; + } + }); + + Log::info(__CLASS__ . " — dispatched {$dispatched} face scan jobs."); + + return $dispatched; + } +} diff --git a/app/Services/Image/FacialRecognitionService.php b/app/Services/Image/FacialRecognitionService.php new file mode 100644 index 00000000000..488d9b21e98 --- /dev/null +++ b/app/Services/Image/FacialRecognitionService.php @@ -0,0 +1,171 @@ +service_url = config('features.ai-vision-service.face-url', ''); + $this->api_key = config('features.ai-vision-service.face-api-key', ''); + } + + /** + * Check if the service is configured. + */ + public function isConfigured(): bool + { + return $this->service_url !== ''; + } + + /** + * Match a selfie image against stored face embeddings. + * + * @param string $file_path The path to the selfie image file + * @param string $file_name The original filename + * + * @return array{matches: array}|null + * + * @throws \Exception When the HTTP request fails + */ + public function matchSelfie(string $file_path, string $file_name): ?array + { + if (!$this->isConfigured()) { + Log::warning('FacialRecognitionService: matchSelfie called but service is not configured.'); + + return null; + } + + $response = Http::withHeaders(['X-API-Key' => $this->api_key]) + ->attach('image', file_get_contents($file_path), $file_name) + ->post($this->service_url . '/match'); + + return $response->successful() ? $response->json() : null; + } + + /** + * Detect faces in a photo. + * + * @param string $photo_id The photo ID + * @param string $photo_path The path to the photo + * + * @return Response + * + * @throws \Exception When the HTTP request fails + */ + public function detectFaces(string $photo_id, string $photo_path): Response + { + if (!$this->isConfigured()) { + throw new ExternalComponentMissingException('AI Vision service is not configured.'); + } + + $data = [ + 'photo_id' => $photo_id, + 'photo_path' => $photo_path, + ]; + + return Http::withHeaders(['X-API-Key' => $this->api_key]) + ->post($this->service_url . '/detect', $data); + } + + /** + * Delete face embeddings from the AI Vision service. + * + * @param list $face_ids The face IDs to delete + * + * @return Response + * + * @throws \Exception When the HTTP request fails + */ + public function deleteEmbeddings(array $face_ids): Response + { + if (!$this->isConfigured()) { + throw new ExternalComponentMissingException('AI Vision service is not configured.'); + } + + return Http::withHeaders(['X-API-Key' => $this->api_key]) + ->delete($this->service_url . '/embeddings', ['face_ids' => $face_ids]); + } + + /** + * Make a raw HTTP request to the AI Vision service health endpoint. + * + * @param int $timeout Request timeout in seconds + * + * @return Response + * + * @throws ExternalComponentMissingException When the service is not configured + * @throws \Exception When the HTTP request fails + */ + public function checkHealthRaw(int $timeout = 5): Response + { + if (!$this->isConfigured()) { + throw new ExternalComponentMissingException('AI Vision service is not configured.'); + } + + return Http::withHeaders(['X-API-Key' => $this->api_key]) + ->timeout($timeout) + ->get($this->service_url . '/health'); + } + + /** + * Check the health status of the AI Vision service. + * + * @return array{status: string, model_loaded: bool, embedding_count: int}|null + */ + public function checkHealth(): ?array + { + if (!$this->isConfigured()) { + Log::warning('FacialRecognitionService: checkHealth called but service is not configured.'); + + return null; + } + + try { + $response = $this->checkHealthRaw(); + + return $response->successful() ? $response->json() : null; + } catch (\Exception) { + return null; + } + } + + /** + * Export all face embeddings with metadata for synchronization. + * + * @return array{count: int, embeddings: array}|null + * + * @throws \Exception When the HTTP request fails + */ + public function syncFaceEmbeddings(): ?array + { + if (!$this->isConfigured()) { + Log::warning('FacialRecognitionService: syncFaceEmbeddings called but service is not configured.'); + + return null; + } + + $response = Http::withHeaders(['X-API-Key' => $this->api_key]) + ->get($this->service_url . '/embeddings/export'); + + return $response->successful() ? $response->json() : null; + } +} diff --git a/config/features.php b/config/features.php index 68a8a704c9d..fb1b3136de8 100644 --- a/config/features.php +++ b/config/features.php @@ -209,4 +209,33 @@ | missing macros and test failures. */ 'populate-request-macros' => (bool) env('POPULATE_REQUEST_MACROS', false), + + /* + |-------------------------------------------------------------------------- + | Enable AI Vision / Assisted Vision + |-------------------------------------------------------------------------- + | + | When enabled, users can use facial recognition and AI-powered features + | such as face detection, person management, and photo clustering. + | Requires ai_vision_enabled to be true in the database configs table + | AND this feature flag to be enabled. + | Disabled by default — set AI_VISION_ENABLED=true to activate. + */ + 'ai-vision' => (bool) env('AI_VISION_ENABLED', false), + + /* + |-------------------------------------------------------------------------- + | AI Vision service integration. + |-------------------------------------------------------------------------- + | + | Infrastructure keys for the external AI Vision (facial recognition) service. + | These are NOT stored in the configs table to avoid exposing the service URL + | or shared API key through the admin settings UI. + */ + 'ai-vision-service' => [ + 'face-url' => env('AI_VISION_FACE_URL', ''), + 'face-api-key' => env('AI_VISION_FACE_API_KEY', ''), + 'face-rescan-iou-threshold' => (float) env('AI_VISION_FACE_RESCAN_IOU_THRESHOLD', 0.3), + 'face-stuck-scan-threshold-minutes' => (int) env('AI_VISION_FACE_STUCK_SCAN_THRESHOLD_MINUTES', 720), + ], ]; \ No newline at end of file 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_000001_create_persons_table.php b/database/migrations/2026_03_21_000001_create_persons_table.php new file mode 100644 index 00000000000..c9fc36f6e14 --- /dev/null +++ b/database/migrations/2026_03_21_000001_create_persons_table.php @@ -0,0 +1,42 @@ +char('id', self::RANDOM_ID_LENGTH)->primary(); + $table->string('name', 255)->nullable(false); + $table->unsignedInteger('user_id')->nullable(true)->unique(); + $table->boolean('is_searchable')->default(true); + $table->unsignedInteger('face_count')->default(0); + $table->unsignedInteger('photo_count')->default(0); + $table->timestamps(); + + $table->index('user_id'); + $table->foreign('user_id')->references('id')->on('users')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('persons'); + } +}; diff --git a/database/migrations/2026_03_21_000002_create_faces_table.php b/database/migrations/2026_03_21_000002_create_faces_table.php new file mode 100644 index 00000000000..5fa5481c05c --- /dev/null +++ b/database/migrations/2026_03_21_000002_create_faces_table.php @@ -0,0 +1,69 @@ +char('id', self::RANDOM_ID_LENGTH)->primary(); + $table->char('photo_id', self::RANDOM_ID_LENGTH)->nullable(false); + $table->char('person_id', self::RANDOM_ID_LENGTH)->nullable(true); + $table->float('x')->nullable(false); + $table->float('y')->nullable(false); + $table->float('width')->nullable(false); + $table->float('height')->nullable(false); + $table->float('confidence')->nullable(false); + $table->float('laplacian_variance')->default(0.0); + $table->string('crop_token')->nullable(true); + $table->boolean('is_dismissed')->default(false); + $table->timestamps(); + + $table->index('photo_id'); + $table->index('person_id'); + $table->foreign('photo_id')->references('id')->on('photos')->cascadeOnDelete(); + $table->foreign('person_id')->references('id')->on('persons')->nullOnDelete(); + }); + + Schema::create('face_suggestions', function (Blueprint $table) { + $table->char('face_id', self::RANDOM_ID_LENGTH)->nullable(false); + $table->char('suggested_face_id', self::RANDOM_ID_LENGTH)->nullable(false); + $table->float('confidence')->nullable(false); + + $table->unique(['face_id', 'suggested_face_id']); + $table->foreign('face_id')->references('id')->on('faces')->cascadeOnDelete(); + $table->foreign('suggested_face_id')->references('id')->on('faces')->cascadeOnDelete(); + }); + + Schema::table('photos', function (Blueprint $table) { + $table->string('face_scan_status', 16)->nullable(true)->after('is_highlighted'); + $table->unsignedInteger('face_count')->default(0)->after('face_scan_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('photos', function (Blueprint $table) { + $table->dropColumn(['face_scan_status', 'face_count']); + }); + + Schema::dropIfExists('face_suggestions'); + Schema::dropIfExists('faces'); + } +}; diff --git a/database/migrations/2026_03_21_000003_ai_vision_config.php b/database/migrations/2026_03_21_000003_ai_vision_config.php new file mode 100644 index 00000000000..abe3472039e --- /dev/null +++ b/database/migrations/2026_03_21_000003_ai_vision_config.php @@ -0,0 +1,115 @@ + 'ai_vision_enabled', + 'value' => '0', + 'cat' => self::CAT, + 'type_range' => self::BOOL, + 'description' => 'Enable AI Vision features', + 'details' => 'Master toggle for the AI Vision subsystem. When disabled, all AI Vision endpoints and UI elements are inactive.', + 'is_expert' => false, + 'is_secret' => false, + 'level' => 1, + 'order' => 10, + ], + [ + 'key' => 'ai_vision_face_enabled', + 'value' => '0', + 'cat' => self::CAT, + 'type_range' => self::BOOL, + 'description' => 'Enable facial recognition', + 'details' => 'Enable the facial recognition subsystem. Requires ai_vision_enabled = 1. When disabled, face detection endpoints, People pages, and auto-scan on upload are inactive.', + 'is_expert' => false, + 'is_secret' => false, + 'level' => 1, + 'order' => 11, + ], + [ + 'key' => 'ai_vision_face_permission_mode', + 'value' => 'restricted', + 'cat' => self::CAT, + 'type_range' => 'public|private|privacy-preserving|restricted', + 'description' => 'Permission mode for facial recognition features', + 'details' => 'Controls who can view people, face overlays, and manage faces. Options: public, private, privacy-preserving, restricted.', + 'is_expert' => false, + 'is_secret' => false, + 'level' => 1, + 'order' => 12, + ], + [ + 'key' => 'ai_vision_face_selfie_confidence_threshold', + 'value' => '0.8', + 'cat' => self::CAT, + 'type_range' => self::STRING, + 'description' => 'Minimum confidence threshold for selfie-based person claim', + 'details' => 'Minimum match confidence score (0.0-1.0) required to automatically link a person via selfie upload.', + 'is_expert' => true, + 'is_secret' => false, + 'level' => 1, + 'order' => 13, + ], + [ + 'key' => 'ai_vision_face_person_is_searchable_default', + 'value' => '1', + 'cat' => self::CAT, + 'type_range' => self::BOOL, + 'description' => 'Default searchability for new persons', + 'details' => 'Default value of the is_searchable flag when a new Person record is created.', + 'is_expert' => true, + 'is_secret' => false, + 'level' => 1, + 'order' => 14, + ], + [ + 'key' => 'ai_vision_face_allow_user_claim', + 'value' => '1', + 'cat' => self::CAT, + 'type_range' => self::BOOL, + 'description' => 'Allow users to claim their person', + 'details' => 'When enabled, regular (non-admin) users may claim a Person record to link it to their account. Admins can always claim/unclaim regardless of this setting.', + 'is_expert' => true, + 'is_secret' => false, + 'level' => 1, + 'order' => 15, + ], + [ + 'key' => 'ai_vision_face_overlay_enabled', + 'value' => '1', + 'cat' => self::CAT, + 'type_range' => self::BOOL, + 'description' => 'Enable face overlay on photos', + 'details' => 'Master toggle for face bounding-box overlays. When disabled (0), no face overlays or face circles are shown anywhere in the UI, regardless of detection results.', + 'is_expert' => false, + 'is_secret' => false, + 'level' => 1, + 'order' => 17, + ], + [ + 'key' => 'ai_vision_face_overlay_default_visibility', + 'value' => 'visible', + 'cat' => self::CAT, + 'type_range' => 'visible|hidden', + 'description' => 'Default visibility of face overlay when viewing a photo', + 'details' => 'Sets whether face overlays are shown (visible) or hidden by default when a photo is opened. Users can toggle visibility with the P key.', + 'is_expert' => true, + 'is_secret' => false, + 'level' => 1, + 'order' => 18, + ], + ]; + } +}; diff --git a/database/migrations/2026_03_21_000004_create_ai_vision_category.php b/database/migrations/2026_03_21_000004_create_ai_vision_category.php new file mode 100644 index 00000000000..3240775329f --- /dev/null +++ b/database/migrations/2026_03_21_000004_create_ai_vision_category.php @@ -0,0 +1,37 @@ +insert([ + [ + 'cat' => self::CAT, + 'name' => 'AI Vision', + 'description' => 'This module integrates with an external AI service to provide facial recognition, person management, and automatic face scanning capabilities.', + 'order' => 27, + ], + ]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + DB::table('config_categories')->where('cat', self::CAT)->delete(); + } +}; 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..b9a879f9dc8 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,37 @@ 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_VERIFY_SSL: "${AI_VISION_VERIFY_SSL:-true}" + 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 +162,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..1748ef1bbcf 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,43 @@ 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_VERIFY_SSL: "${AI_VISION_VERIFY_SSL:-true}" + 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 +618,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..e7d187ff58c --- /dev/null +++ b/docs/specs/2-how-to/configure-facial-recognition.md @@ -0,0 +1,231 @@ +# 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_VERIFY_SSL` | Verify SSL certificates when connecting to Lychee. Set to `false` for dev environments with self-signed certificates | `true` | +| `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/2-how-to/docker-compose/docker-compose-ai-vision-only.yaml b/docs/specs/2-how-to/docker-compose/docker-compose-ai-vision-only.yaml new file mode 100644 index 00000000000..0ff06f3bacd --- /dev/null +++ b/docs/specs/2-how-to/docker-compose/docker-compose-ai-vision-only.yaml @@ -0,0 +1,43 @@ +services: + 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: "${AI_VISION_FACE_LYCHEE_API_URL:-http://lychee_api:8000}" + VISION_FACE_API_KEY: "${AI_VISION_API_KEY:-changeme}" + VISION_FACE_VERIFY_SSL: "${AI_VISION_VERIFY_SSL:-true}" + VISION_FACE_PHOTOS_PATH: "/data/photos" + VISION_FACE_STORAGE_PATH: "/data/embeddings" + VISION_FACE_WORKERS: "${AI_VISION_WORKERS:-1}" + volumes: + - ../../../../public/uploads:/data/photos:ro + - ai_vision_embeddings:/data/embeddings + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + networks: + - local + + +volumes: + ai_vision_embeddings: + name: lychee_ai_vision_embeddings + driver: local + +networks: + local: + driver: bridge \ No newline at end of file diff --git a/docs/specs/3-reference/database-schema.md b/docs/specs/3-reference/database-schema.md index 5b24201c441..c7032bc294a 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,33 @@ 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 | + --- -*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..aba03c7e4d3 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 @@ -2,7 +2,7 @@ _Linked specification:_ `docs/specs/4-architecture/features/030-ai-vision-service/spec.md` _Status:_ Draft -_Last updated:_ 2026-03-18 +_Last updated:_ 2026-04-11 > Guardrail: Keep this plan traceable back to the governing spec. Reference FR/NFR/Scenario IDs from `spec.md` where relevant, log any new high- or medium-impact questions in [docs/specs/4-architecture/open-questions.md](../../open-questions.md), and assume clarifications are resolved only when the spec's normative sections and, where applicable, ADRs have been updated. @@ -29,10 +29,20 @@ Enable Lychee users to browse their photo library by the people who appear in th - Feature and unit tests for all backend functionality. - **Python facial recognition service**: face detection, embedding generation, clustering, similarity matching, REST API with callback support. - **Docker image**: Dockerfile for the Python service, docker-compose integration, deployment documentation. + - **Face dismiss UX**: Dismiss button in modal + CTRL+click shortcut on overlays *(Q-030-54)*. + - **Maintenance blocks**: Destroy dismissed faces, reset stuck/failed scans with conditional visibility *(Q-030-55)*. + - **Batch face operations**: Multi-select faces, unassign/reassign/uncluster *(Q-030-56, Q-030-58)*. + - **Unassign face from person**: Return face to unassigned pool *(Q-030-57)*. + - **Person miniature in dropdowns**: Circular crop in assignment/merge dropdowns *(Q-030-59)*. + - **Face circles in detail panel**: Photo sidebar shows circular face crops with click/CTRL+click interactions *(Q-030-60)*. + - **Face overlay config**: Global enable/disable toggle + default visibility setting + P-key toggle *(Q-030-61)*. + - **Album people endpoint**: List persons found in an album *(Q-030-62)*. + - **Merge person UI**: Modal with person search and miniatures *(Q-030-58)*. - **Out of scope:** - - Training custom face recognition models (use pre-trained models like InsightFace/dlib/face_recognition). - - Cluster review/confirmation UI (Lychee consumes cluster suggestions; dedicated review workflow is a follow-up). + - Training custom face recognition models (use pre-trained models like DeepFace/dlib/face_recognition). + - Per-user face overlay preferences (deferred — currently global config only). + - Policy refinement for album/photo edit rights cross-check (deferred — Q-030-63). ## Dependencies & Interfaces @@ -79,7 +89,7 @@ After each increment, verify: ## Increment Map -> **Implementation order: Python service first (I1–I3), then PHP/Lychee backend (I4–I12), then frontend (I13–I18), then docs (I19).** +> **Implementation order: Python service first (I1–I3), then PHP/Lychee backend (I4–I12), then frontend (I13–I18), then docs (I19), then clustering & embedding sync (I20–I22), then face UX enhancements (I23–I30), then UX polish & maintenance (I31–I37).** ### Phase 1: Python Facial Recognition Service @@ -89,7 +99,7 @@ After each increment, verify: - _Preconditions:_ Inter-service contract finalized (spec appendix). - _Steps:_ 1. Create project structure: `ai-vision-service/` with `pyproject.toml` (uv), `app/`, `tests/`, `Dockerfile`. Configure ruff and ty in `pyproject.toml`. - 2. Integrate InsightFace (ONNX Runtime backend) with `buffalo_l` model pack. Typed wrapper around InsightFace API. + 2. Integrate DeepFace (ArcFace recognition + RetinaFace detector backend). Typed wrapper around DeepFace API. 3. Create Pydantic models (`app/api/schemas.py`): `DetectRequest`, `FaceResult`, `DetectCallbackPayload`, `AppSettings` (BaseSettings). 4. Implement face detection (`app/detection/detector.py`): accept photo filesystem path, return bounding boxes (0.0–1.0 relative) + confidence scores. Full type annotations. 5. Implement embedding generation (`app/embeddings/`): extract face embeddings, store in SQLite+sqlite-vec (default) or PostgreSQL+pgvector. Abstract `EmbeddingStore` protocol with typed implementations. @@ -120,8 +130,8 @@ After each increment, verify: - _Steps:_ 1. Finalize Dockerfile: multi-stage build (builder with `uv sync --frozen --no-dev`, runtime with slim Python base), GPU support optional. 2. docker-compose integration: add face-recognition service to Lychee's docker-compose with shared photos volume and internal network. - 3. Environment variable configuration via Pydantic `AppSettings` (`VISION_FACE_`-prefixed): `VISION_FACE_LYCHEE_API_URL`, `VISION_FACE_API_KEY`, `VISION_FACE_MODEL_NAME`, `VISION_FACE_DETECTION_THRESHOLD` (bounding box filter), `VISION_FACE_MATCH_THRESHOLD` (similarity search cutoff), `VISION_FACE_RESCAN_IOU_THRESHOLD` (IoU on re-scan), `VISION_FACE_MAX_FACES_PER_PHOTO` (default 10), `VISION_FACE_THREAD_POOL_SIZE`, `VISION_FACE_STORAGE_BACKEND`, `VISION_FACE_STORAGE_PATH`, `VISION_FACE_PHOTOS_PATH`, `VISION_FACE_WORKERS`, `VISION_FACE_LOG_LEVEL`. - 4. Startup: FastAPI lifespan handler loads `buffalo_l` model (baked into image at build time; no download on first run — Q-030-32 resolved). Workers count exposed via CMD shell form to honour `VISION_FACE_WORKERS` env var. + 3. Environment variable configuration via Pydantic `AppSettings` (`VISION_FACE_`-prefixed): `VISION_FACE_LYCHEE_API_URL`, `VISION_FACE_API_KEY`, `VISION_FACE_MODEL_NAME`, `VISION_FACE_DETECTOR_BACKEND`, `VISION_FACE_DETECTION_THRESHOLD` (bounding box filter), `VISION_FACE_MATCH_THRESHOLD` (similarity search cutoff), `VISION_FACE_RESCAN_IOU_THRESHOLD` (IoU on re-scan), `VISION_FACE_MAX_FACES_PER_PHOTO` (default 10), `VISION_FACE_THREAD_POOL_SIZE`, `VISION_FACE_STORAGE_BACKEND`, `VISION_FACE_STORAGE_PATH`, `VISION_FACE_PHOTOS_PATH`, `VISION_FACE_WORKERS`, `VISION_FACE_LOG_LEVEL`. + 4. Startup: FastAPI lifespan handler loads ArcFace + RetinaFace models (baked into image at build time; no download on first run — Q-030-32 resolved). Workers count exposed via CMD shell form to honour `VISION_FACE_WORKERS` env var. 5. Create `.github/workflows/python_ai_vision.yml`: lint (ruff), typecheck (ty check), test (pytest --cov, Python 3.13+3.14 matrix), docker-build. Uses `astral-sh/setup-uv@v5`. Follows existing Lychee CI patterns (harden-runner, pinned actions, concurrency groups). 6. Smoke test: docker-compose up → health check passes → detect endpoint responds. - _Commands:_ `docker build .`, `docker-compose up` @@ -139,7 +149,7 @@ After each increment, verify: 3. Create migration for `face_suggestions` table: `face_id` (string, FK→faces CASCADE), `suggested_face_id` (string, FK→faces CASCADE), `confidence` (float); unique constraint on `(face_id, suggested_face_id)`. *(DO-030-05, Q-030-33)* 4. Add `face_scan_status` nullable `VARCHAR(16)` column to `photos` table. *(DO-030-06, Q-030-38)* 5. Add `persons.user_id` index. - 6. Add config entries migration (`cat = 'AI Vision'`, `level = 1` / SE): `ai_vision_enabled` (0|1, default 0), `ai_vision_face_enabled` (0|1, default 0), `ai_vision_face_permission_mode` (string, default `restricted`), `ai_vision_face_selfie_confidence_threshold` (float, default 0.8), `ai_vision_face_person_is_searchable_default` (0|1, default 1), `ai_vision_face_allow_user_claim` (0|1, default 1), `ai_vision_face_scan_batch_size` (integer, default 200). Infrastructure keys (`AI_VISION_FACE_URL`, `AI_VISION_FACE_API_KEY`) stored in `.env` / `config/features.php` only — not in the `configs` table. + 6. Add config entries migration (`cat = 'AI Vision'`, `level = 1` / SE): `ai_vision_enabled` (0|1, default 0), `ai_vision_face_enabled` (0|1, default 0), `ai_vision_face_permission_mode` (string, default `restricted`), `ai_vision_face_selfie_confidence_threshold` (float, default 0.8), `ai_vision_face_person_is_searchable_default` (0|1, default 1), `ai_vision_face_allow_user_claim` (0|1, default 1). Infrastructure keys (`AI_VISION_FACE_URL`, `AI_VISION_FACE_API_KEY`) stored in `.env` / `config/features.php` only — not in the `configs` table. - _Commands:_ `php artisan test` - _Exit:_ Migrations run on test SQLite DB; `php artisan test` passes. @@ -219,7 +229,7 @@ After each increment, verify: - _Steps:_ 1. Write feature tests: trigger scan for photo (202 response), trigger scan for album, receive scan results (Face records created with crop_token), re-scan replaces old faces (old crops deleted), service unavailable (503), auto-scan on upload when enabled. Test both permission modes for scan trigger. 2. Implement FaceDetectionController with `scan` and `results` actions. - 3. Create DispatchFaceScanJob (queued) — sends HTTP request to Python service `POST /detect` with `photo_path` (filesystem path; no `callback_url` in body — Python reads callback URL from env, Q-030-28). API-030-10 body `photo_ids[]` or `album_id`; dispatch in chunks of `ai_vision_face_scan_batch_size` (default 200, Q-030-45). Sets `face_scan_status = pending` on dispatch. + 3. Create DispatchFaceScanJob (queued) — sends HTTP request to Python service `POST /detect` with `photo_path` (filesystem path; no `callback_url` in body — Python reads callback URL from env, Q-030-28). API-030-10 body `photo_ids[]` or `album_id`; dispatch in chunks of 200 (default 200, Q-030-45). Sets `face_scan_status = pending` on dispatch. 4. Create ProcessFaceDetectionResults action — validates X-API-Key, decodes base64 crops and stores at `uploads/faces/{tok[0:2]}/{tok[2:4]}/{tok}.jpg` (Q-030-34), creates Face records with `crop_token`, stores FaceSuggestion rows from `suggestions[]` (Q-030-33). IoU-match old faces on re-scan to preserve `person_id` (Q-030-14/35). Error callback sets `face_scan_status = failed` (Q-030-17). 5. Register routes (scan trigger: per permission mode; results: service-to-service with API key). 6. Hook into photo upload pipeline: listener on PhotoSaved event dispatches DispatchFaceScanJob when `ai_vision_face_enabled = 1`. @@ -245,13 +255,53 @@ After each increment, verify: - _Goal:_ Paginated endpoint listing all photos containing a given Person. - _Preconditions:_ I7 complete. - _Steps:_ - 1. Write feature test: get photos for person (paginated), respects album access control. - 2. Implement PersonPhotosController with paginated query through Face→Photo join. + 1. Write feature test: get photos for person (paginated), respects album access control; verify `next_photo_id` and `previous_photo_id` are set relative to the person's collection (first photo has `previous_photo_id = null`, last has `next_photo_id = null`, middle photos chain correctly). *(Resolved Q-030-74)* + 2. Implement PersonPhotosController with paginated query through Face→Photo join. After fetching the ordered photo page, compute sequential `next_photo_id` / `previous_photo_id` for each photo: `photos[i].next_photo_id = photos[i+1].id` (null for last), `photos[i].previous_photo_id = photos[i-1].id` (null for first). These person-relative values override the album-relative fields on `PhotoResource` so that `PhotoPanel.vue` navigates within the person's collection natively. 3. Register route. - _Commands:_ `php artisan test --filter=PersonPhotos`, `make phpstan` -- _Exit:_ Paginated photos returned; access control respected. +- _Exit:_ Paginated photos returned; access control respected; `next_photo_id`/`previous_photo_id` set relative to the person's collection. -### 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) @@ -351,6 +401,221 @@ After each increment, verify: - _Commands:_ Full quality gate commands. - _Exit:_ All gates green; documentation current. +### Phase 5: Face UX Enhancements (Q-030-54 through Q-030-64) + +### I23 – Face Dismiss UX: Modal Button + CTRL+Click Overlay (≈60 min) + +- _Goal:_ Add dismiss functionality to the FaceAssignmentModal and CTRL+click shortcut on face overlays (desktop only). +- _Preconditions:_ I15 (FaceOverlay.vue) and I16 (FaceAssignmentModal.vue) complete. +- _Steps:_ + 1. **Frontend** — Add "Dismiss" button to FaceAssignmentModal.vue. Clicking calls `PATCH /Face/{id}` to set `is_dismissed = true`, then closes the modal and refreshes overlays. *(FR-030-16)* + 2. **Frontend** — In FaceOverlay.vue, check `isTouchDevice()` from `keybindings-utils.ts`. On non-touch devices only, listen for CTRL `keydown`/`keyup` events on `window`. When CTRL is held, switch all face rectangle CSS to red dashed borders. When a rectangle is clicked in CTRL state, call `PATCH /Face/{id}` directly (no modal). After dismiss, remove the overlay element. Touch devices: no CTRL+click — modal button only. *(Q-030-70: B)* + 3. Write JS unit test: CTRL state toggles overlay CSS classes; click in CTRL state fires dismiss API call. +- _Commands:_ `npm run check`, `npm run format` +- _Exit:_ Dismiss button works in modal; CTRL+click dismiss works on desktop overlays; touch devices unaffected; visual feedback correct; tests green. + +### I24 – Face Overlay Config Settings & P-Key Toggle (≈45 min) + +- _Goal:_ Add config settings for face overlay enable/disable and default visibility; map P key to toggle. +- _Preconditions:_ I4 (config migration pattern established), I15 (FaceOverlay.vue exists). +- _Steps:_ + 1. **PHP** — Add config migration for `ai_vision_face_overlay_enabled` (0|1, default 1) and `ai_vision_face_overlay_default_visibility` (string: `visible`|`hidden`, default `visible`) to the AI Vision category. *(NFR-030-11)* + 2. **Frontend** — In FaceOverlay.vue, gate overlay rendering on `ai_vision_face_overlay_enabled` config. Initialize overlay visibility from `ai_vision_face_overlay_default_visibility`. + 3. **Frontend** — Register `P` key handler (on photo view) to toggle overlay visibility. `P` is confirmed unbound — `F` maps to fullscreen. Use `onKeyStroke('p', ...)` pattern from `@vueuse/core` with `shouldIgnoreKeystroke()` guard. *(Q-030-65: A)* + 4. Write PHP migration test; verify config values accessible. +- _Commands:_ `php artisan test`, `npm run check`, `npm run format` +- _Exit:_ Overlay disabled when config is off; default visibility respected; P key toggles; no key binding conflicts. + +### I25 – Face Circles in Photo Detail Panel (≈60 min) + +- _Goal:_ Display circular face crop thumbnails in the photo details sidebar with click/CTRL+click interactions. +- _Preconditions:_ I15 (face overlays), I16 (FaceAssignmentModal), I23 (CTRL+click dismiss). +- _Steps:_ + 1. **Frontend** — Add "People in this photo" section to `PhotoDetails.vue`. Render a horizontal flex row (`overflow-x: auto`) of circular face crop images (48px, `border-radius: 50%`) with person name label below each. Unassigned faces show "???". Hidden when photo has no faces or when `ai_vision_face_overlay_enabled = 0`. + 2. **Frontend** — Click on a face circle → open FaceAssignmentModal for that face. + 3. **Frontend** — CTRL+click on a face circle (desktop only) → dismiss face directly (same pattern as I23). *(Q-030-70: B — no touch shortcut)* + 4. Overflow handled by horizontal scroll (`overflow-x: auto`) — all circles accessible by scrolling. *(Q-030-71: A)* +- _Commands:_ `npm run check`, `npm run format` +- _Exit:_ Face circles render in detail panel; click/CTRL+click interactions work; overflow handled; tests green. + +### I26 – Batch Face Operations: API + Frontend (≈90 min) + +- _Goal:_ Implement batch face selection, unassign, reassign, uncluster operations in both backend and frontend. +- _Preconditions:_ I9 (face assignment), I22 (cluster review), I14 (person detail). +- _Steps:_ + 1. **PHP** — Implement `POST /api/v2/Face/batch` endpoint in FaceController. Body: `{face_ids: [str], action: "unassign"|"assign", person_id?: str, new_person_name?: str}`. Validation: face_ids non-empty, action valid, person_id or new_person_name for assign. Auth: check assign permission for each face. Returns `{affected_count, person_id?}`. *(FR-030-19, API-030-24)* + 2. **PHP** — Implement `POST /api/v2/FaceDetection/clusters/{cluster_id}/uncluster` endpoint. Body: `{face_ids: [str]}`. Sets `cluster_label = NULL` for qualifying faces. Returns `{unclustered_count}`. *(FR-030-17, API-030-23)* + 3. **PHP** — Write feature tests: batch unassign, batch assign to existing person, batch assign to new person, uncluster faces, auth checks. + 4. **Frontend** — Add "Select Mode" toggle button to PersonDetail.vue and FaceClusters.vue. When active, checkbox overlays appear on each face crop. + 5. **Frontend** — Add action bar (slides in at bottom when faces selected): "Unassign (N)", "Reassign to...", "Assign to new person", "Uncluster" (cluster view only). Each calls the corresponding API. + 6. Create `FaceBatchService.ts` with typed functions. +- _Commands:_ `php artisan test --filter=FaceBatch`, `make phpstan`, `npm run check` +- _Exit:_ Batch operations work end-to-end; select mode activates cleanly; action bar renders; all tests green. + +### I27 – Maintenance Blocks: Dismiss Cleanup + Combined Reset Stuck/Failed Scans (≈60 min) + +- _Goal:_ Add two conditional maintenance blocks for face cleanup and scan reset operations. *(Q-030-73: combined stuck+failed into one block)* +- _Preconditions:_ I9 (dismiss exists), I11 (stuck reset exists), maintenance pattern established. +- _Steps:_ + 1. **PHP** — Implement `DestroyDismissedFaces` maintenance controller with check/do pattern. `check()` returns count of `Face::where('is_dismissed', true)->count()`. `do()` reuses `destroyDismissed` logic from FaceController. *(FR-030-23, API-030-21/21b)* + 2. **PHP** — Implement `ResetFaceScanStatus` maintenance controller (combined stuck + failed). `check()` returns sum of: stuck-pending (>720 min) + `face_scan_status = 'failed'`. `do()` resets both in one DB operation. Returns `{reset_count: N}`. *(FR-030-24, API-030-22/22b, Q-030-73)* + 3. **PHP** — Register maintenance routes in `api_v2.php`. Write feature tests for both check/do endpoints. + 4. **Frontend** — Create `MaintenanceDestroyDismissedFaces.vue`: calls check endpoint on mount, hides when count is 0, "Destroy All" button calls POST endpoint. Follow existing `MaintenanceBulkScanFaces.vue` pattern. + 5. **Frontend** — Create `MaintenanceResetFaceScanStatus.vue` (NOT separate components for stuck and failed — combined per Q-030-73). Same pattern: check on mount, hide when 0, "Reset All" button. + 6. **Frontend** — Add both maintenance cards to `Maintenance.vue` template. +- _Commands:_ `php artisan test --filter=Maintenance`, `make phpstan`, `npm run check` +- _Exit:_ Both maintenance blocks appear conditionally; check returns correct counts; do performs cleanup; cards hidden when count is 0. + +### I28 – Merge Person UI + Person Miniature in Dropdown (≈60 min) + +- _Goal:_ Implement merge person modal and add person miniatures to the face assignment dropdown. +- _Preconditions:_ I8 (merge backend), I16 (FaceAssignmentModal), I14 (PersonDetail). +- _Steps:_ + 1. **Frontend** — Create `MergePersonModal.vue`. Triggered by "Merge into..." button on PersonDetail.vue. Shows source person info, PrimeVue Dropdown with person search (custom option template: 24px circular miniature + name + face count), warning text about merge consequences, Cancel/Merge buttons. On confirm, calls `POST /Person/{id}/merge`. *(FR-030-25)* + 2. **Frontend** — Update FaceAssignmentModal.vue dropdown to use custom `option` template with circular miniature (from `representative_crop_url`), person name, and face count. Use PrimeVue Dropdown's `optionLabel` slot. Fallback placeholder icon when no crop exists. *(FR-030-20)* + 3. After merge, redirect from source person page to target person page. +- _Commands:_ `npm run check`, `npm run format` +- _Exit:_ Merge modal opens from PersonDetail; person search works with miniatures; merge executes and redirects; assignment dropdown shows miniatures. + +### I29 – Album People Endpoint (≈45 min) + +- _Goal:_ New endpoint returning persons found in a given album. +- _Preconditions:_ I7 (People CRUD), photo_albums relationship exists. +- _Steps:_ + 1. **PHP** — Implement `AlbumPeopleController` with `index()` action. `GET /api/v2/Album/{id}/people`: query `SELECT DISTINCT persons.* FROM persons JOIN faces ... JOIN photo_albums WHERE photo_albums.album_id = ?` (non-recursive, direct photos only). Return `PaginatedPersonsResource`. Respect `ai_vision_face_permission_mode` and `is_searchable` filtering. *(FR-030-22, API-030-25)* + 2. **PHP** — Create `AlbumPeopleRequest` form request; validate album_id exists, user has album access. + 3. **PHP** — Write feature tests: album with persons returns correct list; empty album returns empty; non-searchable filtering; access control. + 4. **PHP** — Register route in `api_v2.php`. +- _Commands:_ `php artisan test --filter=AlbumPeople`, `make phpstan` +- _Exit:_ Album people endpoint returns correct persons; pagination works; access control verified. + +### I30 – Unassign Face from Person (≈30 min) + +- _Goal:_ Allow unassigning a face from a person, returning it to the unassigned pool. +- _Preconditions:_ I9 (face assignment API). +- _Steps:_ + 1. **PHP** — Update `POST /Face/{id}/assign` to accept `person_id: null` (or empty string) as a valid value that unassigns the face (sets `face.person_id = NULL`). Emit `face.unassigned` telemetry. *(FR-030-18, API-030-25)* + 2. **PHP** — Write feature test: assign face, then unassign (person_id = null); verify face returns to unassigned state. + 3. **Frontend** — In PersonDetail.vue (non-batch mode), add "Remove" button or context menu option on each face crop that calls assign with `person_id: null`. +- _Commands:_ `php artisan test --filter=FaceAssignment`, `make phpstan`, `npm run check` +- _Exit:_ Face unassign works via API; PersonDetail UI allows removal; face returns to unassigned pool. + +### Phase 6: UX Polish & Face Maintenance (FR-030-26 through FR-030-40) + +### I31 – Face Cluster Page UX Overhaul (≈120 min) + +- _Goal:_ Improve the FaceClusters.vue page with Enter-to-submit, existing person dropdown, infinite scrolling, grid layout, and descriptive header. +- _Preconditions:_ I22 complete (FaceClusters.vue exists). +- _Steps:_ + 1. **Frontend** — Replace the "Load more" button with infinite scrolling using `IntersectionObserver`. A sentinel element at the bottom of the cluster list triggers `loadMore()` when it enters the viewport. Show a loading spinner during fetch. Stop observing on last page. *(FR-030-28)* + 2. **Frontend** — Add `@keydown.enter` handler on the name `InputText` to call the assign function (same as clicking "Assign" button). Only fires when the name is non-empty. *(FR-030-26)* + 3. **Frontend** — Add a PrimeVue Dropdown (with type-ahead filter and custom option template: miniature + name + count, reusing the pattern from I28/T-030-67) alongside the name input. The user can either type a new name OR select an existing person. When a person is selected, `assignCluster()` sends `person_id` instead of `new_person_name`. *(FR-030-27)* + 4. **Frontend** — Rework the page layout from vertical list to a responsive grid (`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4`). Each cluster card is a compact unit with face thumbnails, name input, person dropdown, and action buttons in close visual proximity. *(FR-030-31)* + 5. **Frontend** — Move the "Run Clustering" and "Toggle Multi-Select" buttons from the `Toolbar` into the page body (below the header description text). Add a descriptive header paragraph: "Review face clusters to identify people. Assign a name to group similar faces, or dismiss false positives." *(FR-030-31)* +- _Commands:_ `npm run check`, `npm run format` +- _Exit:_ Infinite scroll works; Enter submits name; existing person dropdown available; grid layout renders cleanly; buttons in page body; header description shown. + +### I32 – Face Cluster Detail View + Individual Face Dismiss (≈90 min) + +- _Goal:_ Allow clicking on a cluster to see all its faces and dismiss individual faces from a cluster. +- _Preconditions:_ I31 complete; I22 complete (cluster API exists). +- _Steps:_ + 1. **PHP** — Implement `GET /api/v2/FaceDetection/clusters/{cluster_id}/faces` endpoint (API-030-26): paginated list of all `FaceResource` items for the cluster (`cluster_label = cluster_id AND person_id IS NULL AND is_dismissed = false`). Auth per permission mode. Register route. + 2. **PHP** — Write feature test: list faces for a valid cluster_id (paginated), 404 for unknown cluster_id, permission mode enforcement. + 3. **Frontend** — Create a cluster detail view as a PrimeVue `` *(Resolved Q-030-75)*. Clicking a cluster card (or "+N more" badge) opens the Dialog. Shows a full responsive grid of all face crops in the cluster with a small "×" dismiss badge on each face. At the bottom: name input + existing person dropdown + "Assign All" + "Dismiss All" buttons. *(FR-030-29, FR-030-30)* + 4. **Frontend** — Dismissing a face from the detail view calls `PATCH /Face/{id}` (existing dismiss endpoint), removes the face from the grid, decrements the cluster size. If all faces dismissed, close the detail view and remove the cluster card. +- _Commands:_ `php artisan test --filter=FaceClusterFaces`, `make phpstan`, `npm run check` +- _Exit:_ Cluster detail shows all faces; individual dismiss works; assign from detail view works; all tests green. + +### I33 – People Page: Context Menu + Compact Cards (≈60 min) + +- _Goal:_ Add a context menu to PersonCards and make face thumbnails smaller with rounded corners. +- _Preconditions:_ I13 complete (People.vue and PersonCard.vue exist). +- _Steps:_ + 1. **Frontend** — Add a right-click (or long-press on touch) context menu to PersonCard using PrimeVue `ContextMenu` component. Menu items: "Merge into..." (opens MergePersonModal with this person as source), "Toggle privacy" (calls `PeopleService.update(id, {is_searchable: !current})`), "Assign to user" (admin-only; opens a PrimeVue `` with an autocomplete Dropdown listing user accounts by name + email; on confirm calls `PATCH /Person/{id}` with `{ user_id: selectedUserId }`; requires extending `UpdatePersonRequest` to accept nullable `user_id` with admin-only validation gate) *(Resolved Q-030-76)*, "Remove association" (calls `DELETE /Person/{id}` after confirmation). *(FR-030-32)* + 2. **Frontend** — Reduce PersonCard face crop size (e.g. from 96px to 80px diameter). Add `border-radius: 12px` (or `rounded-xl`) to the PersonCard container for a polished look. Ensure the card layout remains balanced at the smaller size. *(FR-030-33)* +- _Commands:_ `npm run check`, `npm run format` +- _Exit:_ Context menu opens with all four actions; each action calls the correct API; face crops smaller with rounded corners. + +### I34 – Person Detail: Inline Edit, Dark Mode Fix, Compact Remove (≈60 min) + +- _Goal:_ Make the person name editable inline, fix dark mode title visibility, and replace the full-image hover overlay with a compact remove badge. +- _Preconditions:_ I14 complete (PersonDetail.vue exists). +- _Steps:_ + 1. **Frontend** — Replace the separate edit form and pencil icon with inline-editable name text. Clicking the name text switches to an `InputText` (or contenteditable span). Enter saves via `PeopleService.update()`, Escape cancels. Remove the "Edit" button from the toolbar. *(FR-030-34)* + 2. **Frontend** — Fix the person name title color in dark mode. Use theme-aware CSS classes (e.g. `text-text-main-0` or Tailwind `dark:text-white`) so the title is readable against both light and dark backgrounds. *(FR-030-35)* + 3. **Frontend** — Replace the full-image hover overlay ("Remove from person" button covering the entire photo) with a small "×" badge that appears in the top-right corner on hover. Clicking it calls `POST /Face/{id}/assign` with `person_id: null`. The badge should be small (~24px), positioned absolutely, and styled with a semi-transparent background. *(FR-030-37)* +- _Commands:_ `npm run check`, `npm run format` +- _Exit:_ Name editable inline; title readable in dark mode; compact "×" badge replaces full overlay. + +### I35 – Person Detail: Album-Style Layout + Photo Lightbox (≈90 min) + +- _Goal:_ Use the existing album photo layout (justified gallery) in PersonDetail and open photos in the lightbox. +- _Preconditions:_ I14 complete; existing album layout components available. +- _Steps:_ + 1. **Frontend** — Replace the current uniform square grid in PersonDetail.vue with the justified/masonry photo layout component used in album views. Photos should display with their natural aspect ratios. Reuse existing gallery layout components/composables (investigate existing implementation in Album.vue or PhotoLayout component). *(FR-030-36)* + 2. **Frontend** — Wire up photo click (when not in select mode) to open the photo lightbox/overlay — the same viewer used in album views with navigation arrows, EXIF data sidebar, etc. The lightbox should scope its navigation to the current person's photos. *(FR-030-39)* + 3. **Frontend** — Integrate infinite scrolling (IntersectionObserver pattern) to replace the "Load more" button for consistency with the cluster page. +- _Commands:_ `npm run check`, `npm run format` +- _Exit:_ Photos render in justified layout with natural aspect ratios; clicking opens lightbox with navigation; infinite scroll works. + +### I36 – Person Detail: Multi-Select with Drag & Blue Border (≈90 min) + +- _Goal:_ Implement album-style multi-select with blue border highlights, Shift+click range select, and drag-to-select. +- _Preconditions:_ I35 complete (album-style layout in place); existing selection patterns in album views available for reuse. +- _Steps:_ + 1. **Frontend** — Replace the current checkbox-based batch selection in PersonDetail.vue with blue-border selection (matching album selection style). Selected photos show a blue border highlight instead of a checkbox overlay. *(FR-030-38)* + 2. **Frontend** — Implement Shift+click range select: clicking a photo, then Shift+clicking another photo, selects all photos between them (inclusive). Follow existing album selection pattern. + 3. **Frontend** — Implement drag-to-select (rubber-band selection): mousedown on empty area starts a selection rectangle; all face/photo tiles intersecting the rectangle are selected. Reuse existing drag-select composable if available in album views. + 4. **Frontend** — Wire selected faces to the existing batch action bar (unassign/reassign). +- _Commands:_ `npm run check`, `npm run format` +- _Exit:_ Click-to-select with blue border works; Shift+click range select works; drag-to-select works; batch actions operate on selected items. + +### I37 – Face Maintenance Admin Page (≈120 min) + +- _Goal:_ Create a face quality review page where admins can sort faces by confidence and blur score, and dismiss low-quality detections. +- _Preconditions:_ I4 complete (faces table); Python service stores Laplacian scores. +- _Steps:_ + 1. **Python** — Extend the `FaceResult` Pydantic schema in `app/api/schemas.py` to include a `laplacian_variance: float` field in the detection callback payload. In `app/detection/detector.py`, compute and include the Laplacian variance value for each detected face (already computed for blur filtering; now also returned in the callback). + 2. **Python** — Update tests to verify `laplacian_variance` is included in callback payload. + 3. **PHP** — Create migration: `ALTER TABLE faces ADD COLUMN laplacian_variance FLOAT NULL` (DO-030-09). Update Face model fillable and casts. + 4. **PHP** — Update `ProcessFaceDetectionResults` action to store `laplacian_variance` from the callback payload (nullable — existing faces will have NULL). + 5. **PHP** — Implement `GET /api/v2/Face/maintenance` endpoint (API-030-27): admin-only; returns paginated list of faces with extended fields: `id`, `crop_url`, `photo_id`, `photo_thumb_url`, `person_name` (nullable), `cluster_label` (nullable), `confidence`, `laplacian_variance` (nullable). Supports `sort_by` query param (`confidence` | `laplacian_variance`, default `confidence`) and `sort_dir` (`asc` | `desc`, default `asc`). Register route. + 6. **PHP** — Write feature tests: list faces sorted by confidence asc, sorted by laplacian_variance asc, admin-only auth, pagination. + 7. **Frontend** — Create `FaceMaintenance.vue` page (admin-only, add route and navigation link in maintenance area). PrimeVue DataTable with sortable columns: face crop (img), photo thumbnail (img), person name, cluster label, confidence, blur score. Clicking a row allows dismissing the face (confirmation + `PATCH /Face/{id}`). Descriptive header text: "Review detected face quality. Sort by confidence or blur score to find low-quality detections." +- _Commands:_ `uv run pytest`, `uv run ruff check`, `php artisan test --filter=FaceMaintenance`, `make phpstan`, `npm run check` +- _Exit:_ Laplacian score stored on faces; maintenance endpoint returns sortable face list; admin page renders with sortable table; dismiss from table works; all tests green. + +### I38 – Denormalized Face & Photo Counters (≈60 min) + +- _Goal:_ Replace runtime `COUNT` queries in `PersonResource` and `PhotoResource` with denormalized counter columns maintained by an Eloquent observer on `Face`. +- _Preconditions:_ I5 (Person & Face models), I6 (PersonResource), I9 (Face dismiss logic) complete. +- _Steps:_ + 1. Write unit tests for the counter invariants (see tasks T-030-93, T-030-94): assign face → counters increment; unassign → decrement; dismiss → decrement; undismiss → increment; delete non-dismissed face → decrement; dismissed faces never counted; photo_count counts distinct photos only. + 2. Update Person model: add `face_count` and `photo_count` to `$fillable` and `$casts` (`integer`). Update PHPDoc block. *(Columns added directly to the existing persons migration — greenfield.)* + 3. Update Photo model: add `face_count` to `$fillable` and `$casts`. Update PHPDoc block. *(Column added directly to the existing faces migration alongside `face_scan_status` — greenfield.)* + 4. Implement `FaceObserver` (`app/Observers/FaceObserver.php`): handle `creating`, `updating` (delta on `person_id` and `is_dismissed` changes), and `deleting` events. Use DB transactions to update counters atomically. For `photo_count` on Person: after any counter-affecting change, recount `faces.count(DISTINCT photo_id) WHERE person_id = ? AND is_dismissed = false` and write the result (avoids double-decrement edge cases when multiple faces share a photo+person pair). + 5. Register `FaceObserver` in a service provider. + 6. Update `PersonResource`: replace `$person->faces()->count()` with `$person->face_count` and `$person->faces()->distinct('photo_id')->count('photo_id')` with `$person->photo_count`. +- _Commands:_ `php artisan test --filter=FaceCounterTest`, `php artisan test --filter=PersonResourceTest`, `make phpstan` +- _Exit:_ Counter columns exist in existing migrations; observer keeps them in sync; PersonResource reads columns directly; all unit and feature tests pass; PHPStan clean. + +### I39 – Per-Resource Face Access Rights (≈75 min) + +- _Goal:_ Implement proper per-photo and per-album ownership checks in `PhotoPolicy` and `AlbumPolicy` for all face operations; surface the resulting right flags in `PhotoRightsResource` and `AlbumRightsResource`; wire request authorizers to the new gates. Resolves Q-030-63 and Q-030-72. +- _Preconditions:_ I5 (models), I6 (resources), I7 (controllers), I9 (face ops) complete. +- _Steps:_ + 1. Write feature tests covering the four permission modes × ownership scenarios for each new gate (S-030-61 through S-030-65). Tests verify 403 for non-owners in privacy-preserving/restricted modes and 200 for owners with the same operations. + 2. Add four gate constants + methods to `PhotoPolicy` (FR-030-43): `CAN_VIEW_FACE_OVERLAYS`, `CAN_DISMISS_FACE`, `CAN_ASSIGN_FACE_ON_PHOTO`, `CAN_TRIGGER_SCAN_ON_PHOTO`. Each method accepts `(?User, Photo)` and resolves mode + ownership. Delegate feature-flag/admin short-circuit via `AiVisionPolicy`. + 3. Add four gate constants + methods to `AlbumPolicy` (FR-030-44): `CAN_VIEW_ALBUM_PEOPLE`, `CAN_TRIGGER_SCAN_ON_ALBUM`, `CAN_ASSIGN_FACE_IN_ALBUM`, `CAN_BATCH_FACE_OPS`. Each method accepts `(?User, AbstractAlbum|null)`. + 4. Register new gate abilities in `AuthServiceProvider` (or existing registration location for `PhotoPolicy`/`AlbumPolicy`). + 5. Extend `PhotoRightsResource` (DO-030-12): add optional `?Photo $photo` parameter, compute `can_view_face_overlays`, `can_dismiss_face`, `can_assign_face`, `can_trigger_scan` via Gate checks. Update all call sites to pass the `Photo` instance. + 6. Extend `AlbumRightsResource` (DO-030-13): add `can_view_album_people`, `can_trigger_scan`, `can_assign_face`, `can_batch_face_ops` via Gate checks. No constructor change needed. + 7. Update request authorizers (FR-030-47): `AssignFaceRequest`, `ToggleDismissedRequest` (remove `// TODO` + inline ownership check), `BatchFaceRequest`, `ScanPhotosRequest`, `GetAlbumPersonsRequest`. Replace `AiVisionPolicy` gate calls with new `PhotoPolicy`/`AlbumPolicy` gate calls. Replace `PhotoResource::buildFaceData()` inline mode-check with `Gate::check(PhotoPolicy::CAN_VIEW_FACE_OVERLAYS, $photo)`. + 8. Regenerate TypeScript type declarations (`npm run generate-types` or equivalent) to include the new rights fields. +- _Commands:_ `php artisan test --filter=FaceAccessRightsTest`, `make phpstan`, `npm run check` +- _Exit:_ All four modes + ownership scenarios tested; rights resources include face flags; all TODOs in request classes removed; PHPStan clean; TypeScript types updated. + ## Scenario Tracking | Scenario ID | Increment / Task reference | Notes | @@ -381,6 +646,45 @@ 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 | +| S-030-33 | I23 | Dismiss button in FaceAssignmentModal | +| S-030-34 | I23 | CTRL+click dismiss on face overlays (red dashed borders) | +| S-030-35 | I26 | Uncluster selected faces from a cluster | +| S-030-36 | I30 | Unassign face from person (person_id = NULL) | +| S-030-37 | I26 | Batch select + reassign faces in person detail | +| S-030-38 | I25 | Face circles in photo detail panel (click → modal) | +| S-030-39 | I25 | CTRL+click face circle in detail panel → dismiss | +| S-030-40 | I29 | Album people endpoint returns persons in album | +| S-030-41 | I24 | P key toggles face overlay visibility | +| S-030-42 | I27 | Maintenance destroy dismissed faces block | +| S-030-43 | I27 | Maintenance reset failed face scans block | +| S-030-44 | I24 | Face overlay disabled when config is off | +| S-030-45 | I28 | Person merge from UI with merge modal | +| S-030-46 | I28 | Person miniature in face assignment dropdown | +| S-030-47 | I31 | Cluster page: Enter key submits name assignment | +| S-030-48 | I31 | Cluster page: existing person dropdown selection | +| S-030-49 | I31 | Cluster page: infinite scroll loads next page | +| S-030-50 | I32 | Cluster detail view: all faces displayed; assign name | +| S-030-51 | I32 | Cluster detail: dismiss individual face | +| S-030-52 | I33 | People page: context menu on PersonCard | +| S-030-53 | I34 | Person detail: inline name editing | +| S-030-54 | I35 | Person detail: album-style justified layout | +| S-030-55 | I35 | Person detail: click photo opens lightbox | +| S-030-56 | I36 | Person detail: drag & blue-border multi-select | +| S-030-57 | I37 | Face Maintenance page: admin sorts by quality; dismisses | +| S-030-58 | I38 | Face assigned to Person → person.face_count and person.photo_count updated | +| S-030-59 | I38 | Face dismissed → person.face_count, person.photo_count, photo.face_count decremented | +| S-030-60 | I38 | Face undismissed → counters re-incremented consistently | +| S-030-61 | I39 | Photo owner in privacy-preserving mode → overlays visible; non-owner → hidden | +| S-030-62 | I39 | Non-owner assign face on privacy-preserving photo → 403 | +| S-030-63 | I39 | Album owner in restricted mode triggers scan on own album → 200; non-owner → 403 | +| S-030-64 | I39 | Non-owner requests album people in privacy-preserving mode → 403 | +| S-030-65 | I39 | Rights resource fields reflect correct booleans for all four modes | ## Analysis Gate @@ -388,7 +692,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 39 increments (I1–I39) complete with passing tests. - [ ] PHPStan 0 errors. - [ ] php-cs-fixer clean. - [ ] npm run check / npm run format clean. @@ -401,9 +705,8 @@ _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. +- Per-user face overlay visibility preference (currently global config only — Q-030-61). +- Policy refinement: cross-check album/photo edit rights in AiVisionPolicy (Q-030-63, Q-030-72 — deferred to future iteration). 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..a673e14afcb 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 @@ -3,7 +3,7 @@ | Field | Value | |-------|-------| | Status | Draft | -| Last updated | 2026-03-18 | +| Last updated | 2026-04-11 | | Owners | LycheeOrg | | Linked plan | `docs/specs/4-architecture/features/030-ai-vision-service/plan.md` | | Linked tasks | `docs/specs/4-architecture/features/030-ai-vision-service/tasks.md` | @@ -42,8 +42,8 @@ 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-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-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. *(Resolved Q-030-74)* | GET endpoint returns paginated photos where at least one Face with matching person_id exists. Each `PhotoResource` in the response includes `next_photo_id` and `previous_photo_id` computed relative to the person's collection (sequential index order, access-filtered; null at boundaries), enabling `PhotoPanel.vue` to navigate within the person's collection natively. | 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 | | FR-030-06 | A Person's linked User (or admin) can toggle `is_searchable` on the Person. When `is_searchable` is false, the Person is excluded from search results and People browsing for non-admin users who are not the linked User. *(Resolved Q-030-05: hidden from search + People page for unauthorized users)* | Toggle flips boolean; subsequent search/browse queries filter accordingly. | Only linked User or admin can toggle. | 403 if unauthorized; 404 if Person not found. | `person.searchability_changed` | Owner directive, Q-030-05 | @@ -53,6 +53,46 @@ 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 | +| FR-030-16 | System shall support dismissing a face via: (a) a "Dismiss" button in the FaceAssignmentModal; (b) a CTRL+click shortcut on face overlays (desktop only) — when the CTRL key is held, overlay rectangles switch to red dashed borders, and clicking a rectangle directly dismisses the face. On touch devices, CTRL+click is not available; dismiss is only accessible via the modal button. *(Q-030-54, Q-030-70)* | (a) FaceAssignmentModal includes a "Dismiss" button that calls `PATCH /Face/{id}` to set `is_dismissed = true`. (b) FaceOverlay.vue monitors `keydown`/`keyup` for CTRL key on non-touch devices (checked via `isTouchDevice()` from `keybindings-utils.ts`); when CTRL is held, all face rectangles render with red dashed CSS borders; clicking a rectangle in this state calls `PATCH /Face/{id}` directly without opening the modal. After dismiss, the overlay is removed from view. | Modal dismiss: face must exist, user must have dismiss permission per `ai_vision_face_permission_mode`. CTRL+click: same authorization as modal dismiss; only available on non-touch devices. | 403 if unauthorized; 404 if face not found. | `face.dismissed` | Owner directive, Q-030-54, Q-030-70 | +| FR-030-17 | In the Cluster Review UI, users can select individual faces within a cluster and "uncluster" them — setting `cluster_label = NULL` on selected faces, removing them from the cluster without dismissing them. Supports batch selection. *(Q-030-56)* | New API: `POST /api/v2/FaceDetection/clusters/{cluster_id}/uncluster` with body `{face_ids: [str]}`. Sets `faces.cluster_label = NULL` for the specified face IDs (only if they belong to the given cluster_id and are qualifying — `person_id IS NULL AND is_dismissed = false`). Returns `{unclustered_count: int}`. | face_ids must be non-empty; all face_ids must belong to the specified cluster_id. | 404 if cluster_id not found; 422 if face_ids empty or invalid; 403 if unauthorized. | `face.unclustered` (with `cluster_id`, `face_count`) | Owner directive, Q-030-56 | +| FR-030-18 | Users can remove a face from a person (unassign), making it unassigned again (not dismissed). Distinct from dismissal — unassign returns the face to the unassigned pool. *(Q-030-57)* | `POST /Face/{id}/assign` accepts `person_id: null` to unassign the face. Sets `face.person_id = NULL`. The face becomes available for future cluster runs and manual assignment. | Face must exist; user must have assign permission. | 404 if face not found; 403 if unauthorized. | `face.unassigned` (with `face_id`, `previous_person_id`) | Owner directive, Q-030-57 | +| FR-030-19 | In person detail and cluster review views, users can select multiple faces (batch selection mode), then choose: (a) unassign all selected (`person_id = NULL`), (b) assign all to an existing person, (c) assign all to a new person. *(Q-030-58)* | New API: `POST /api/v2/Face/batch` with body `{face_ids: [str], action: "unassign"\|"assign", person_id?: str, new_person_name?: str}`. For "unassign": sets `person_id = NULL` on all specified faces. For "assign": sets `person_id` to the given person (or creates a new Person from `new_person_name`). Returns `{affected_count: int, person_id?: str}`. | face_ids must be non-empty; action must be "unassign" or "assign"; for "assign", either `person_id` or `new_person_name` required. | 422 if validation fails; 403 if unauthorized for any face; 404 if person_id not found. | `face.batch_updated` (with `action`, `face_count`, `person_id`) | Owner directive, Q-030-58 | +| FR-030-20 | When listing persons in the face assignment modal dropdown, each entry shall display a small circular face crop miniature (the representative crop from `PersonResource.representative_crop_url`) next to the person name, to disambiguate people with the same name. *(Q-030-59)* | FaceAssignmentModal dropdown uses PrimeVue Dropdown with custom `option` template: 24px circular `` (representative_crop_url) + person name + face count. Placeholder icon when no representative crop exists. | PersonResource already includes `representative_crop_url`. | — | — | Owner directive, Q-030-59 | +| FR-030-21 | When the photo details panel (sidebar) is open and the photo has detected faces, display circular face crop thumbnails with person name underneath. Click opens FaceAssignmentModal; CTRL+click dismisses the face (desktop only). Map the `P` key to toggle face overlay visibility. *(Q-030-60, Q-030-61, Q-030-65, Q-030-70, Q-030-71)* | New "People in this photo" section in PhotoDetails.vue. Horizontal scrollable flex row of circular face crops (48px diameter) with name label below each. Overflow: `overflow-x: auto`; when faces exceed visible width they are reachable by scrolling. Click → FaceAssignmentModal. CTRL+click (desktop only) → dismiss (same API as FR-030-16). `P` key confirmed free — `F` maps to fullscreen — toggles overlay visibility (stored in component state; default from `ai_vision_face_overlay_default_visibility` config). | Photo must have faces; face overlay feature must be enabled (`ai_vision_face_overlay_enabled = 1`). | — | — | Owner directive, Q-030-60, Q-030-61, Q-030-65, Q-030-70, Q-030-71 | +| FR-030-22 | New endpoint `GET /api/v2/Album/{id}/people` returns the list of persons found in the given album (via `photo_albums` pivot join). Response uses `PaginatedPersonsResource`. *(Q-030-62)* | Query: `SELECT DISTINCT persons.* FROM persons JOIN faces ON faces.person_id = persons.id JOIN photos ON faces.photo_id = photos.id JOIN photo_albums ON photo_albums.photo_id = photos.id WHERE photo_albums.album_id = ? AND faces.is_dismissed = false`. Respects `ai_vision_face_permission_mode` and `is_searchable` filtering. Paginated. | Album must exist; user must have access to the album. | 404 if album not found; 403 if unauthorized. | — | Owner directive, Q-030-62 | +| FR-030-23 | Maintenance page shall include a block for destroying all dismissed faces. The block only appears when dismissed faces exist (count > 0). *(Q-030-55)* | `GET /Maintenance::destroyDismissedFaces` (check): returns count of dismissed faces. `POST /Maintenance::destroyDismissedFaces` (do): calls existing `DELETE /Face/dismissed` logic. Maintenance card hidden when count is 0. | Admin-only. | — | `face.bulk_deleted` | Owner directive, Q-030-55 | +| FR-030-24 | Maintenance page shall include a single combined block for resetting photos with stuck-pending (`face_scan_status = 'pending'` older than 720 minutes) OR failed (`face_scan_status = 'failed'`) scan status, so they can be re-scanned. The block only appears when the combined count is > 0. *(Q-030-55, Q-030-73)* | `GET /Maintenance::resetFaceScanStatus` (check): returns combined count of stuck-pending (>720 min) + failed photos. `POST /Maintenance::resetFaceScanStatus` (do): sets `face_scan_status = NULL` on all stuck-pending (>720 min) and all failed photos in a single update. Block hidden when combined count is 0. The existing `Maintenance::resetStuckFaces` endpoint remains available for CLI use but is superseded in the UI by this combined block. | Admin-only. | — | `face.failed_scans_reset` (with `reset_count`) | Owner directive, Q-030-55, Q-030-73 | +| FR-030-25 | Person merge shall be accessible from the UI via a "Merge into..." button on the PersonDetail page. A modal allows searching/selecting the target person. *(Q-030-58)* | PersonDetail page includes a "Merge" action button. Clicking opens `MergePersonModal.vue` with a person search dropdown (same PrimeVue Dropdown + custom template with miniature as FR-030-20). User selects target, confirms → calls `POST /Person/{id}/merge`. Source person's page redirects to target person after merge. | Both persons must exist; user must have merge permission. | 403 if unauthorized; 404 if either person not found. | `person.merged` | Owner directive, Q-030-58 | + +### UX Enhancement Requirements (Phase 6) + +| ID | Requirement | Success path | Validation path | Failure path | Telemetry & traces | Source | +|----|-------------|--------------|-----------------|--------------|---------------------|--------| +| FR-030-26 | **Face Cluster page: Enter key assigns name.** In the cluster review page, pressing Enter in the name input field shall submit the assignment (equivalent to clicking "Create Person & Assign All"). | User types a name and presses Enter → cluster is assigned to a new Person with that name, cluster card removed from list. | Name must be non-empty after trim. | No-op if name is empty. | — | Owner directive | +| FR-030-27 | **Face Cluster page: Existing person dropdown.** The cluster assignment UI shall include a dropdown to select an existing Person (with miniature and type-ahead search) in addition to creating a new one. The user can either type a new name or select an existing person. | User selects an existing person from the dropdown → `POST /clusters/{id}/assign` is called with `person_id`. Dropdown uses the same custom option template as FR-030-20 (miniature + name + count). | Person must exist; assignment rules per permission mode. | — | — | Owner directive | +| FR-030-28 | **Face Cluster page: Infinite scrolling.** The cluster review page shall use infinite scrolling (IntersectionObserver) instead of a "Load more" button. New clusters load automatically as the user scrolls near the bottom. | User scrolls down → next page of clusters fetched and appended automatically. Loading spinner shown during fetch. | Server returns valid paginated data. | Error toast on network failure; infinite scroll stops on last page. | — | Owner directive | +| FR-030-29 | **Face Cluster page: Cluster detail view.** Clicking on a cluster card (or a "View all" indicator) shall open a PrimeVue `Dialog` showing all faces in that cluster (not just the preview subset). From this view the user can assign a name to the entire cluster or dismiss individual faces. *(Resolved Q-030-75: PrimeVue Dialog)* | User clicks a cluster card → a PrimeVue Dialog opens displaying all faces in the cluster in a responsive grid. The user can: type a name and assign all faces, select an existing person, or click an individual face to dismiss it. | Cluster must exist with qualifying faces. | 404 if cluster not found. | — | Owner directive | +| FR-030-30 | **Face Cluster page: Dismiss individual faces from cluster.** Within the cluster detail view, individual faces can be dismissed (marked `is_dismissed = true`) without dismissing the entire cluster. A dismiss icon/button appears on each face crop. | User clicks dismiss on a single face → `PATCH /Face/{id}` toggles `is_dismissed`, face is removed from the cluster view, cluster size decremented. | Face must exist; user must have dismiss permission. | 403 if unauthorized. | `face.dismissed` | Owner directive | +| FR-030-31 | **Face Cluster page: Grid layout.** The cluster review page shall use a compact grid layout (not a wide list) where each cluster card contains the face thumbnails, name input, and action buttons in close visual proximity. The "Run Clustering" and "Toggle multi-select" buttons shall move from the header toolbar into the page body. An explanatory header text shall describe the purpose of the page. | Clusters render as compact cards in a responsive grid. Buttons are positioned adjacent to the face thumbnails. Page includes a descriptive header: "Review face clusters to identify people. Assign a name to group similar faces, or dismiss false positives." | — | — | — | Owner directive | +| FR-030-32 | **People page: Context menu.** Right-clicking (or long-pressing on touch) a PersonCard shall open a context menu with actions: "Merge into...", "Toggle privacy", "Assign to user", "Remove association". *(Resolved Q-030-76)* | Context menu opens on PersonCard with the four options. "Assign to user" (admin-only): opens a PrimeVue `` with an autocomplete Dropdown listing user accounts by name + email; on confirm calls `PATCH /Person/{id}` with `{ user_id: selectedUserId }`; requires extending `UpdatePersonRequest` to accept nullable `user_id` (admin-only validation gate). Each other action calls the corresponding API endpoint. | Person must exist; user must have appropriate permissions per action. For "Assign to user": admin-only. | 403 if unauthorized for the selected action. | — | Owner directive | +| FR-030-33 | **People page: Compact face thumbnails.** PersonCard face crop thumbnails shall be smaller than the current size and use rounded corners (`border-radius`) for a polished appearance. | PersonCard renders with a smaller circular or rounded-corner face crop (e.g. 80px instead of 96px) and rounded corners on the card itself. | — | — | — | Owner directive | +| FR-030-34 | **Person detail: Inline name editing.** Clicking directly on the person's name text shall make it editable inline (contenteditable or input swap) without requiring the pencil icon. Pressing Enter submits the change; pressing Escape cancels. The pencil icon is removed. | User clicks the person name → name becomes an editable input. Enter saves, Escape reverts. | Name must be non-empty after trim. | Revert to previous name on validation failure. | `person.updated` | Owner directive | +| FR-030-35 | **Person detail: Title visibility in dark mode.** The person name title text shall use a color that is readable in dark mode (e.g. `text-text-main-0` or equivalent theme-aware class). | Title text is fully readable against both light and dark backgrounds. | — | — | — | Owner directive | +| FR-030-36 | **Person detail: Album-style photo layout.** The photo grid in PersonDetail shall use the same justified/masonry photo layout as album views (preserving aspect ratios) instead of a uniform square grid. | Photos render with their natural aspect ratios in a justified gallery layout, consistent with the album photo view. | Photos must have size variant metadata. | Fallback to square grid if no size metadata available. | — | Owner directive | +| FR-030-37 | **Person detail: Compact face removal toggle.** The "Remove from person" hover overlay shall be replaced with a smaller toggle indicator (e.g. a small "×" badge in the corner of each face/photo tile) that appears on hover but does not cover the entire photo. | User hovers over a photo → a small "×" badge appears in the top-right corner. Clicking it removes the face from the person. | Face must exist; user must have assign permission. | — | `face.unassigned` | Owner directive | +| FR-030-38 | **Person detail: Multi-select with drag & blue border.** Photo/face selection in PersonDetail shall support click-to-select (with blue border highlight, matching album selection style), Shift+click range select, and drag-to-select (rubber-band selection). Selected items show a blue border (not checkbox). | User clicks a photo → blue border highlight. Shift+click selects a range. Drag creates a rubber-band rectangle selecting enclosed items. | — | — | — | Owner directive | +| FR-030-39 | **Person detail: Click photo opens photo lightbox.** Clicking a photo in PersonDetail (when not in select mode) shall open the full photo viewer/lightbox (the same photo overlay used in album views, with navigation arrows, EXIF data, etc.). *(Resolved Q-030-74)* | User clicks a photo → photo lightbox/overlay opens at that photo. Navigation between photos in the person's collection is driven by `next_photo_id`/`previous_photo_id` set person-relative by `GET /Person/{id}/photos` (FR-030-03); `PhotoPanel.vue` follows these IDs natively with no additional store manipulation. | Photo must exist with valid size variants. | Fallback gracefully if photo data is missing. | — | Owner directive | +| FR-030-40 | **Face Maintenance page.** A new page accessible from the admin maintenance area shall list all detected faces in a sortable table. Columns: face crop thumbnail, photo thumbnail, person name (or "Unassigned"), cluster label, confidence score, Laplacian blur score. The table shall support sorting by confidence and blur score to identify low-quality faces for manual review and dismissal. | Admin navigates to the face maintenance page → table loads with all faces. Admin sorts by confidence ascending → lowest-quality faces shown first. Admin can dismiss individual faces from this view. | Admin-only access. | 403 for non-admin users. | — | Owner directive | +| FR-030-41 | **Denormalized face and photo counters on Person.** The `persons` table shall store two denormalized integer counter columns — `face_count` (number of non-dismissed faces assigned to this Person) and `photo_count` (number of distinct photos in which this Person has at least one non-dismissed face). Dismissed faces (`is_dismissed = true`) must never be counted. These counters shall be kept in sync atomically via an Eloquent observer on the `Face` model whenever a face is created, deleted, has its `person_id` changed (assign/unassign/reassign), or has its `is_dismissed` flag changed (dismiss/undismiss). `PersonResource` shall read the counter values directly from the model columns instead of executing a runtime `COUNT` query. | Face assigned to Person → `person.face_count` incremented; `person.photo_count` incremented if no other non-dismissed face from the same Person exists on that photo. Face unassigned → counters decremented accordingly. Face dismissed → counters decremented. Face undismissed → counters re-incremented. Counters always match results of equivalent `COUNT` / `COUNT(DISTINCT photo_id)` queries filtered to `is_dismissed = false`. | Counters must be non-negative integers. | If observer fails (exception), the operation is rolled back and the counters remain consistent. | — | Owner directive | +| FR-030-42 | **Denormalized non-dismissed face counter on Photo.** The `photos` table shall store a denormalized integer counter column `face_count` reflecting the number of non-dismissed faces attached to that photo. Dismissed faces must not be counted. The counter shall be kept in sync via the same Face observer (FR-030-41): incremented when a non-dismissed face is created on the photo, decremented when a non-dismissed face is deleted or dismissed, re-incremented when a face on the photo is undismissed. `PhotoResource` shall read this column directly instead of iterating faces. The column is not affected by `person_id` changes (assignment to a Person). | Face created on photo (not dismissed) → `photo.face_count++`. Face deleted (was not dismissed) → `photo.face_count--`. Face dismissed → `photo.face_count--`. Face undismissed → `photo.face_count++`. | Counter must be non-negative. | Same rollback consistency guarantee as FR-030-41. | — | Owner directive | +| FR-030-43 | **PhotoPolicy — photo-context face gate constants.** `PhotoPolicy` shall gain four new gate constants and corresponding methods that evaluate face operation permissions against the current `ai_vision_face_permission_mode` **and** the specific photo's ownership, resolving Q-030-63. All four methods rely on `PhotoPolicy::before()` for the admin short-circuit (note: admins bypass the gate even when AI Vision is disabled — accepted risk, Q-030-77). `canViewFaceOverlays` in `public` mode calls `$this->canAccess($user, $photo->album)` directly on the policy instance (no circular dependency — Q-030-78). (1) `CAN_VIEW_FACE_OVERLAYS` (`canViewFaceOverlays(User\|null, Photo)`): *public* – viewer has album access to the photo; *private* – logged in; *privacy-preserving/restricted* – photo owner or admin. (2) `CAN_DISMISS_FACE` (`canDismissFace(User\|null, Photo)`): photo owner or admin, **mode-independent** (always the same row in the matrix). (3) `CAN_ASSIGN_FACE_ON_PHOTO` (`canAssignFaceOnPhoto(User\|null, Photo)`): *public/private* – logged in; *privacy-preserving* – photo owner or admin; *restricted* – admin only (not even photo owner). (4) `CAN_TRIGGER_SCAN_ON_PHOTO` (`canTriggerScanOnPhoto(User\|null, Photo)`): *public/private* – logged in; *privacy-preserving/restricted* – photo owner or admin. | Each mode–operation combination returns the expected boolean consistent with the permission matrix in `FacePermissionMode`. Feature-disabled: all return `false`. Admin: all pass (via `PhotoPolicy::before()`). | AI Vision feature must be enabled (`ai_vision_enabled`). Photo must exist. | Returns `false` on missing photo; does not throw. | — | Owner directive, Q-030-63, Q-030-77, Q-030-78 | +| FR-030-44 | **AlbumPolicy — album-context face gate constants.** `AlbumPolicy` shall gain four new gate constants and corresponding methods that evaluate face operation permissions against the mode and the specific album's ownership. All four methods rely on `AlbumPolicy::before()` for the admin short-circuit (note: admins bypass the gate even when AI Vision is disabled — accepted risk, Q-030-77). `canViewAlbumPeople` in `public` mode calls `$this->canAccess($user, $abstract_album)` directly on the policy instance (no circular dependency — Q-030-78). (1) `CAN_VIEW_ALBUM_PEOPLE` (`canViewAlbumPeople(User\|null, AbstractAlbum)`): *public* – album access; *private* – logged in; *privacy-preserving/restricted* – album owner or admin. (2) `CAN_TRIGGER_SCAN_ON_ALBUM` (`canTriggerScanOnAlbum(User\|null, AbstractAlbum)`): *public/private* – logged in; *privacy-preserving/restricted* – album owner or admin. (3) `CAN_ASSIGN_FACE_IN_ALBUM` (`canAssignFaceInAlbum(User\|null, AbstractAlbum)`): *public/private* – logged in; *privacy-preserving* – album owner or admin; *restricted* – admin only. (4) `CAN_BATCH_FACE_OPS` (`canBatchFaceOps(User\|null, AbstractAlbum\|null)`): *public/private* – logged in; *privacy-preserving* – album owner or admin (null album → deny); *restricted* – admin only. All four return `false` when AI Vision is disabled. Ownership refers to `AbstractAlbum::owner_id === $user->id` for concrete albums; smart albums have no owner and are treated as admin-only for these checks. | Each mode–operation–ownership combination returns the expected boolean consistent with the permission matrix. | AI Vision feature must be enabled. | `false` on disabled feature or insufficient permissions; does not throw. | — | Owner directive, Q-030-63, Q-030-77, Q-030-78 | +| FR-030-45 | **PhotoRightsResource — face operation rights surface.** `PhotoRightsResource` shall accept an additional optional `?Photo $photo` constructor parameter and expose four new boolean fields computed via the new `PhotoPolicy` gates (FR-030-43): `can_view_face_overlays`, `can_dismiss_face`, `can_assign_face`, `can_trigger_scan`. When `$photo` is `null` or the AI Vision feature is disabled, all four fields default to `false`. The TypeScript type declaration (`lychee.d.ts`) must be regenerated to include the new fields. `PhotoResource` construction must pass the concrete `Photo` model instance when creating the embedded `PhotoRightsResource`. | Frontend receives correct `rights.can_view_face_overlays`, `rights.can_dismiss_face`, `rights.can_assign_face`, `rights.can_trigger_scan` for every photo response. | Photo must exist to compute ownership. | All fields `false` when photo is null or AI Vision off. | — | Owner directive | +| FR-030-46 | **AlbumRightsResource — face operation rights surface.** `AlbumRightsResource` shall expose four new boolean fields computed via the new `AlbumPolicy` gates (FR-030-44): `can_view_album_people`, `can_trigger_scan`, `can_assign_face`, `can_batch_face_ops`. These are appended to the existing resource and computed from the `?AbstractAlbum $abstract_album` already passed to the constructor. When AI Vision is disabled or the album is `null`, all four default to `false`. The TypeScript type declaration must be regenerated. | Frontend receives correct rights flags alongside every album response. | — | All fields `false` when AI Vision off or album is null. | — | Owner directive | +| FR-030-47 | **Wire request authorizers to per-resource gates.** All existing `Request` classes that currently bypass per-resource ownership checks for face operations (those carrying `// TODO: Make sure FacePermissionMode applies here` or equivalent gaps) shall be updated to use the new `PhotoPolicy` / `AlbumPolicy` gate constants. Specifically: (a) `AssignFaceRequest::authorize()` → `Gate::check(PhotoPolicy::CAN_ASSIGN_FACE_ON_PHOTO, $face->photo)`; (b) `ToggleDismissedRequest::authorize()` → `Gate::check(PhotoPolicy::CAN_DISMISS_FACE, $face->photo)` (removes the inline ownership check and the `// TODO`); (c) `BatchFaceRequest::authorize()` → when `album_id` is provided: `Gate::check(AlbumPolicy::CAN_BATCH_FACE_OPS, $album)` where `$album` is resolved from request context; when `album_id` is null (no album context): `Gate::check(PhotoPolicy::CAN_ASSIGN_FACE_ON_PHOTO, $face->photo)` for each resolved face (Q-030-79); (d) `ScanPhotosRequest::authorize()` → when album provided: `Gate::check(AlbumPolicy::CAN_TRIGGER_SCAN_ON_ALBUM, $this->album)`; when photo IDs only (no album): `Gate::check(PhotoPolicy::CAN_TRIGGER_SCAN_ON_PHOTO, $photo)` for each resolved photo (Q-030-79); (e) `GetAlbumPersonsRequest::authorize()` → existing album-access check **AND** `Gate::check(AlbumPolicy::CAN_VIEW_ALBUM_PEOPLE, $album)`; (f) `PhotoResource::buildFaceData()` → `Gate::check(PhotoPolicy::CAN_VIEW_FACE_OVERLAYS, $photo)` replacing the inline mode-check block. This resolves Q-030-63 and Q-030-72. | Each request returns 403 when the calling user does not have ownership of the relevant photo or album in privacy-preserving/restricted modes. | — | 403 returned for unauthorized callers; no silent data leak. | — | Owner directive, Q-030-63, Q-030-72, Q-030-79 | ## Non-Functional Requirements @@ -77,9 +117,16 @@ Affected modules: **Models** (new Person, Face), **Migrations** (new tables + pi | Trigger scan | logged users | logged users | photo/album owner + admin | photo/album owner + admin | | Claim person | logged users | logged users | logged users | logged users | | Merge persons | logged users | logged users | photo/album owner + admin | admin only | +| Dismiss face | photo owner + admin | photo owner + admin | photo owner + admin | photo owner + admin | +| Batch face ops | logged users | logged users | photo/album owner + admin | admin only | +| View album people | album access | logged users | photo/album owner + admin | photo/album owner + admin | | 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 | +| NFR-030-11 | Face overlay display is governed by two global config settings: `ai_vision_face_overlay_enabled` (0\|1, default 1) — master toggle for face overlay rendering; when 0, no face overlays or face circles are shown anywhere. `ai_vision_face_overlay_default_visibility` (string: `visible`\|`hidden`, default `visible`) — sets whether overlays are shown or hidden by default when a photo is viewed; user can toggle with `P` key (`P` confirmed unbound — `F` maps to fullscreen). Both settings are global (configs table), not per-user. *(Q-030-61, Q-030-65)* | UX flexibility; some users find overlays distracting. | Overlay disabled: feature test confirms no face DOM elements rendered. Toggle: pressing `P` flips overlay visibility. | Config migration, FaceOverlay.vue, PhotoDetails.vue. | Owner directive, Q-030-61, Q-030-65 | + +> **Policy refinement note *(Q-030-63)*:** ~~The current four-level permission mode semantic (public/private/privacy-preserving/restricted) is correct. However, the policy also needs refinement with regard to album/photo edit rights — currently, "photo/album owner + admin" checks global ownership rather than per-resource ownership. This gap is acknowledged and will be revisited in a future iteration. For now, the focus is on UI/UX interaction.~~ **Resolved 2026-04-11 by FR-030-43 through FR-030-47 (I39).** `PhotoPolicy` and `AlbumPolicy` now gate all face operations against the concrete photo/album owner, and `PhotoRightsResource` / `AlbumRightsResource` surface the resulting flags to the frontend. > **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. @@ -93,11 +140,11 @@ All keys below belong to the `AI Vision` config category (`cat = 'AI Vision'`) a #### `config/features.php` — AI Vision infrastructure keys -Read via `config('features.ai-vision.face-url')` and `config('features.ai-vision.face-api-key')`. Never visible in the admin UI or included in config API responses. +Read via `config('features.ai-vision-service.face-url')` and `config('features.ai-vision-service.face-api-key')`. Never visible in the admin UI or included in config API responses. | PHP key | `.env` variable | Default | Description | |---------|----------------|---------|-------------| -| `features.ai-vision.face-url` | `AI_VISION_FACE_URL` | `""` | Base URL of the Python face-recognition service (e.g. `http://ai-vision:8000`). Must not have a trailing slash. | +| `features.ai-vision-service.face-url` | `AI_VISION_FACE_URL` | `""` | Base URL of the Python face-recognition service (e.g. `http://ai-vision:8000`). Must not have a trailing slash. | | `features.ai-vision.face-api-key` | `AI_VISION_FACE_API_KEY` | `""` | Shared API key for both directions: sent as `X-API-Key` in Lychee→Python scan requests; expected as `X-API-Key` in Python→Lychee callbacks. Must match `VISION_FACE_API_KEY` on the Python side. | #### `configs` table — AI Vision admin-configurable keys @@ -110,29 +157,38 @@ Read via `config('features.ai-vision.face-url')` and `config('features.ai-vision | `ai_vision_face_selfie_confidence_threshold` | float (0.0–1.0) | `0.8` | Minimum match confidence score for selfie-based Person auto-claim (FR-030-12). | | `ai_vision_face_person_is_searchable_default` | `0\|1` | `1` | Default `is_searchable` value assigned to each newly created Person record (FR-030-01). | | `ai_vision_face_allow_user_claim` | `0\|1` | `1` | When `1`, regular (non-admin) users may claim an assigned Person to link it to their account. Admins can always claim/unclaim regardless of this setting (FR-030-05). | -| `ai_vision_face_scan_batch_size` | integer | `200` | Number of photo IDs dispatched per job chunk when bulk-scanning. Controls queue saturation — lower values reduce burst load at the cost of more job records. *(Q-030-45)* | +| `ai_vision_face_overlay_enabled` | `0\|1` | `1` | Master toggle for face overlay rendering. When `0`, no face overlays or face circles are shown anywhere in the UI. *(Q-030-61, NFR-030-11)* | +| `ai_vision_face_overlay_default_visibility` | enum string | `visible` | Default visibility state for face overlays when viewing a photo. Values: `visible`, `hidden`. User can toggle with `P` key during photo viewing. *(Q-030-61, NFR-030-11)* | ## UI / Interaction Mock-ups -### People Page (new top-level view) +### People Page *(updated — FR-030-32, FR-030-33)* ``` +------------------------------------------------------------------+ | Lychee > People [Search] | +------------------------------------------------------------------+ | | -| +----------+ +----------+ +----------+ +----------+ | -| | ┌────┐ | | ┌────┐ | | ┌────┐ | | ┌────┐ | | -| | │face│ | | │face│ | | │face│ | | │ ? │ | | -| | │crop│ | | │crop│ | | │crop│ | | │ │ | | -| | └────┘ | | └────┘ | | └────┘ | | └────┘ | | -| | Alice | | Bob | | Carol | | Unknown | | -| | 142 photos| | 87 photos| | 53 photos| | 12 faces | | -| +----------+ +----------+ +----------+ +----------+ | +| +--------+ +--------+ +--------+ +--------+ +--------+ | +| | ┌──┐ | | ┌──┐ | | ┌──┐ | | ┌──┐ | | ┌──┐ | | +| | │ │ | | │ │ | | │ │ | | │ │ | | │ ? │ | | +| | └──┘ | | └──┘ | | └──┘ | | └──┘ | | └──┘ | | +| | Alice | | Bob | | Carol | | Dave | |Unknown | | +| | 142 ph | | 87 ph | | 53 ph | | 28 ph | | 12 f | | +| +--------+ +--------+ +--------+ +--------+ +--------+ | | | +| Right-click PersonCard: | +| ┌──────────────────┐ | +| │ Merge into... │ | +| │ Toggle privacy │ | +| │ Assign to user │ | +| │ Remove assoc. │ | +| └──────────────────┘ | +------------------------------------------------------------------+ ``` +*Note: Smaller face crop thumbnails (80px, rounded corners). Context menu on right-click or long-press. More cards per row.* + ### Photo Detail — Face Overlay ``` @@ -154,29 +210,37 @@ Read via `config('features.ai-vision.face-url')` and `config('features.ai-vision +------------------------------------------------------------------+ ``` -### Person Detail Page +### Person Detail Page *(updated — FR-030-34 through FR-030-39)* ``` +------------------------------------------------------------------+ -| Lychee > People > Alice | +| ◄ Alice | +------------------------------------------------------------------+ -| ┌────┐ | -| │face│ Alice | -| │crop│ 142 photos · Linked to: alice@example.com | -| └────┘ [✓ Searchable] [Edit] [Merge] [Delete] | +| ┌──┐ | +| │ │ [Alice] ← click name to edit inline; Enter saves | +| └──┘ 142 photos · Linked to: alice@example.com | +| [✓ Searchable] [Merge] [Delete] | +------------------------------------------------------------------+ | | -| Photos containing Alice: | -| ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ | -| │ │ │ │ │ │ │ │ │ │ | -| │ img1 │ │ img2 │ │ img3 │ │ img4 │ │ img5 │ | -| │ │ │ │ │ │ │ │ │ │ | -| └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ | +| Photos containing Alice: (album-style justified layout) | +| ┌────────┐ ┌──────────────┐ ┌──────┐ ┌──────────┐ | +| │ │ │ │ │ │ │ │ | +| │ img1 │ │ img2 │ │ img3 │ │ img4 │ | +| │ [×]│ │ [×]│ │ [×]│ │ [×]│ | +| └────────┘ └──────────────┘ └──────┘ └──────────┘ | +| ┌──────────────┐ ┌──────┐ ┌────────┐ | +| │ │ │ │ │ │ | +| │ img5 │ │ img6 │ │ img7 │ | +| │ [×]│ │ [×]│ │ [×]│ | +| └──────────────┘ └──────┘ └────────┘ | | | -| [Load more (137 remaining)] | +| Click photo → opens lightbox. [×] = compact remove badge. | +| Click+drag or Shift+click for blue-border multi-select. | +------------------------------------------------------------------+ ``` +*Note: Name is editable inline (click to edit, Enter to save, Escape to cancel — pencil icon removed). Photos use album-style justified layout (natural aspect ratios). Small [×] badge in corner for face removal (replaces full-image hover overlay). Clicking a photo opens the full lightbox. Multi-select via click/Shift+click/drag with blue border highlight. Title uses theme-aware colors for dark mode.* + ### Face Assignment Modal ``` @@ -190,16 +254,20 @@ Read via `config('features.ai-vision.face-url')` and `config('features.ai-vision | └────┘ | | | | Assign to: | -| ○ Existing person: [ Alice ▼ ] | +| ○ Existing person: | +| [ (○) Alice 142ph ▼ ] | +| *(miniature + name + count)* | | ○ New person: [ __________ ] | | | | Similar faces found: | | [Alice (94%)] [Bob (12%)] | | | -| [Cancel] [Assign] | +| [Dismiss] [Cancel] [Assign] | +------------------------------------------+ ``` +*Note: The dropdown for existing persons shows a circular miniature (representative_crop_url) next to the name and face count to disambiguate same-name persons (FR-030-20). The "Dismiss" button calls `PATCH /Face/{id}` to mark `is_dismissed = true` (FR-030-16).* + ### Selfie Upload Claim ``` @@ -228,6 +296,217 @@ Read via `config('features.ai-vision.face-url')` and `config('features.ai-vision +------------------------------------------+ ``` +### Cluster Review Page *(updated — FR-030-26 through FR-030-31)* + +``` ++------------------------------------------------------------------+ +| Lychee > People > Clusters | ++------------------------------------------------------------------+ +| Review face clusters to identify people. Assign a name to group | +| similar faces, or dismiss false positives. | +| | +| [Run Clustering] [Toggle Multi-Select] | ++------------------------------------------------------------------+ +| | +| +---------------------------+ +---------------------------+ | +| | Cluster #1 (12 faces) | | Cluster #2 (7 faces) | | +| | +----+ +----+ +----+ | | +----+ +----+ +----+ | | +| | |crop| |crop| |crop| +7 | | |crop| |crop| |crop| +4 | | +| | +----+ +----+ +----+ | | +----+ +----+ +----+ | | +| | [ ___name___ ⏎] [▼Person] | | [ ___name___ ⏎] [▼Person] | | +| | [Assign] [Dismiss] | | [Assign] [Dismiss] | | +| +---------------------------+ +---------------------------+ | +| | +| +---------------------------+ +---------------------------+ | +| | Cluster #3 ... | | Cluster #4 ... | | +| +---------------------------+ +---------------------------+ | +| | +| ↓ (infinite scroll — loads more as user scrolls) | ++------------------------------------------------------------------+ +``` + +*Note: Grid layout with compact cards. Name input supports Enter to submit. Dropdown to select existing person alongside "Create new" text input. Infinite scroll replaces "Load more" button. Clicking a cluster card opens the cluster detail view (FR-030-29). "Run Clustering" and "Toggle Multi-Select" buttons are in the page body, not the toolbar.* + +### Cluster Detail View *(new — FR-030-29, FR-030-30)* + +``` ++------------------------------------------------------------------+ +| Lychee > People > Clusters > Cluster #1 (12 faces) [← Back] | ++------------------------------------------------------------------+ +| | +| +------+ +------+ +------+ +------+ +------+ +------+ | +| | crop | | crop | | crop | | crop | | crop | | crop | | +| | [×] | | [×] | | [×] | | [×] | | [×] | | [×] | | +| +------+ +------+ +------+ +------+ +------+ +------+ | +| +------+ +------+ +------+ +------+ +------+ +------+ | +| | crop | | crop | | crop | | crop | | crop | | crop | | +| | [×] | | [×] | | [×] | | [×] | | [×] | | [×] | | +| +------+ +------+ +------+ +------+ +------+ +------+ | +| | +| [ ___person name___ ⏎] [▼ Select person] | +| [Create Person & Assign All] [Dismiss All] | ++------------------------------------------------------------------+ +``` + +*Note: Shows ALL faces in the cluster. Each face has a small [×] dismiss button. Name input + existing person dropdown at bottom. Enter submits assignment.* + +### Face Overlay — CTRL+Click Dismiss Mode *(FR-030-16)* + +``` ++------------------------------------------------------------------+ +| ◄ Photo Title ⋮ Menu | ++------------------------------------------------------------------+ +| | +| ┌──────────────────────────────────────────┐ | +| │ │ | +| │ ┌╌╌╌╌╌╌╌┐ ┌╌╌╌╌╌╌╌┐ │ | +| │ ╎ face1 ╎ ╎ face2 ╎ │ | +| │ ╎ RED ╎ ╎ RED ╎ │ | +| │ └╌╌╌╌╌╌╌┘ └╌╌╌╌╌╌╌┘ │ | +| │ │ | +| └──────────────────────────────────────────┘ | +| | +| [CTRL held — click face to dismiss] | ++------------------------------------------------------------------+ +``` + +*Note: When CTRL is held (desktop only — touch devices dismiss via modal button only), all face overlay rectangles switch to red dashed borders. Clicking directly dismisses the face without opening the modal. *(Q-030-70)*** + +### Photo Detail Panel — Face Circles *(FR-030-21)* + +``` ++------------------------------------------------------------------+ +| Photo Details Sidebar | ++------------------------------------------------------------------+ +| ┌────┐ | +| │ │ sunset_beach.jpg | +| │img │ 4032×3024 · 8.2 MB | +| └────┘ | +| ... | +| | +| People in this photo: | +| ┌──┐ ┌──┐ ┌──┐ | +| │○○│ │○○│ │○○│ | +| │○○│ │○○│ │○○│ | +| └──┘ └──┘ └──┘ | +| Alice Bob ??? | +| | +| [P] Toggle face overlay | +| ... | ++------------------------------------------------------------------+ +``` + +*Note: Circular face crop thumbnails (48px) with person name underneath. Horizontal scrollable row (`overflow-x: auto`). Click opens FaceAssignmentModal. CTRL+click (desktop only) dismisses. "???" for unassigned faces. *(Q-030-70, Q-030-71)*** + +### Cluster Review — Batch Select & Uncluster *(FR-030-17, FR-030-19)* + +``` ++------------------------------------------------------------------+ +| Lychee > People > Clusters [Run Cluster] | ++------------------------------------------------------------------+ +| Cluster #1 (12 faces) [Select Mode ✓] | +| +------+ +------+ +------+ +------+ +------+ [+7 more] | +| | ☑ | | ☐ | | ☑ | | ☐ | | ☐ | | +| | crop | | crop | | crop | | crop | | crop | | +| +------+ +------+ +------+ +------+ +------+ | +| ┌──────────────────────────────────────────────────────────┐ | +| │ 2 selected: [Uncluster] [Reassign to...] [Unassign] │ | +| └──────────────────────────────────────────────────────────┘ | +| [ _______________ ] [Create Person & Assign All] [Dismiss] | ++------------------------------------------------------------------+ +``` + +*Note: When "Select Mode" is active, checkbox overlays appear. Selected faces can be unclustered (removed from cluster), reassigned to another person, or unassigned.* + +### Person Detail — Batch Face Operations *(FR-030-19)* + +``` ++------------------------------------------------------------------+ +| Lychee > People > Alice [Select Mode ✓] | ++------------------------------------------------------------------+ +| ┌────┐ Alice · 142 photos | +| │crop│ [Edit] [Merge into...] [Delete] | +| └────┘ | ++------------------------------------------------------------------+ +| Faces assigned to Alice: | +| +------+ +------+ +------+ +------+ +------+ | +| | ☑ | | ☐ | | ☑ | | ☐ | | ☐ | | +| | crop | | crop | | crop | | crop | | crop | | +| +------+ +------+ +------+ +------+ +------+ | +| ┌──────────────────────────────────────────────────────────┐ | +| │ 2 selected: [Unassign] [Reassign to...] [New Person] │ | +| └──────────────────────────────────────────────────────────┘ | ++------------------------------------------------------------------+ +``` + +*Note: From a person's detail page, users can select faces and unassign them (return to unassigned pool), reassign to another person, or create a new person from the selection.* + +### Merge Person Modal *(FR-030-25)* + +``` ++------------------------------------------+ +| Merge Person | ++------------------------------------------+ +| | +| Merge "Bob" into: | +| | +| [ (○) Alice 142ph ▼ ] | +| *(search dropdown with miniature)* | +| | +| This will: | +| • Move all 87 faces from Bob to Alice | +| • Delete Bob's person record | +| • This action cannot be undone | +| | +| [Cancel] [Merge] | ++------------------------------------------+ +``` + +### Maintenance — Face Blocks *(FR-030-23, FR-030-24)* + +``` ++------------------------------------------------------------------+ +| Maintenance | ++------------------------------------------------------------------+ +| ... existing blocks ... | +| | +| ┌─────────────────────┐ ┌─────────────────────┐ | +| │ Destroy Dismissed │ │ Reset Face Scan │ | +| │ Faces │ │ Status │ | +| │ 23 dismissed faces │ │ 5 stuck + 3 failed │ | +| │ [Destroy All] │ │ [Reset All] │ | +| └─────────────────────┘ └─────────────────────┘ | +| | ++------------------------------------------------------------------+ +``` + +*Note: Each face maintenance block is conditional — hidden when its count is 0. The "Reset Face Scan Status" block combines stuck-pending (>720 min) and failed scans into one action. The existing `Maintenance::resetStuckFaces` API is retained for CLI use but has no dedicated UI card.* + +### Face Maintenance Admin Table *(new — FR-030-40)* + +``` ++------------------------------------------------------------------+ +| Lychee > Maintenance > Face Quality | ++------------------------------------------------------------------+ +| Review detected faces sorted by quality metrics. | +| Dismiss low-quality faces to improve recognition accuracy. | ++------------------------------------------------------------------+ +| | +| | Crop | Photo | Person | Cluster | Confidence ▼ | Blur | | +| |------|-------|-----------|---------|-------------|---------| | +| | [○] | [□] | Unassigned| #3 | 0.52 | 45.2 | | +| | [○] | [□] | Alice | #1 | 0.55 | 62.8 | | +| | [○] | [□] | Unassigned| — | 0.61 | 88.1 | | +| | [○] | [□] | Bob | #2 | 0.73 | 112.4 | | +| | [○] | [□] | Carol | #1 | 0.89 | 245.6 | | +| ... | +| | +| ↓ (infinite scroll or paginated table) | ++------------------------------------------------------------------+ +``` + +*Note: Sortable by Confidence and Blur (Laplacian) score columns. Admin can click a face row to dismiss it. Low confidence / low blur scores indicate low-quality detections. Allows admin to tune detection thresholds based on real data.* + ## Branch & Scenario Matrix | Scenario ID | Description / Expected outcome | @@ -255,6 +534,45 @@ 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` | +| S-030-33 | User clicks "Dismiss" button in FaceAssignmentModal → face is dismissed (`is_dismissed = true`), modal closes, overlay removed | +| S-030-34 | User holds CTRL key → face overlay rectangles turn red/dashed → clicking a rectangle directly dismisses the face without opening modal | +| S-030-35 | User selects faces in cluster, clicks "Uncluster" → selected faces have `cluster_label` set to NULL, removed from cluster view | +| S-030-36 | User removes a face from a person (unassign) → face's `person_id` set to NULL, face returns to unassigned pool | +| S-030-37 | User selects multiple faces in person detail, clicks "Reassign to..." → all selected faces assigned to the chosen person | +| S-030-38 | Photo detail panel open with detected faces → circular face crops shown with names under; clicking opens FaceAssignmentModal | +| S-030-39 | CTRL+click on face circle in detail panel → face is dismissed directly | +| S-030-40 | User opens album → requests `GET /Album/{id}/people` → list of persons appearing in album photos is returned | +| S-030-41 | User presses `P` while viewing photo → face overlays toggle between visible and hidden | +| S-030-42 | Admin opens maintenance page with dismissed faces → "Destroy Dismissed Faces" block shown; clicks "Destroy All" → all dismissed faces hard-deleted | +| S-030-43 | Admin opens maintenance page with failed face scans → "Reset Failed Scans" block shown; clicks "Reset All" → failed photos reset to null | +| S-030-44 | `ai_vision_face_overlay_enabled` set to `0` → no face overlays or face circles rendered anywhere in the UI | +| S-030-45 | Person merge from UI: user opens PersonDetail → clicks "Merge into..." → selects target person with miniature → confirms → source person merged into target | +| S-030-46 | Face assignment dropdown shows persons with circular miniature + name + face count; two persons named "John" visually distinguishable | +| S-030-47 | Cluster page: user types name, presses Enter → cluster assigned to new Person | +| S-030-48 | Cluster page: user selects existing person from dropdown → cluster assigned to that person | +| S-030-49 | Cluster page: user scrolls down → next page of clusters loads automatically (infinite scroll) | +| S-030-50 | Cluster page: user clicks cluster → detail view shows all faces; user assigns name → all faces in cluster assigned | +| S-030-51 | Cluster page: user dismisses individual face from cluster detail → face marked dismissed, removed from view | +| S-030-52 | People page: user right-clicks PersonCard → context menu with Merge/Privacy/Assign/Remove actions | +| S-030-53 | Person detail: user clicks on name text → becomes editable; Enter saves; Escape cancels | +| S-030-54 | Person detail: photos display in album-style justified layout with natural aspect ratios | +| S-030-55 | Person detail: user clicks photo (not in select mode) → photo lightbox opens; left/right navigation stays within the person's collection via person-relative `next_photo_id`/`previous_photo_id` set by `GET /Person/{id}/photos` *(Resolved Q-030-74)* | +| S-030-56 | Person detail: user Shift+clicks to range-select photos → blue border highlight; drag-to-select works | +| S-030-57 | Face Maintenance page: admin sorts by confidence ascending → lowest-quality faces shown; can dismiss from table | +| S-030-58 | Face assigned to Person → `person.face_count` and `person.photo_count` updated; `PersonResource` returns updated counters without a runtime COUNT query | +| S-030-59 | Face dismissed → `person.face_count`, `person.photo_count` (if no other qualifying face exists for that person+photo pair), and `photo.face_count` all decremented atomically | +| S-030-60 | Face undismissed → `person.face_count`, `photo.face_count` (and `person.photo_count` if needed) re-incremented; counts remain consistent with filtered COUNT queries | +| S-030-61 | Photo owner in `privacy-preserving` mode views photo → face overlays shown; non-owner → overlays hidden (`can_view_face_overlays = false`) | +| S-030-62 | Non-owner attempts `POST /Face/{id}/assign` in `privacy-preserving` mode for a photo they do not own → 403 | +| S-030-63 | Album owner in `restricted` mode attempts `POST /FaceDetection/scan` targeting their own album → allowed; non-owner → 403 | +| S-030-64 | `GET /api/v2/Album/{id}/people` in `privacy-preserving` mode by non-owner, non-admin → 403; album owner → 200 | +| S-030-65 | `AlbumRightsResource` and `PhotoRightsResource` include correct `can_view_face_overlays`, `can_dismiss_face`, `can_assign_face`, `can_trigger_scan`, `can_view_album_people`, `can_batch_face_ops` booleans for all four permission modes | ## Test Strategy @@ -271,12 +589,19 @@ 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), `face_count` (unsigned int, default 0 — see DO-030-10), `photo_count` (unsigned int, default 0 — see DO-030-10), 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 — read from `persons.face_count` denormalized column, see DO-030-10), `photo_count` (int — read from `persons.photo_count` denormalized column, see DO-030-10), `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-12 | `PhotoRightsResource` addendum — new boolean fields added (FR-030-45): `can_view_face_overlays: bool`, `can_dismiss_face: bool`, `can_assign_face: bool`, `can_trigger_scan: bool`. Computed via `PhotoPolicy` gate checks on the concrete `Photo` instance. All default to `false` when AI Vision is disabled or photo is null. Constructor signature extended to `__construct(?AbstractAlbum $album, ?Photo $photo = null)`. | Resources, Frontend | +| DO-030-13 | `AlbumRightsResource` addendum — new boolean fields added (FR-030-46): `can_view_album_people: bool`, `can_trigger_scan: bool`, `can_assign_face: bool`, `can_batch_face_ops: bool`. Computed via `AlbumPolicy` gate checks. Defaults to `false` when AI Vision is disabled or album is null. No constructor signature change (already takes `?AbstractAlbum`). | Resources, Frontend | | 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 | +| DO-030-09 | `faces` table addendum — adds nullable `laplacian_variance FLOAT` column. Stores the Laplacian variance (sharpness) value computed by the Python service during detection. Populated from the detection callback payload. Used for face quality sorting in the face maintenance admin view (FR-030-40). | Models, Migrations | +| DO-030-10 | `persons` table addendum — adds `face_count UNSIGNED INT NOT NULL DEFAULT 0` and `photo_count UNSIGNED INT NOT NULL DEFAULT 0` denormalized counter columns to the existing `create_persons_table` migration (greenfield — no backfill needed). `face_count` = count of non-dismissed (`is_dismissed = false`) Face records referencing this Person. `photo_count` = count of distinct `photo_id` values among those same non-dismissed faces. Maintained by `FaceObserver` (see FR-030-41). | Models, Migrations | +| DO-030-11 | `photos` table addendum — adds `face_count UNSIGNED INT NOT NULL DEFAULT 0` denormalized counter column to the existing `create_faces_table` migration alongside `face_scan_status` (greenfield — no backfill needed). `face_count` = count of non-dismissed (`is_dismissed = false`) Face records referencing this Photo. Maintained by `FaceObserver` (see FR-030-42). | Models, Migrations | ### API Routes / Services @@ -291,15 +616,27 @@ Read via `config('features.ai-vision.face-url')` and `config('features.ai-vision | API-030-07 | POST /api/v2/Person/{id}/claim | Link Person to current User (1-1) | | | API-030-08 | GET /api/v2/Person/{id}/photos | Paginated photos containing Person | | | API-030-09 | POST /api/v2/Face/{id}/assign | Assign a Face to a Person (or create new Person) | Body: person_id or new_person_name | -| API-030-10 | POST /api/v2/FaceDetection/scan | Request face detection for photo(s) or album | Body: photo_ids[] or album_id, optional `force` boolean; dispatched in chunks of `ai_vision_face_scan_batch_size` (default 200) *(Q-030-45)* | +| API-030-10 | POST /api/v2/FaceDetection/scan | Request face detection for photo(s) or album | Body: photo_ids[] or album_id, optional `force` boolean; dispatched in chunks of 200 *(Q-030-45)* | | API-030-11 | POST /api/v2/FaceDetection/results | Receive face detection results (success or error) from Python service (service-to-service only; authenticated via `X-API-Key`, no user session) | Body: success payload or error payload | | API-030-12 | POST /api/v2/FaceDetection/bulk-scan | Admin: enqueue all unscanned photos (face_scan_status IS NULL); scope: full library (no album_id) or direct photos in specified album (non-recursive) *(Q-030-41)* | Admin-only | | 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 | +| API-030-21 | GET /api/v2/Maintenance::destroyDismissedFaces | Admin: check — returns count of dismissed faces (`is_dismissed = true`). Follows existing check/do Maintenance pattern. Hidden when count is 0. *(Q-030-55, FR-030-23)* | Admin-only | +| API-030-21b | POST /api/v2/Maintenance::destroyDismissedFaces | Admin: do — hard-delete all dismissed faces, their crop files, and their embeddings (same logic as API-030-16). Returns `{deleted_count: N}`. | Admin-only | +| API-030-22 | GET /api/v2/Maintenance::resetFaceScanStatus | Admin: check — returns combined count of stuck-pending photos (older than 720 min) + photos with `face_scan_status = 'failed'`. Hidden when count is 0. *(Q-030-55, Q-030-73, FR-030-24)* | Admin-only | +| API-030-22b | POST /api/v2/Maintenance::resetFaceScanStatus | Admin: do — sets `face_scan_status = NULL` on all stuck-pending (>720 min) AND all failed photos in one operation. Returns `{reset_count: N}`. Supersedes the UI role of `Maintenance::resetStuckFaces` (which remains for CLI use only). *(Q-030-73)* | Admin-only | +| API-030-23 | POST /api/v2/FaceDetection/clusters/{cluster_id}/uncluster | Remove selected faces from a cluster by setting `cluster_label = NULL`. Body: `{face_ids: [str]}`. Only affects qualifying faces in the given cluster (`cluster_label = cluster_id AND person_id IS NULL AND is_dismissed = false`). Returns `{unclustered_count: int}`. *(FR-030-17, Q-030-56)* | Auth per permission mode | +| API-030-24 | POST /api/v2/Face/batch | Batch face operations. Body: `{face_ids: [str], action: "unassign"\|"assign", person_id?: str, new_person_name?: str}`. "unassign" sets `person_id = NULL`; "assign" sets `person_id` (or creates new Person). Returns `{affected_count: int, person_id?: str}`. *(FR-030-19, Q-030-58)* | Auth per permission mode; user must have assign permission for all faces | +| API-030-25 | GET /api/v2/Album/{id}/people | List distinct persons appearing in an album's photos. Joins `photo_albums → photos → faces → persons`. Returns `PaginatedPersonsResource`. Respects `ai_vision_face_permission_mode` and `is_searchable` filtering. *(FR-030-22, Q-030-62)* | Auth: album access required | +| API-030-26 | GET /api/v2/FaceDetection/clusters/{cluster_id}/faces | List all faces in a specific cluster (not just the preview subset). Paginated. Returns `FaceResource[]` for qualifying faces (`cluster_label = cluster_id AND person_id IS NULL AND is_dismissed = false`). *(FR-030-29)* | Auth per permission mode | +| API-030-27 | GET /api/v2/Face/maintenance | Admin: list all faces with extended metadata (confidence, laplacian_variance, person_name, cluster_label, photo thumbnail). Sortable by confidence and laplacian_variance. Paginated. *(FR-030-40)* | Admin-only | ### CLI Commands / Flags @@ -327,6 +664,10 @@ Read via `config('features.ai-vision.face-url')` and `config('features.ai-vision | TE-030-10 | face.dismissed | `face_id`, `photo_id` *(Q-030-47)* | | TE-030-11 | face.undismissed | `face_id`, `photo_id` *(Q-030-47)* | | TE-030-12 | face.bulk_deleted | `deleted_count` (count of dismissed faces hard-deleted by API-030-16) *(Q-030-47)* | +| TE-030-13 | face.unclustered | `cluster_id`, `face_count` (faces removed from cluster via uncluster action) *(Q-030-56)* | +| TE-030-14 | face.unassigned | `face_id`, `previous_person_id` (face removed from person, returned to unassigned pool) *(Q-030-57)* | +| TE-030-15 | face.batch_updated | `action` (unassign\|assign), `face_count`, `person_id` (nullable) *(Q-030-58)* | +| TE-030-16 | face.failed_scans_reset | `reset_count` (number of failed-status photos reset to null) *(Q-030-55)* | ### UI States @@ -339,6 +680,22 @@ Read via `config('features.ai-vision.face-url')` and `config('features.ai-vision | UI-030-05 | Scan progress | Trigger scan → Progress indicator (scanning N of M photos) | | UI-030-06 | Service unavailable | Python service down → Toast notification, face features gracefully disabled | | UI-030-07 | Selfie upload claim | User profile → "Find me" button → Upload selfie → Matching result displayed → Confirm claim | +| UI-030-08 | CTRL+click dismiss mode | Hold CTRL (desktop only) → face overlays turn red/dashed → click to dismiss directly *(FR-030-16, Q-030-70)* | +| UI-030-09 | Person miniature in dropdown | Face assignment dropdown shows 24px circular miniature + name + face count per person; type-ahead filter *(FR-030-20, Q-030-69)* | +| UI-030-10 | Face circles in detail panel | Photo detail sidebar → "People in this photo" → horizontal scrollable row of 48px circular crops with name labels *(FR-030-21, Q-030-71)* | +| UI-030-11 | Face overlay visibility toggle | Press `P` (confirmed free; `F` = fullscreen) → face overlays toggle between visible and hidden *(NFR-030-11, Q-030-65)* | +| UI-030-12 | Batch face selection mode | Person detail or cluster view → "Select" button → checkbox overlays → action bar *(FR-030-19)* | +| UI-030-13 | Merge person modal | PersonDetail → "Merge into..." → modal with person search dropdown → confirm merge *(FR-030-25)* | +| UI-030-14 | Maintenance dismiss cleanup | Maintenance page → "Destroy Dismissed Faces" card (visible only when count > 0) *(FR-030-23)* | +| UI-030-15 | Maintenance reset face scan status | Maintenance page → "Reset Face Scan Status" card — combined reset for stuck-pending (>720 min) AND failed scans (visible only when combined count > 0) *(FR-030-24, Q-030-73)* | +| UI-030-16 | Cluster detail view | Click cluster card → full grid of all faces in cluster; assign name or dismiss individual faces *(FR-030-29, FR-030-30)* | +| UI-030-17 | Cluster infinite scroll | Scroll near bottom of cluster page → next page loads automatically *(FR-030-28)* | +| UI-030-18 | People context menu | Right-click PersonCard → context menu with Merge/Privacy/Assign/Remove *(FR-030-32)* | +| UI-030-19 | Inline person name edit | Click person name → editable inline input; Enter saves; Escape cancels *(FR-030-34)* | +| UI-030-20 | Album-style photo layout in PersonDetail | Person photos use justified gallery layout matching album views *(FR-030-36)* | +| UI-030-21 | Photo lightbox from PersonDetail | Click photo → full photo viewer/lightbox opens *(FR-030-39)* | +| UI-030-22 | Drag & blue-border multi-select | Click/Shift+click/drag-select photos with blue border highlight *(FR-030-38)* | +| UI-030-23 | Face Maintenance admin table | Admin sortable table of all faces with confidence/blur scores *(FR-030-40)* | ## Telemetry & Observability @@ -496,6 +853,34 @@ routes: method: POST path: /api/v2/Maintenance::resetStuckFaces notes: admin-only; do — reset stuck pending photos to null; body: older_than_minutes (default 60) (Q-030-48) + - id: API-030-21 + method: GET + path: /api/v2/Maintenance::destroyDismissedFaces + notes: admin-only; check — count of dismissed faces (Q-030-55) + - id: API-030-21b + method: POST + path: /api/v2/Maintenance::destroyDismissedFaces + notes: admin-only; do — hard-delete all dismissed faces + crops + embeddings (Q-030-55) + - id: API-030-22 + method: GET + path: /api/v2/Maintenance::resetFaceScanStatus + notes: admin-only; check — combined count of stuck-pending (>720 min) + failed face scans (Q-030-55, Q-030-73) + - id: API-030-22b + method: POST + path: /api/v2/Maintenance::resetFaceScanStatus + notes: admin-only; do — reset both stuck-pending AND failed scans to null in one operation (Q-030-73) + - id: API-030-23 + method: POST + path: /api/v2/FaceDetection/clusters/{cluster_id}/uncluster + notes: remove selected faces from cluster (set cluster_label = NULL) (Q-030-56) + - id: API-030-24 + method: POST + path: /api/v2/Face/batch + notes: batch face operations — unassign or assign multiple faces (Q-030-58) + - id: API-030-25 + method: GET + path: /api/v2/Album/{id}/people + notes: list distinct persons in album photos (Q-030-62) cli_commands: - id: CLI-030-01 command: php artisan lychee:scan-faces @@ -533,6 +918,14 @@ telemetry_events: event: face.undismissed - id: TE-030-12 event: face.bulk_deleted + - id: TE-030-13 + event: face.unclustered + - id: TE-030-14 + event: face.unassigned + - id: TE-030-15 + event: face.batch_updated + - id: TE-030-16 + event: face.failed_scans_reset ui_states: - id: UI-030-01 description: People grid page @@ -548,6 +941,22 @@ ui_states: description: Service unavailable state - id: UI-030-07 description: Selfie upload claim modal + - id: UI-030-08 + description: CTRL+click dismiss mode on face overlays + - id: UI-030-09 + description: Person miniature in assignment dropdown + - id: UI-030-10 + description: Face circles in photo detail panel + - id: UI-030-11 + description: Face overlay visibility toggle (P key) + - id: UI-030-12 + description: Batch face selection mode + - id: UI-030-13 + description: Merge person modal + - id: UI-030-14 + description: Maintenance dismiss cleanup card + - id: UI-030-15 + description: Maintenance reset failed scans card ``` ## Appendix @@ -577,7 +986,7 @@ Person ◄──many──► Photo (derived through Face: Person has many Face ### Inter-Service Communication (REST + Webhook Callbacks + Shared Volume) -Communication uses REST API with webhook callbacks. Photo files are accessed via **shared Docker volume** (Q-030-07 resolved). Authentication via a single shared symmetric API key stored in `.env` as `AI_VISION_FACE_API_KEY` and read via `config('features.ai-vision.face-api-key')` — **never** from the `configs` table. Header: `X-API-Key: `. *(Q-030-15 resolved: single key, both directions; Q-030-19 resolved: ai_vision_* naming)* +Communication uses REST API with webhook callbacks. Photo files are accessed via **shared Docker volume** (Q-030-07 resolved). Authentication via a single shared symmetric API key stored in `.env` as `AI_VISION_FACE_API_KEY` and read via `config('features.ai-vision-service.face-api-key')` — **never** from the `configs` table. Header: `X-API-Key: `. *(Q-030-15 resolved: single key, both directions; Q-030-19 resolved: ai_vision_* naming)* > **Separation of concerns:** The `POST /api/v2/FaceDetection/results` callback endpoint is authenticated **exclusively** via the `X-API-Key` header. It is not accessible via user session or admin session — even an authenticated admin cannot call this endpoint through the normal auth middleware. @@ -654,17 +1063,29 @@ 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 | Component | Choice | Rationale | |-----------|--------|-----------| -| **Language** | Python 3.13+ | Required for InsightFace compatibility and modern type annotation syntax (`type` statements, `X \| Y` unions). | +| **Language** | Python 3.13+ | Required for DeepFace compatibility and modern type annotation syntax (`type` statements, `X \| Y` unions). | | **Package manager** | `uv` | Fast dependency resolution and lockfile support. `pyproject.toml` as single config source. | | **Web framework** | FastAPI | Async-capable, auto-generated OpenAPI docs, native Pydantic integration for request/response validation. | -| **Face detection & recognition** | InsightFace (ONNX Runtime backend) | State-of-the-art accuracy on LFW benchmark (99.8%+); permissive Apache-2.0 license; ONNX Runtime allows CPU-only or GPU-accelerated inference without heavy CUDA build deps. | -| **Face detection model** | `buffalo_l` (default), configurable via `MODEL_NAME` env var | InsightFace model pack including RetinaFace detector + ArcFace recognition. `buffalo_l` = large/high-accuracy; `buffalo_s` = small/faster alternative. | +| **Face detection & recognition** | DeepFace (MIT license) | MIT-licensed; supports ArcFace recognition (512-dim embeddings) with RetinaFace detector backend. No Cython build step required. | +| **Face detection model** | `ArcFace` (default recognition model) + `retinaface` (default detector backend), configurable via `MODEL_NAME` and `DETECTOR_BACKEND` env vars | DeepFace ArcFace delivers the same 512-dim embedding space as InsightFace's ArcFace component. RetinaFace provides high-accuracy face bounding boxes. | | **Embedding storage** | SQLite + `sqlite-vec` (default); PostgreSQL + `pgvector` (optional) | SQLite for single-container simplicity; pgvector for production-scale deployments. Configurable via `STORAGE_BACKEND` env var. | | **Clustering** | scikit-learn DBSCAN (density-based) | Offline batch operation grouping unassigned faces for the People browse UI. **Not used for per-scan suggestions** — those use NN cosine similarity search via `sqlite-vec`/`pgvector`. Triggered manually via `POST /cluster`. *(Q-030-30 resolved)* | | **Image processing** | Pillow (PIL) | Face crop generation (150×150px JPEG). | @@ -693,7 +1114,7 @@ ai-vision-service/ │ │ └── schemas.py # Pydantic request/response models │ ├── detection/ │ │ ├── __init__.py -│ │ ├── detector.py # InsightFace face detection wrapper +│ │ ├── detector.py # DeepFace face detection wrapper │ │ └── cropper.py # Face crop generation (150x150 JPEG, base64) │ ├── embeddings/ │ │ ├── __init__.py @@ -718,7 +1139,7 @@ ai-vision-service/ #### Concurrency Model *(Q-030-26, Q-030-27 resolved)* -InsightFace inference is synchronous and CPU-bound. The `POST /detect` handler offloads detection to a `ThreadPoolExecutor` via `asyncio.run_in_executor`, keeping the FastAPI event loop responsive while detection runs on a background thread. Pool size is configurable via `VISION_FACE_THREAD_POOL_SIZE` (default `1`). +DeepFace inference is synchronous and CPU-bound. The `POST /detect` handler offloads detection to a `ThreadPoolExecutor` via `asyncio.run_in_executor`, keeping the FastAPI event loop responsive while detection runs on a background thread. Pool size is configurable via `VISION_FACE_THREAD_POOL_SIZE` (default `1`). **Structured logging checkpoints** (required at each stage): @@ -806,7 +1227,8 @@ class AppSettings(BaseSettings): lychee_api_url: str lychee_api_key: str # Key Python sends to Lychee callbacks (X-API-Key header) api_key: str # Key Lychee sends to authenticate with this service (X-API-Key header) - model_name: str = "buffalo_l" + model_name: str = "ArcFace" + detector_backend: str = "retinaface" detection_threshold: float = 0.5 # Q-030-31: bounding box filter — faces below threshold excluded from callback match_threshold: float = 0.5 # Q-030-31: similarity search cutoff for suggestions and selfie matching rescan_iou_threshold: float = 0.5 # Q-030-35: IoU threshold for bounding-box matching on re-scan (preserves person_id) @@ -873,7 +1295,7 @@ python-version = "3.13" | Layer | Scope | Tooling | Notes | |-------|-------|---------|-------| -| **Unit** | Detection, cropping, embedding CRUD, clustering, matching | pytest + fixtures with sample images | Mock InsightFace model for fast tests; one slow integration test with real model. | +| **Unit** | Detection, cropping, embedding CRUD, clustering, matching | pytest + fixtures with sample images | Mock DeepFace model for fast tests; one slow integration test with real model. | | **API integration** | Full endpoint flows (detect → callback, match → response, health) | pytest-asyncio + httpx `AsyncClient` | Test against FastAPI `TestClient`; mock external HTTP calls (callback to Lychee). | | **Validation** | Pydantic schema enforcement | pytest | Invalid payloads return 422 with structured errors. | | **Type checking** | Static analysis | ty (Astral) | Run in CI via `uv run ty check`; zero errors required. | @@ -934,21 +1356,27 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv WORKDIR /app COPY pyproject.toml uv.lock ./ RUN uv sync --frozen --no-dev -# Bake buffalo_l model weights into the image at build time (~300 MB download). (Q-030-32 resolved: Option A) -# The resulting image is ~1 GB larger but starts instantly and works in airgapped environments. +# Bake ArcFace + RetinaFace model weights into the image at build time. (Q-030-32 resolved: Option A) +# The resulting image starts instantly and works in airgapped environments. # Model updates require an image rebuild. -RUN uv run python -c \ - "from insightface.app import FaceAnalysis; \ - app = FaceAnalysis(name='buffalo_l', root='/root/.insightface', providers=['CPUExecutionProvider']); \ - app.prepare(ctx_id=-1); \ - print('buffalo_l model downloaded.')" +RUN DEEPFACE_HOME=/root/.deepface uv run python -c \ + "from deepface import DeepFace; \ + import numpy as np; \ + DeepFace.represent( \ + img_path=np.zeros((1, 1, 3), dtype='uint8'), \ + model_name='ArcFace', \ + detector_backend='retinaface', \ + enforce_detection=False, \ + ); \ + print('ArcFace + RetinaFace models downloaded.')" FROM python:3.13-slim AS runtime WORKDIR /app COPY --from=builder /app/.venv /app/.venv -COPY --from=builder /root/.insightface /root/.insightface +COPY --from=builder /root/.deepface /root/.deepface COPY app/ ./app/ ENV PATH="/app/.venv/bin:$PATH" +ENV DEEPFACE_HOME=/root/.deepface EXPOSE 8000 CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers ${VISION_FACE_WORKERS:-1}"] ``` @@ -959,9 +1387,12 @@ CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers ${VI |----------|----------|---------|-------------| | `VISION_FACE_LYCHEE_API_URL` | Yes | — | Lychee instance base URL for callbacks | | `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_MODEL_NAME` | No | `ArcFace` | DeepFace recognition model name | +| `VISION_FACE_DETECTOR_BACKEND` | No | `retinaface` | DeepFace detector backend (`retinaface`, `mtcnn`, `opencv`, `ssd`) | | `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 13e112ce3d6..b339b15d56b 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 @@ -1,7 +1,7 @@ # Feature 030 Tasks – Facial Recognition _Status: Draft_ -_Last updated: 2026-03-21_ +_Last updated: 2026-04-11_ > Keep this checklist aligned with the feature plan increments. Stage tests before implementation, record verification commands beside each task, and prefer bite-sized entries (≤90 minutes). > **Mark tasks `[x]` immediately** after each one passes verification—do not batch completions. Update the roadmap status when all tasks are done. @@ -12,7 +12,7 @@ _Last updated: 2026-03-21_ ### I1 – Python Service: Project Setup & Face Detection -- [ ] T-030-01 – Create Python service project structure with uv, ruff, ty. +- [x] T-030-01 – Create Python service project structure with uv, ruff, ty. _Intent:_ Create `ai-vision-service/` directory with: `pyproject.toml` (uv project config, ruff settings, ty config), `app/` (main application with `__init__.py`), `app/detection/`, `app/embeddings/`, `app/api/`, `app/clustering/`, `app/matching/`, `tests/`, `Dockerfile`, `README.md`. Configure ruff lint rules (E, W, F, I, N, UP, ANN, B, A, SIM, TCH, RUF) and ty in `pyproject.toml`. Create Pydantic `AppSettings` (BaseSettings) in `app/config.py` with all `VISION_FACE_`-prefixed env vars. Create Pydantic request/response schemas in `app/api/schemas.py`: `DetectRequest`, `FaceResult`, `DetectCallbackPayload`, `MatchResult`, `MatchResponse`, `HealthResponse`. All code fully type-annotated. _Verification commands:_ - `uv sync` @@ -20,13 +20,13 @@ _Last updated: 2026-03-21_ - `uv run ruff check` - `uv run ty check` -- [ ] T-030-02 – Implement face detection and crop generation with InsightFace. - _Intent:_ `app/detection/detector.py`: typed wrapper around InsightFace (ONNX Runtime backend, `buffalo_l` model). Accept photo filesystem path (shared Docker volume — Q-030-07 resolved), return list of `FaceResult` with bounding box coordinates as 0.0–1.0 relative values and confidence scores. `app/detection/cropper.py`: generate 150x150px JPEG face crop per detected face using Pillow, returned as base64 string (Q-030-09 resolved: server-side crop). Full type annotations; no `Any` types. +- [x] T-030-02 – Implement face detection and crop generation with DeepFace. + _Intent:_ `app/detection/detector.py`: typed wrapper around DeepFace (ArcFace recognition + RetinaFace detector backend). Accept photo filesystem path (shared Docker volume — Q-030-07 resolved), return list of `FaceResult` with bounding box coordinates as 0.0–1.0 relative values and confidence scores. `app/detection/cropper.py`: generate 150x150px JPEG face crop per detected face using Pillow, returned as base64 string (Q-030-09 resolved: server-side crop). Full type annotations. _Verification commands:_ - `uv run pytest tests/test_detection.py tests/test_cropper.py` - `uv run ty check` -- [ ] T-030-03 – Implement embedding generation and storage layer. +- [x] T-030-03 – Implement embedding generation and storage layer. _Intent:_ `app/embeddings/store.py`: abstract `EmbeddingStore` protocol (typed). `app/embeddings/sqlite_store.py`: SQLite+sqlite-vec implementation. `app/embeddings/pgvector_store.py`: PostgreSQL+pgvector implementation. CRUD operations for embeddings. Vector similarity search for matching. Configurable via `VISION_FACE_STORAGE_BACKEND` env var. Pydantic validation on all inputs. _Verification commands:_ - `uv run pytest tests/test_embeddings.py` @@ -34,19 +34,19 @@ _Last updated: 2026-03-21_ ### I2 – Python Service: Clustering, Matching & Callback -- [ ] T-030-04 – Implement face clustering with scikit-learn DBSCAN. +- [x] T-030-04 – Implement face clustering with scikit-learn DBSCAN. _Intent:_ `app/clustering/clusterer.py`: cluster similar face embeddings using scikit-learn DBSCAN. Configurable distance threshold (eps). Returns cluster labels for each embedding. Typed interface. No need to pre-specify cluster count (Q-030-03 resolved: auto-cluster with manual confirmation). _Verification commands:_ - `uv run pytest tests/test_clustering.py` - `uv run ty check` -- [ ] T-030-05 – Implement similarity matching. +- [x] T-030-05 – Implement similarity matching. _Intent:_ `app/matching/matcher.py`: `POST /match` endpoint logic (Q-030-12 resolved: dedicated endpoint). Accepts image file (multipart via FastAPI `UploadFile`), detects face, compares embedding against stored embeddings via `EmbeddingStore.similarity_search()`, returns list of `MatchResult` with confidence scores. Selfie image discarded after match — no temp file persisted (Q-030-11 resolved). Full type annotations. _Verification commands:_ - `uv run pytest tests/test_matching.py` - `uv run ty check` -- [ ] T-030-06 – Implement FastAPI REST API, scan callback flow, and API key auth. +- [x] T-030-06 – Implement FastAPI REST API, scan callback flow, and API key auth. _Intent:_ `app/main.py`: FastAPI app factory with lifespan handler (model loading on startup). `app/api/routes.py`: `POST /detect`, `POST /match`, `GET /health` — all using Pydantic request/response models. `app/api/dependencies.py`: API key auth as FastAPI dependency (validates `X-API-Key` header against `VISION_FACE_API_KEY`). Scan callback flow: receive `DetectRequest` → detect faces → generate embeddings + base64 crops → store embeddings → POST `DetectCallbackPayload` back to Lychee via httpx. `HealthResponse` includes model_loaded status and embedding_count. _Verification commands:_ - `uv run pytest tests/test_api.py` @@ -56,18 +56,18 @@ _Last updated: 2026-03-21_ ### I3 – Python Service: Docker Image, Deployment & CI/CD -- [ ] T-030-07 – Create Dockerfile and docker-compose integration. - _Intent:_ Multi-stage Dockerfile: builder stage uses `uv sync --frozen --no-dev`, runtime stage uses `python:3.13-slim`. Minimal image size. GPU support optional. Model (`buffalo_l`) baked into image at build time — lifespan handler loads it on startup, no runtime download (Q-030-32 resolved). Workers count via CMD shell form to honour `VISION_FACE_WORKERS`. All env vars `VISION_FACE_`-prefixed (see Pydantic `AppSettings`). Add service to Lychee's docker-compose example with shared photos volume and internal network. +- [x] T-030-07 – Create Dockerfile and docker-compose integration. + _Intent:_ Multi-stage Dockerfile: builder stage uses `uv sync --frozen --no-dev`, runtime stage uses `python:3.13-slim`. Minimal image size. GPU support optional via `tensorflow[and-cuda]`. ArcFace + RetinaFace models baked into image at build time — lifespan handler loads them on startup, no runtime download (Q-030-32 resolved). Workers count via CMD shell form to honour `VISION_FACE_WORKERS`. All env vars `VISION_FACE_`-prefixed (see Pydantic `AppSettings`). Add service to Lychee's docker-compose example with shared photos volume and internal network. _Verification commands:_ - `docker build -t lychee-ai-vision .` - `docker-compose up -d` -- [ ] T-030-08 – Create GitHub Actions CI/CD workflow for Python service. +- [x] T-030-08 – Create GitHub Actions CI/CD workflow for Python service. _Intent:_ `.github/workflows/python_ai_vision.yml`: triggers on push/PR when `ai-vision-service/**` changes. Jobs: lint (`uv run ruff format --check`, `uv run ruff check`), typecheck (`uv run ty check`), test (`uv run pytest --cov=app --cov-report=xml`, Python 3.13+3.14 matrix), docker-build (`docker build .`). Uses `astral-sh/setup-uv@v5`. Follow existing Lychee CI patterns: pinned action versions, `step-security/harden-runner`, concurrency groups. _Verification commands:_ - Push branch and verify workflow runs green -- [ ] T-030-09 – End-to-end smoke test in Docker. +- [x] T-030-09 – End-to-end smoke test in Docker. _Intent:_ docker-compose up → health check passes → detect endpoint responds → callback delivers results to mock Lychee endpoint. Verify shared volume photo access works. _Verification commands:_ - Integration test suite @@ -76,50 +76,56 @@ _Last updated: 2026-03-21_ ### I4 – Database Migrations -- [ ] 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. +- [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. **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. -- [ ] T-030-11 – Create `faces` table migration and `face_suggestions` table migration (FR-030-02, DO-030-02, DO-030-05, Q-030-33/34/38). +- [x] T-030-11 – Create `faces` table migration and `face_suggestions` table migration (FR-030-02, DO-030-02, DO-030-05, Q-030-33/34/38). _Intent:_ `faces` migration: `id` (string PK), `photo_id` (FK→photos CASCADE), `person_id` (nullable FK→persons SET NULL), `x`/`y`/`width`/`height` (float, 0.0–1.0), `confidence` (float), `crop_token` (nullable string — random high-entropy token; file served nginx-direct from `uploads/faces/{tok[0:2]}/{tok[2:4]}/{tok}.jpg`, Q-030-34), `is_dismissed` (boolean default false), timestamps. Indexes on `photo_id`, `person_id`. `face_suggestions` migration: `face_id` (FK→faces CASCADE), `suggested_face_id` (FK→faces CASCADE), `confidence` (float 0.0–1.0); unique on `(face_id, suggested_face_id)`. Separate migration: add nullable `face_scan_status VARCHAR(16)` column to `photos` table (Q-030-38). _Verification commands:_ - `php artisan test` _Notes:_ Bounding box values are relative (0.0–1.0) per NFR-030-06. `crop_path` is NOT a column — `crop_url` is a computed accessor derived from `crop_token`. -- [ ] T-030-12 – Add AI Vision config entries migration (FR-030-07, FR-030-08, NFR-030-09). +- [x] T-030-12 – Add AI Vision config entries migration (FR-030-07, FR-030-08, NFR-030-09). _Intent:_ Config entries in `configs` table (`cat = 'AI Vision'`, `level = 1` / Supporter Edition): `ai_vision_enabled` (0|1, default 0), `ai_vision_face_enabled` (0|1, default 0), `ai_vision_face_permission_mode` (string, default `restricted`), `ai_vision_face_selfie_confidence_threshold` (float, default 0.8), `ai_vision_face_person_is_searchable_default` (0|1, default 1), `ai_vision_face_allow_user_claim` (0|1, default 1), `ai_vision_face_scan_batch_size` (integer, default 200). Infrastructure secrets (`AI_VISION_FACE_URL`, `AI_VISION_FACE_API_KEY`) added to `config/features.php` — NOT in the `configs` table. _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` - `make phpstan` -- [ ] T-030-15 – Implement Person model (FR-030-01, FR-030-03). +- [x] T-030-15 – Implement Person model (FR-030-01, FR-030-03). _Intent:_ Eloquent model with: `user()` belongsTo, `faces()` hasMany, `photos()` custom relation via Face→Photo, `scopeSearchable()` query scope for is_searchable filtering. Fillable: name, user_id, is_searchable. _Verification commands:_ - `php artisan test --filter=PersonModelTest` - `make phpstan` -- [ ] T-030-16 – Implement Face model (FR-030-02). +- [x] T-030-16 – Implement Face model (FR-030-02). _Intent:_ Eloquent model with: `photo()` belongsTo, `person()` belongsTo (nullable). `crop_url` computed accessor (from `crop_token`). Fillable: photo_id, person_id, x, y, width, height, confidence, crop_token, is_dismissed. Casts for float fields. _Verification commands:_ - `php artisan test --filter=FaceModelTest` - `make phpstan` -- [ ] T-030-17 – Add `faces()`, `faceSuggestions()` relationships to Photo model; `person()` to User model; `ScanStatus` Enum cast (FR-030-04, FR-030-05, Q-030-38). +- [x] T-030-17 – Add `faces()`, `faceSuggestions()` relationships to Photo model; `person()` to User model; `ScanStatus` Enum cast (FR-030-04, FR-030-05, Q-030-38). _Intent:_ Photo hasMany Face; User hasOne Person. Add `ScanStatus` PHP Backed Enum (values: `pending`, `completed`, `failed`) and cast `face_scan_status` via it on the Photo model. FaceSuggestion Eloquent model: `face()` / `suggestedFace()` belongsTo Face; `confidence` float; fillable `[face_id, suggested_face_id, confidence]`. *(DO-030-05, DO-030-06)* _Verification commands:_ - `php artisan test --filter=PersonModelTest` @@ -128,21 +134,21 @@ _Last updated: 2026-03-21_ ### I6 – Spatie Data Resources -- [ ] 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). +- [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_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` - `make phpstan` -- [ ] T-030-20 – Implement PeopleController with CRUD actions (API-030-01 through API-030-05). +- [x] T-030-20 – Implement PeopleController with CRUD actions (API-030-01 through API-030-05). _Intent:_ index (paginated, searchable scope), show, store, update, destroy. Form requests: StorePersonRequest (name required, user_id optional unique), UpdatePersonRequest (name, is_searchable). Permission mode middleware/gate: check `ai_vision_face_permission_mode` config. Routes in api_v2.php. _Verification commands:_ - `php artisan test --filter=PeopleControllerTest` @@ -150,47 +156,47 @@ _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` -- [ ] 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` -- [ ] 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. +- [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. 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` - `make phpstan` -- [ ] T-030-26b – Implement FaceController: `assign`, `toggleDismissed`, and `destroyDismissed` actions (API-030-09, API-030-14, API-030-16). +- [x] T-030-26b – Implement FaceController: `assign`, `toggleDismissed`, and `destroyDismissed` actions (API-030-09, API-030-14, API-030-16). _Intent:_ `POST /Face/{id}/assign`: accepts `person_id` OR `new_person_name`; creates Person if needed; updates `face.person_id`. `PATCH /Face/{id}`: flips `is_dismissed`; auth: photo owner or admin; emits `face.dismissed` or `face.undismissed`. `DELETE /Face/dismissed`: admin-only; loops `is_dismissed = true` faces, deletes crop files from `uploads/faces/`, deletes Face records, emits `face.bulk_deleted` with count. Create form requests: `AssignFaceRequest`, `ToggleDismissedRequest`. Register routes. _Verification commands:_ - `php artisan test --filter=FaceAssignmentTest` @@ -199,45 +205,45 @@ _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` - `make phpstan` -- [ ] T-030-29 – Implement FaceDetectionController, DispatchFaceScanJob, ProcessFaceDetectionResults, and auto-on-upload hook (API-030-10, API-030-11, API-030-12, S-030-23, Q-030-28/33/34/35/45). - _Intent:_ `scan` action: validate target (`photo_ids[]` or `album_id`), set `face_scan_status = pending`, dispatch DispatchFaceScanJob in chunks of `ai_vision_face_scan_batch_size` (default 200, Q-030-45), return 202. Job sends HTTP `POST /detect` with `photo_path` (filesystem via shared volume) — **no `callback_url` in body** (Python reads callback URL from `VISION_FACE_LYCHEE_API_URL` env, Q-030-28). `results` action: validate X-API-Key; on success — decode base64 crops, store at `uploads/faces/{tok[0:2]}/{tok[2:4]}/{tok}.jpg`, create Face records with `crop_token`, create/replace FaceSuggestion rows from `suggestions[]` (Q-030-33), IoU-match old faces on re-scan to preserve `person_id` (Q-030-14; threshold from `VISION_FACE_RESCAN_IOU_THRESHOLD`, Q-030-35), set `face_scan_status = completed`; on error — set `face_scan_status = failed` (Q-030-17). `bulk-scan` action: enqueue photos where `face_scan_status IS NULL` (Q-030-40/41). Auto-on-upload: listener on PhotoSaved event dispatches job when `ai_vision_face_enabled = 1`. +- [x] T-030-29 – Implement FaceDetectionController, DispatchFaceScanJob, ProcessFaceDetectionResults, and auto-on-upload hook (API-030-10, API-030-11, API-030-12, S-030-23, Q-030-28/33/34/35/45). + _Intent:_ `scan` action: validate target (`photo_ids[]` or `album_id`), set `face_scan_status = pending`, dispatch DispatchFaceScanJob in chunks of 200 (Q-030-45), return 202. Job sends HTTP `POST /detect` with `photo_path` (filesystem via shared volume) — **no `callback_url` in body** (Python reads callback URL from `VISION_FACE_LYCHEE_API_URL` env, Q-030-28). `results` action: validate X-API-Key; on success — decode base64 crops, store at `uploads/faces/{tok[0:2]}/{tok[2:4]}/{tok}.jpg`, create Face records with `crop_token`, create/replace FaceSuggestion rows from `suggestions[]` (Q-030-33), IoU-match old faces on re-scan to preserve `person_id` (Q-030-14; threshold from `VISION_FACE_RESCAN_IOU_THRESHOLD`, Q-030-35), set `face_scan_status = completed`; on error — set `face_scan_status = failed` (Q-030-17). `bulk-scan` action: enqueue photos where `face_scan_status IS NULL` (Q-030-40/41). Auto-on-upload: listener on PhotoSaved event dispatches job when `ai_vision_face_enabled = 1`. _Verification commands:_ - `php artisan test --filter=FaceDetection` - `make phpstan` ### 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` - `make phpstan` -- [ ] T-030-31 – Implement `lychee:scan-faces` and `lychee:scan-faces --album={id}` commands (CLI-030-01, CLI-030-02). +- [x] T-030-31 – Implement `lychee:scan-faces` and `lychee:scan-faces --album={id}` commands (CLI-030-01, CLI-030-02). _Intent:_ Query photos where `face_scan_status IS NULL`, dispatch DispatchFaceScanJob. `--album={id}` limits to direct photos in that album (non-recursive). Progress output per batch. _Verification commands:_ - `php artisan test --filter=ScanFacesCommandTest` - `make phpstan` -- [ ] T-030-31b – Implement `lychee:rescan-failed-faces [--stuck-pending] [--older-than=N]` command (CLI-030-03, Q-030-40/48). +- [x] T-030-31b – Implement `lychee:rescan-failed-faces [--stuck-pending] [--older-than=N]` command (CLI-030-03, Q-030-40/48). _Intent:_ Default: re-enqueue all photos where `face_scan_status = 'failed'`. With `--stuck-pending`: additionally reset photos with `face_scan_status = 'pending'` and `updated_at < now() - N minutes` (default `--older-than=60`) back to `null`, making them eligible for a fresh scan. *(Q-030-48)* _Verification commands:_ - `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,14 +251,14 @@ _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). - _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. +- [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. Additionally verify `next_photo_id` and `previous_photo_id` are set relative to the person's collection: first photo has `previous_photo_id = null`, last photo has `next_photo_id = null`, and middle photos chain correctly. *(Resolved Q-030-74)* _Verification commands:_ - `php artisan test --filter=PersonPhotosTest` - `make phpstan` -- [ ] T-030-33 – Implement PersonPhotosController (API-030-08). - _Intent:_ GET /Person/{id}/photos: paginated photos through Face join, apply PhotoQueryPolicy for access control. Register route. +- [x] T-030-33 – Implement PersonPhotosController (API-030-08). + _Intent:_ GET /Person/{id}/photos: paginated photos through Face join, apply PhotoQueryPolicy for access control. After fetching the ordered paginated collection, compute sequential `next_photo_id` / `previous_photo_id` for each photo in the page: `photos[i].next_photo_id = photos[i+1].id` (null for last), `photos[i].previous_photo_id = photos[i-1].id` (null for first). These person-relative values override the album-relative fields native to `PhotoResource`, allowing `PhotoPanel.vue` to navigate within the person's collection natively. Register route. *(Resolved Q-030-74)* _Verification commands:_ - `php artisan test --filter=PersonPhotosTest` - `make phpstan` @@ -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,47 +317,465 @@ _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:_ + - `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:_ - - 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` + - `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` + +### Phase 5: Face UX Enhancements + +### I23 – Face Dismiss UX: Modal Button + CTRL+Click Overlay + +- [x] T-030-54 – Add "Dismiss" button to FaceAssignmentModal.vue (FR-030-16, S-030-33). + _Intent:_ Add a "Dismiss" button to FaceAssignmentModal.vue (alongside existing "Cancel" and "Assign" buttons). Clicking "Dismiss" calls `PATCH /Face/{id}` to set `is_dismissed = true`, closes the modal, and refreshes the face overlay on the photo. + _Verification commands:_ + - `npm run check` + - `npm run format` + +- [x] T-030-55 – Implement CTRL+click dismiss shortcut on FaceOverlay.vue (FR-030-16, S-030-34, UI-030-08, Q-030-70). + _Intent:_ In FaceOverlay.vue, **first** check `isTouchDevice()` from `keybindings-utils.ts` — if true, skip all CTRL+click setup (Q-030-70: B, no touch shortcut). On non-touch devices: listen for CTRL `keydown`/`keyup` events on `window`. When CTRL is held: (a) switch all face rectangle CSS to red dashed borders (`border: 2px dashed red`); (b) change cursor to `crosshair` to indicate dismiss action. When a rectangle is clicked in CTRL state: call `PATCH /Face/{id}` directly (no modal), remove the overlay element on success, show success toast. When CTRL is released, revert to normal overlay styles. + _Verification commands:_ + - `npm run check` + - `npm run format` + +### I24 – Face Overlay Config Settings & P-Key Toggle + +- [x] T-030-56 – Add config migration for face overlay settings (NFR-030-11, S-030-44). + _Intent:_ Add two new config entries to the AI Vision category: `ai_vision_face_overlay_enabled` (0|1, default 1, level 1/SE) — master toggle for face overlay rendering; `ai_vision_face_overlay_default_visibility` (string: `visible`|`hidden`, default `visible`, level 1/SE) — default visibility when viewing photos. + _Verification commands:_ + - `php artisan test` + - `make phpstan` + +- [x] T-030-57 – Implement face overlay config gating and P-key toggle in FaceOverlay.vue (NFR-030-11, FR-030-21, S-030-41, UI-030-11, Q-030-65). + _Intent:_ Gate FaceOverlay rendering on `ai_vision_face_overlay_enabled` config (if 0, render nothing). Initialize overlay visibility from `ai_vision_face_overlay_default_visibility` config. Register `P` key handler using `onKeyStroke('p', ...)` from `@vueuse/core` with `shouldIgnoreKeystroke()` guard — `P` is **confirmed free** (confirmed in Q-030-65: `F` maps to fullscreen via `Album.vue` `onKeyStroke("f", ...)`). + _Verification commands:_ + - `npm run check` + - `npm run format` + +### I25 – Face Circles in Photo Detail Panel + +- [x] T-030-58 – Add "People in this photo" section to PhotoDetails.vue (FR-030-21, S-030-38, S-030-39, UI-030-10, Q-030-70, Q-030-71). + _Intent:_ Add a new section titled "People in this photo" in `PhotoDetails.vue` (photo detail sidebar). Render a horizontal flex row (`overflow-x: auto`) of circular face crop `` elements (48px diameter, `border-radius: 50%`, `object-fit: cover`) sourced from `FaceResource.crop_url`. Below each circle, show person name (`person_name` from FaceResource or "???" for unassigned). Section hidden when: no faces detected, `ai_vision_face_overlay_enabled = 0`, or `ai_vision_enabled = 0`. Click on a face circle → emit event to open FaceAssignmentModal for that face. CTRL+click (desktop only, checked via `isTouchDevice()`) → call `PATCH /Face/{id}` to dismiss (same pattern as I23; no touch shortcut per Q-030-70). Overflow handled by horizontal scroll — all circles accessible by scrolling, no "+N more" truncation needed (Q-030-71: A). + _Verification commands:_ + - `npm run check` + - `npm run format` + +### I26 – Batch Face Operations: API + Frontend + +- [x] T-030-59 – Implement `POST /api/v2/Face/batch` endpoint (FR-030-19, API-030-24, S-030-37). + _Intent:_ New endpoint in FaceController. Body: `{face_ids: string[], action: "unassign"|"assign", person_id?: string, new_person_name?: string}`. For "unassign": bulk `UPDATE faces SET person_id = NULL WHERE id IN (...)`. For "assign": if `person_id` provided, validate it exists; if `new_person_name` provided, create new Person. Then bulk `UPDATE faces SET person_id = ? WHERE id IN (...)`. Auth: check assign permission for every face. Return `{affected_count: int, person_id?: string}`. Emit `face.batch_updated` telemetry. Create `BatchFaceRequest` form request. + _Verification commands:_ + - `php artisan test --filter=FaceBatchTest` + - `make phpstan` + +- [x] T-030-60 – Implement `POST /api/v2/FaceDetection/clusters/{cluster_id}/uncluster` endpoint (FR-030-17, API-030-23, S-030-35). + _Intent:_ New endpoint in FaceClusterController. Body: `{face_ids: string[]}`. Sets `cluster_label = NULL` for faces matching: `id IN (face_ids) AND cluster_label = cluster_id AND person_id IS NULL AND is_dismissed = false`. Returns `{unclustered_count: int}`. Emit `face.unclustered` telemetry. Create `UnclusterFacesRequest` form request. Register route. + _Verification commands:_ + - `php artisan test --filter=FaceUnclusterTest` + - `make phpstan` + +- [x] T-030-61 – Write feature tests for batch face operations and uncluster (FR-030-17, FR-030-19). + _Intent:_ Tests: batch unassign (person_id set to NULL on selected faces), batch assign to existing person, batch assign creating new person, uncluster faces from cluster (cluster_label set to NULL), auth checks (unauthorized user → 403), invalid face_ids (422), empty face_ids (422). + _Verification commands:_ + - `php artisan test --filter=FaceBatch` + - `php artisan test --filter=FaceUncluster` + - `make phpstan` + +- [x] T-030-62 – Implement batch selection mode in PersonDetail.vue and FaceClusters.vue (FR-030-19, UI-030-12). + _Intent:_ Add "Select" toggle button to PersonDetail.vue (face grid section) and FaceClusters.vue (each cluster card). When active: checkbox overlay appears on each face crop thumbnail. Selecting faces shows an action bar at the bottom: "Unassign (N)" (PersonDetail only), "Reassign to..." (opens person search dropdown), "Assign to new person" (text input), "Uncluster" (FaceClusters only). Each action calls the corresponding API endpoint. After action, deselect all and refresh the view. Create `FaceBatchService.ts` with typed functions: `batchUpdate(faceIds, action, personId?, newPersonName?)`, `unclusterFaces(clusterId, faceIds)`. + _Verification commands:_ + - `npm run check` + - `npm run format` + +### I27 – Maintenance Blocks: Dismiss Cleanup + Reset Failed/Stuck Scans + +- [x] T-030-63 – Implement `DestroyDismissedFaces` maintenance controller (FR-030-23, API-030-21/21b, S-030-42). + _Intent:_ New maintenance controller class following the existing check/do pattern. `check(MaintenanceRequest)`: returns count of `Face::where('is_dismissed', true)->count()`. Returns 0 if AI Vision is not enabled. `do(MaintenanceRequest)`: reuse `destroyDismissed` logic — collect dismissed face IDs, delete crop files, delete Face records, dispatch `DeleteFaceEmbeddingsJob`, return `{deleted_count}`. Register `GET`/`POST` routes as `Maintenance::destroyDismissedFaces` in `api_v2.php`. + _Verification commands:_ + - `php artisan test --filter=MaintenanceDestroyDismissedFacesTest` + - `make phpstan` + +- [x] T-030-64 – Implement `ResetFaceScanStatus` combined maintenance controller (FR-030-24, API-030-22/22b, S-030-43, Q-030-73). + _Intent:_ New maintenance controller class `ResetFaceScanStatus` following check/do pattern. Combines stuck-pending AND failed resets into one block (Q-030-73: group together). `check(MaintenanceRequest)`: returns combined count — stuck-pending (older than 720 min: `face_scan_status = PENDING AND updated_at < now() - 720min`) + failed (`face_scan_status = FAILED`). Returns 0 if AI Vision is not enabled. `do(MaintenanceRequest)`: single DB operation that resets both: `Photo::where(fn → face_scan_status=FAILED OR (face_scan_status=PENDING AND updated_at < cutoff))->update(['face_scan_status' => null])`. Returns `{reset_count: N}`. Emit `face.failed_scans_reset` telemetry. Register routes as `Maintenance::resetFaceScanStatus` in `api_v2.php`. The existing `ResetStuckFaces.php` controller remains (unchanged) for CLI use. + _Verification commands:_ + - `php artisan test --filter=MaintenanceResetFailedFaceScansTest` + - `make phpstan` + +- [x] T-030-65 – Create maintenance Vue components for dismiss cleanup and combined scan reset (UI-030-14, UI-030-15, Q-030-73). + _Intent:_ Create `MaintenanceDestroyDismissedFaces.vue`: follows existing `MaintenanceBulkScanFaces.vue` pattern. On mount, calls `GET /Maintenance::destroyDismissedFaces` check endpoint. If count is 0, component renders nothing (v-if). If count > 0, shows card with count and "Destroy All" button. Button calls `POST /Maintenance::destroyDismissedFaces`, refreshes count on success. Create `MaintenanceResetFaceScanStatus.vue` (NOT separate stuck/failed cards — combined per Q-030-73): same pattern, calls `GET/POST /Maintenance::resetFaceScanStatus`, label describes "stuck and failed scans". Add **both** components (two total, not three) to `Maintenance.vue` template alongside existing face maintenance blocks. + _Verification commands:_ + - `npm run check` + - `npm run format` + +### I28 – Merge Person UI + Person Miniature in Dropdown + +- [x] T-030-66 – Create MergePersonModal.vue (FR-030-25, S-030-45, UI-030-13). + _Intent:_ New modal component `MergePersonModal.vue`. Props: `sourcePerson` (PersonResource). Content: header "Merge {source.name} into:", PrimeVue Dropdown with person search (same custom option template as T-030-67 — miniature + name + count), filter by typing. Warning text explaining merge consequences (face count moved, source deleted, irreversible). Cancel/Merge buttons. On confirm, call `POST /Person/{source.id}/merge` with `{source_person_id: source.id}` (note: URL `{id}` = target, body = source). After success, navigate to target person page. Add "Merge into..." button to PersonDetail.vue actions, gated on merge permission. + _Verification commands:_ + - `npm run check` + - `npm run format` + +- [x] T-030-67 – Add person miniature to FaceAssignmentModal dropdown (FR-030-20, S-030-46, UI-030-09). + _Intent:_ Update the existing person Dropdown in FaceAssignmentModal.vue to use a custom `option` template (PrimeVue `#option` slot). Each option renders: 24px circular `` (`border-radius: 50%`, `object-fit: cover`) from `person.representative_crop_url`, person name, face count in muted text. Fallback: placeholder person icon (PrimeVue `pi pi-user` or similar) when `representative_crop_url` is null. Reuse this template pattern in MergePersonModal.vue. + _Verification commands:_ + - `npm run check` + - `npm run format` + +### I29 – Album People Endpoint + +- [x] T-030-68 – Implement `GET /api/v2/Album/{id}/people` endpoint (FR-030-22, API-030-25, S-030-40). + _Intent:_ New `AlbumPeopleController` with `index()` action. Query joins `photo_albums → photos → faces → persons` to collect distinct persons in the album (non-recursive, direct photos only). Apply `ai_vision_face_permission_mode` visibility rules and `is_searchable` filtering. Return `PaginatedPersonsResource` (consistent with People listing). Create `AlbumPeopleRequest` form request: validate album_id exists, user has album access (use existing album access policy). Register route in `api_v2.php`. + _Verification commands:_ + - `php artisan test --filter=AlbumPeopleTest` + - `make phpstan` + +- [x] T-030-69 – Write feature tests for album people endpoint (FR-030-22, S-030-40). + _Intent:_ Tests: album with persons returns correct distinct list, album with no faces returns empty, non-searchable person filtered out for non-admin, user without album access gets 403, album not found returns 404, pagination works correctly. Test with photos linked via `photo_albums` pivot table. + _Verification commands:_ + - `php artisan test --filter=AlbumPeopleTest` + - `make phpstan` + +### I30 – Unassign Face from Person + +- [x] T-030-70 – Update face assign endpoint to support unassign (FR-030-18, S-030-36). + _Intent:_ Update `POST /Face/{id}/assign` in FaceController to accept `person_id: null` (or omitted `person_id` with neither `person_id` nor `new_person_name` present, treated as unassign). Sets `face.person_id = NULL`. Emit `face.unassigned` telemetry with `previous_person_id`. Update `AssignFaceRequest` validation to allow nullable `person_id`. Write feature test: assign a face, then unassign it; verify face is in unassigned state. + _Verification commands:_ + - `php artisan test --filter=FaceAssignment` + - `make phpstan` + +- [x] T-030-71 – Add "Remove from person" UI in PersonDetail.vue (FR-030-18). + _Intent:_ In PersonDetail.vue face grid (non-batch mode), add a small "×" remove button (or right-click context menu) on each face crop. Clicking calls `POST /Face/{id}/assign` with `person_id: null`. After success, remove the face from the grid and update the face count. + _Verification commands:_ + - `npm run check` + - `npm run format` + +### Phase 6: UX Polish & Face Maintenance + +### I31 – Face Cluster Page UX Overhaul + +- [x] T-030-72 – Replace "Load more" with infinite scroll in FaceClusters.vue (FR-030-28, S-030-49). + _Intent:_ Remove the "Load more" button. Add a sentinel `
` at the bottom of the cluster list observed by `IntersectionObserver`. When the sentinel enters the viewport, call `loadMore()` to fetch and append the next page. Show a loading spinner during fetch. Stop observing when on the last page (`current_page >= last_page`). Handle edge cases: empty results, error during fetch (show toast, stop observing temporarily). + _Verification commands:_ + - `npm run check` + - `npm run format` + +- [x] T-030-73 – Add Enter-to-submit on cluster name input (FR-030-26, S-030-47). + _Intent:_ In FaceClusters.vue, add `@keydown.enter` handler on the per-cluster `InputText`. When Enter is pressed and the name is non-empty after trim, call the same `assignCluster()` function as the "Assign" button. Prevent default form submission if wrapped in a form. + _Verification commands:_ + - `npm run check` + - `npm run format` + +- [x] T-030-74 – Add existing person dropdown to cluster assignment (FR-030-27, S-030-48). + _Intent:_ Add a PrimeVue `Dropdown` next to the name `InputText` in each cluster card. The dropdown shows a type-ahead filtered list of existing persons using the custom option template from T-030-67 (24px circular miniature + name + face count). When a person is selected from the dropdown, `assignCluster()` sends `{ person_id: selectedPerson.id }` instead of `{ new_person_name: name }`. The name input and dropdown are mutually exclusive — selecting a person clears the name input and vice versa. Fetch persons on page load via `PeopleService.getPeople()`. + _Verification commands:_ + - `npm run check` + - `npm run format` + +- [x] T-030-75 – Rework FaceClusters.vue layout to grid with descriptive header (FR-030-31). + _Intent:_ Replace the vertical `flex flex-col` cluster list with a responsive CSS grid (`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4`). Each cluster card is a compact unit containing: face thumbnails (top), name input + person dropdown (middle), and Assign/Dismiss buttons (bottom). Move "Run Clustering" and "Toggle Multi-Select" buttons from the `Toolbar` `#end` slot into the page body, below a new descriptive header paragraph: "Review face clusters to identify people. Assign a name to group similar faces, or dismiss false positives." The `Toolbar` retains only the back navigation and page title. + _Verification commands:_ + - `npm run check` + - `npm run format` + +### I32 – Face Cluster Detail View + Individual Face Dismiss + +- [x] T-030-76 – Implement `GET /api/v2/FaceDetection/clusters/{cluster_id}/faces` endpoint (API-030-26, FR-030-29). + _Intent:_ New action in `FaceClusterController`. Query: `Face::where('cluster_label', $clusterId)->where('person_id', null)->where('is_dismissed', false)` paginated. Return `FaceResource` collection. Auth per `ai_vision_face_permission_mode`. 404 if cluster_id has no qualifying faces. Register route in `api_v2.php`. + _Verification commands:_ + - `php artisan test --filter=FaceClusterFacesTest` + - `make phpstan` + +- [x] T-030-77 – Write feature tests for cluster faces endpoint (FR-030-29). + _Intent:_ Tests: list faces for valid cluster_id (paginated), 404 for unknown cluster_id, only qualifying faces returned (assigned/dismissed excluded), permission mode enforcement. + _Verification commands:_ + - `php artisan test --filter=FaceClusterFacesTest` + - `make phpstan` + +- [x] T-030-78 – Create cluster detail view in FaceClusters.vue (FR-030-29, FR-030-30, S-030-50, S-030-51). + _Intent:_ When a cluster card is clicked (or the "+N more" overflow badge), open a PrimeVue `` (no routing change). *(Resolved Q-030-75)* The Dialog fetches all faces via `GET /FaceDetection/clusters/{cluster_id}/faces` (paginated, infinite scroll within the dialog). Displays faces in a responsive grid. Each face crop has a small "×" dismiss badge (absolute positioned, top-right corner). Clicking "×" calls `PATCH /Face/{id}` to dismiss, removes the face from the grid, decrements cluster size. At the bottom of the Dialog: name input + existing person dropdown + "Create Person & Assign All" button + "Dismiss All" button. If all faces are dismissed, close the Dialog and remove the cluster from the parent list. + _Verification commands:_ + - `npm run check` + - `npm run format` + +### I33 – People Page: Context Menu + Compact Cards + +- [x] T-030-79 – Add context menu to PersonCard in People.vue (FR-030-32, S-030-52). + _Intent:_ Add a PrimeVue `ContextMenu` component to People.vue. On PersonCard `@contextmenu` (right-click / long-press), open the menu with items: (1) "Merge into..." — opens `MergePersonModal` with this person as source; (2) "Toggle privacy" — calls `PeopleService.update(person.id, { is_searchable: !person.is_searchable })`, updates in-place; (3) "Assign to user" (admin-only) — opens a PrimeVue `` with an autocomplete `Dropdown` listing user accounts (name + email); on confirm calls `PeopleService.update(person.id, { user_id: selectedUserId })`; requires extending `UpdatePersonRequest` to accept nullable `user_id` (admin-only validation gate) *(Resolved Q-030-76)*; (4) "Remove association" — calls `DELETE /Person/{id}` after `useConfirm()` confirmation, removes card from grid. Each action gated on `canEdit`. + _Verification commands:_ + - `npm run check` + - `npm run format` + +- [x] T-030-80 – Reduce PersonCard face crop size and add rounded corners (FR-030-33). + _Intent:_ In `PersonCard.vue`, reduce the face crop `` from its current size to ~80px diameter (_or_ whatever looks balanced with the new card dimensions). Add `border-radius: 12px` (`rounded-xl`) or similar rounded corners to the card container `
`. Ensure the card text (name, photo count) remains readable at the smaller size. Adjust the grid `gap` in `People.vue` if needed to compensate for the smaller cards. + _Verification commands:_ + - `npm run check` + - `npm run format` + +### I34 – Person Detail: Inline Edit, Dark Mode Fix, Compact Remove + +- [x] T-030-81 – Implement inline name editing in PersonDetail.vue (FR-030-34, S-030-53). + _Intent:_ Replace the separate `isEditing` form and pencil toolbar button with inline-editable name text. The person name in the header is initially rendered as a styled `` (or `

`). Clicking it replaces it with an `InputText` (same styling/size). `@keydown.enter` saves: call `PeopleService.update(person.id, { name: editName.trim() })`, revert to display mode. `@keydown.escape` cancels and reverts. `@blur` also saves (if changed). Remove the `pi pi-pencil` edit button from the toolbar `#end` slot. Keep the `isEditing` ref for internal state management. + _Verification commands:_ + - `npm run check` + - `npm run format` + +- [x] T-030-82 – Fix person name title color in dark mode (FR-030-35). + _Intent:_ In PersonDetail.vue, ensure the person name heading uses a theme-aware text color class. Replace any hardcoded dark text color (e.g. `text-gray-900`) with `text-text-main-0` or Tailwind dark mode class (`text-gray-900 dark:text-gray-100`). Verify the title is readable on both light and dark backgrounds. + _Verification commands:_ + - `npm run check` + - `npm run format` + +- [x] T-030-83 – Replace full-image hover overlay with compact "×" badge (FR-030-37). + _Intent:_ In PersonDetail.vue, remove the current hover overlay group that covers the entire photo tile (the dark backdrop + centered "Remove from person" button). Replace with a small (~24px) "×" badge positioned absolutely in the top-right corner of each photo tile. The badge is hidden by default, appears on hover (`group-hover:opacity-100` with `transition-opacity`). Styled with `bg-black/50 text-white rounded-full`. Clicking calls `FaceBatchService.batchUnassign([faceId])` for the face, removes the photo from the grid, decrements counts. + _Verification commands:_ + - `npm run check` + - `npm run format` + +### I35 – Person Detail: Album-Style Layout + Photo Lightbox + +- [x] T-030-84 – Replace square grid with album-style justified layout in PersonDetail.vue (FR-030-36, S-030-54). + _Intent:_ Replace the current `grid grid-cols-*` with the justified/masonry photo layout used in album views. Investigate and reuse the existing layout component (likely wrapping a gallery library or custom CSS). Photos should display with their natural aspect ratios. If the existing album layout is a distinct component, import and use it directly. If it's a composable pattern, replicate it. Ensure responsive behavior matches album views. Add infinite scroll (IntersectionObserver) to replace "Load more" button. + _Verification commands:_ + - `npm run check` + - `npm run format` + +- [x] T-030-85 – Wire photo click to lightbox in PersonDetail.vue (FR-030-39, S-030-55). + _Intent:_ When a photo is clicked (and the user is NOT in select mode), open the full photo viewer/lightbox overlay. Reuse the existing photo overlay component used in album views. The lightbox should: (a) open at the clicked photo; (b) allow left/right navigation within the current person's photo collection — navigation is driven by the `next_photo_id`/`previous_photo_id` fields already computed person-relative by `GET /Person/{id}/photos` (T-030-33), so `PhotoPanel.vue` navigates within the person's collection natively with no additional store manipulation *(Resolved Q-030-74)*; (c) show the usual EXIF sidebar, face overlays, etc. Investigate how album views open the lightbox and replicate the pattern. + _Verification commands:_ + - `npm run check` + - `npm run format` + +### I36 – Person Detail: Multi-Select with Drag & Blue Border + +- [x] T-030-86 – Implement blue-border click/Shift+click selection (FR-030-38, S-030-56). + _Intent:_ Replace the current `Checkbox` overlay batch selection in PersonDetail.vue with blue-border selection matching album style. When a photo is clicked in select mode: toggle a `border-2 border-blue-500` (or equivalent) highlight on the tile (no checkbox). Shift+click: select all items between the last-clicked item and the current one (inclusive). Maintain a `selectedFaceIds` set. Wire to the existing batch action bar. + _Verification commands:_ + - `npm run check` + - `npm run format` + +- [x] T-030-87 – Implement drag-to-select (rubber-band selection) (FR-030-38). + _Intent:_ Implement rectangular drag-select in PersonDetail.vue. On `mousedown` in empty space (not on a photo), start drawing a semi-transparent blue selection rectangle. On `mousemove`, update the rectangle dimensions. On `mouseup`, compute intersection with all photo tile bounding rects; select all intersecting tiles. Use a composable or utility function for the rubber-band logic. Ensure it works alongside existing click/Shift+click selection (additive when holding Ctrl/Cmd, replace otherwise). Reuse an existing drag-select implementation from album views if available. + _Verification commands:_ + - `npm run check` + - `npm run format` + +### I37 – Face Maintenance Admin Page + +- [x] T-030-88 – Python: Include `laplacian_variance` in detection callback (FR-030-40). + _Intent:_ In `app/api/schemas.py`, add `laplacian_variance: float` field to `FaceResult`. In `app/detection/detector.py`, the Laplacian variance is already computed for blur filtering — include it in the returned `DetectedFace` dataclass (add `laplacian_variance: float = 0.0` field). Pass it through to the callback payload in `FaceResult`. Update `tests/test_detection.py`: verify a detected face includes a `laplacian_variance` value. Update `tests/test_api.py`: verify callback payload includes `laplacian_variance`. + _Verification commands:_ + - `cd ai-vision-service/face-recognition && uv run pytest` + - `uv run ruff format --check app/ tests/` + - `uv run ruff check app/ tests/` + - `uv run ty check app/` + +- [x] T-030-89 – PHP: Add `laplacian_variance` column and store from callback (DO-030-09, FR-030-40). + _Intent:_ Create migration: `ALTER TABLE faces ADD COLUMN laplacian_variance FLOAT NULL`. Update Face model: add `laplacian_variance` to `$fillable` and `$casts` (float). Update `ProcessFaceDetectionResults` action to store `laplacian_variance` from callback payload (nullable — existing faces and callbacks without the field get NULL). Write unit test: verify laplacian_variance is stored, verify nullable handling. + _Verification commands:_ + - `php artisan test --filter=FaceDetection` + - `make phpstan` + +- [x] T-030-90 – PHP: Implement `GET /api/v2/Face/maintenance` endpoint (API-030-27, FR-030-40). + _Intent:_ New controller `FaceMaintenanceController` with `index()` action. Admin-only. Query: `Face::with(['photo:id,thumb', 'person:id,name'])->select('id', 'photo_id', 'person_id', 'confidence', 'laplacian_variance', 'crop_token', 'cluster_label', 'is_dismissed')`. Support query params: `sort_by` (enum: `confidence` | `laplacian_variance`, default `confidence`), `sort_dir` (`asc` | `desc`, default `asc`), `page`, `per_page` (default 50). Return paginated response with: `id`, `crop_url`, `photo_id`, `photo_thumb_url`, `person_name`, `cluster_label`, `confidence`, `laplacian_variance`, `is_dismissed`. Register route in `api_v2.php`. + _Verification commands:_ + - `php artisan test --filter=FaceMaintenanceTest` + - `make phpstan` + +- [x] T-030-91 – PHP: Write feature tests for face maintenance endpoint (FR-030-40, S-030-57). + _Intent:_ Tests: list faces sorted by confidence ascending (lowest first), list sorted by laplacian_variance ascending (blurriest first), pagination works, admin-only (non-admin gets 403), default sort is confidence asc, includes person_name and cluster_label. + _Verification commands:_ + - `php artisan test --filter=FaceMaintenanceTest` + - `make phpstan` + +- [x] T-030-92 – Create FaceMaintenance.vue admin page (FR-030-40, UI-030-23, S-030-57). + _Intent:_ New Vue3 view `FaceMaintenance.vue` at `/maintenance/faces` (admin-only route). Uses PrimeVue `DataTable` with sortable columns: face crop (`` 48px), photo thumb (`` 48px), person name (or "Unassigned"), cluster label (or "—"), confidence (float, 2 decimal places), blur score (float, 1 decimal place). Server-side sorting: clicking a column header changes `sort_by` and `sort_dir` query params and re-fetches data. Paginated with PrimeVue `Paginator`. Clicking a face row shows a "Dismiss" action (button or row action) that calls `PATCH /Face/{id}` and removes the row. Descriptive header text: "Review detected face quality. Sort by confidence or blur score to find low-quality detections." Create `FaceMaintenanceService.ts` with `getFaces(params)` typed function. Add route to Vue Router and a link in the admin maintenance area. + _Verification commands:_ + - `npm run check` + - `npm run format` + +### I38 – Denormalized Face & Photo Counters + +- [x] T-030-93 – Write unit tests for Person counter invariants (FR-030-41, S-030-58, S-030-59, S-030-60). + _Intent:_ Using `AbstractTestCase` (SQLite in-memory). Create a Person with a Photo+Face fixture. Assert: (a) creating a non-dismissed face with `person_id` increments `person.face_count` by 1 and `person.photo_count` by 1; (b) creating a second non-dismissed face for the **same** person+photo increments `face_count` by 1 but leaves `photo_count` unchanged; (c) dismissing one face decrements `person.face_count` and leaves `person.photo_count` unchanged (other face still active); (d) dismissing the last non-dismissed face for that person+photo also decrements `person.photo_count`; (e) undismissing a face re-increments the relevant counters; (f) deleting a non-dismissed face with `person_id` decrements the counters; (g) deleting a dismissed face leaves counters unchanged; (h) unassigning a face (`person_id = null`) decrements counters for the old person. + _Verification commands:_ + - `php artisan test --filter=FaceCounterPersonTest` + - `make phpstan` + +- [x] T-030-94 – Write unit tests for Photo.face_count counter invariants (FR-030-42, S-030-59, S-030-60). + _Intent:_ Using `AbstractTestCase`. Assert: (a) creating a non-dismissed face on a photo increments `photo.face_count`; (b) creating a dismissed face (`is_dismissed = true`) on a photo does **not** increment `photo.face_count`; (c) dismissing a previously non-dismissed face decrements `photo.face_count`; (d) undismissing increments `photo.face_count`; (e) deleting a non-dismissed face decrements `photo.face_count`; (f) deleting a dismissed face leaves `photo.face_count` unchanged; (g) changing `person_id` on a non-dismissed face does **not** affect `photo.face_count`. + _Verification commands:_ + - `php artisan test --filter=FaceCounterPhotoTest` + - `make phpstan` + +- [x] T-030-96 – Implement FaceObserver and register it (FR-030-41, FR-030-42). + _Intent:_ Create `app/Observers/FaceObserver.php`. Handle three Eloquent events: + - **`creating`**: if `is_dismissed = false`, queue a `photo.face_count++`. If `person_id` is set and `is_dismissed = false`, queue a `person.face_count++` and recount `person.photo_count` post-save. + - **`updating`**: compare `getOriginal('person_id')` vs new `person_id` and `getOriginal('is_dismissed')` vs new `is_dismissed`. Apply appropriate increments/decrements to the affected Person(s) and Photo. For `person.photo_count`, always recount from DB after the change (avoids edge cases with multiple faces sharing person+photo). + - **`deleted`**: if the deleted face was not dismissed and had a `person_id`, decrement `person.face_count` and recount `person.photo_count`. If not dismissed, decrement `photo.face_count`. All counter updates wrapped in a DB transaction. + Register the observer in `AppServiceProvider` (or a dedicated `ObserverServiceProvider`) via `Face::observe(FaceObserver::class)`. + _Verification commands:_ + - `php artisan test --filter=FaceCounterPersonTest` + - `php artisan test --filter=FaceCounterPhotoTest` + - `make phpstan` + +- [x] T-030-97 – Update PersonResource to read denormalized columns (FR-030-41). + _Intent:_ In `app/Http/Resources/Models/PersonResource.php`, replace: + - `$person->faces()->count()` → `$person->face_count` + - `$person->faces()->distinct('photo_id')->count('photo_id')` → `$person->photo_count` + Add `face_count` and `photo_count` to `$fillable` and the `integer` cast in `Person` model. Update PHPDoc block on `Person` to document the new columns. Verify no runtime COUNT query is issued by asserting `PersonResource::fromModel($person)` does not trigger an additional DB query (use `DB::enableQueryLog()` in the test). + _Verification commands:_ + - `php artisan test --filter=PersonResourceTest` + - `make phpstan` + +### I39 – Per-Resource Face Access Rights + +- [x] T-030-98 – Write feature tests for PhotoPolicy face gates across all four modes (FR-030-43, S-030-61, S-030-62, S-030-65). + _Intent:_ Using `BaseApiWithDataTest`. For each of the four `FacePermissionMode` values, test `canViewFaceOverlays`, `canDismissFace`, `canAssignFaceOnPhoto`, `canTriggerScanOnPhoto` with three actor roles: (a) photo owner, (b) logged-in non-owner, (c) guest. Expected results per matrix row: + - `canViewFaceOverlays`: public → album access sufficient; private → logged; pp/restricted → owner only. + - `canDismissFace`: always owner only, regardless of mode. + - `canAssignFaceOnPhoto`: public/private → logged; pp → owner; restricted → deny even owner. + - `canTriggerScanOnPhoto`: public/private → logged; pp/restricted → owner. + Also verify: AI Vision disabled → all return false. Admin → all return true. + _Verification commands:_ + - `php artisan test --filter=PhotoPolicyFaceTest` + - `make phpstan` + +- [x] T-030-99 – Write feature tests for AlbumPolicy face gates across all four modes (FR-030-44, S-030-63, S-030-64, S-030-65). + _Intent:_ Using `BaseApiWithDataTest`. Test `canViewAlbumPeople`, `canTriggerScanOnAlbum`, `canAssignFaceInAlbum`, `canBatchFaceOps` with roles: album owner, logged non-owner, guest. Expected per matrix: + - `canViewAlbumPeople`: public → album access; private → logged; pp/restricted → album owner only. + - `canTriggerScanOnAlbum`: public/private → logged; pp/restricted → album owner only. + - `canAssignFaceInAlbum`: public/private → logged; pp → album owner; restricted → deny even owner. + - `canBatchFaceOps`: public/private → logged; pp → album owner; restricted → deny even owner; null album → deny. + Also verify: AI Vision disabled → all false. Admin → all true. + _Verification commands:_ + - `php artisan test --filter=AlbumPolicyFaceTest` + - `make phpstan` + +- [x] T-030-100 – Implement PhotoPolicy face gate constants and methods (FR-030-43). + _Intent:_ In `app/Policies/PhotoPolicy.php`, add four new constants: `CAN_VIEW_FACE_OVERLAYS`, `CAN_DISMISS_FACE`, `CAN_ASSIGN_FACE_ON_PHOTO`, `CAN_TRIGGER_SCAN_ON_PHOTO`. Implement the corresponding methods — each accepts `(?User $user, Photo $photo)`. The admin short-circuit and feature-disabled checks are handled by `PhotoPolicy::before()` (note: admins bypass the gate even when AI Vision is disabled — accepted risk per Q-030-77). Each method: (1) resolves `FacePermissionMode` via `ConfigManager`; (2) applies mode + ownership logic per the permission matrix. `canViewFaceOverlays` in `public` mode calls `$this->canAccess($user, $photo->album)` directly on the policy instance (not via `Gate::check()` — no circular dependency per Q-030-78). `isOwner()` helper already exists on `PhotoPolicy`. Register the four new abilities in the Gate (same registration point as existing `PhotoPolicy` constants). + _Verification commands:_ + - `php artisan test --filter=PhotoPolicyFaceTest` + - `make phpstan` + +- [x] T-030-101 – Implement AlbumPolicy face gate constants and methods (FR-030-44). + _Intent:_ In `app/Policies/AlbumPolicy.php`, add four new constants: `CAN_VIEW_ALBUM_PEOPLE`, `CAN_TRIGGER_SCAN_ON_ALBUM`, `CAN_ASSIGN_FACE_IN_ALBUM`, `CAN_BATCH_FACE_OPS`. Implement corresponding methods accepting `(?User $user, AbstractAlbum|null $album)`. The admin short-circuit and feature-disabled checks are handled by `AlbumPolicy::before()` (accepted risk: admin bypasses even when AI Vision disabled — Q-030-77). `canViewAlbumPeople` in `public` mode calls `$this->canAccess($user, $album)` directly on the policy instance (Q-030-78). Ownership check for concrete albums: `$album instanceof BaseAlbum && $this->isOwner($user, $album)`. Smart albums: no owner concept → return false for non-admin. Null album: return false for any mode requiring ownership. Register in Gate. + _Verification commands:_ + - `php artisan test --filter=AlbumPolicyFaceTest` + - `make phpstan` + +- [x] T-030-102 – Extend PhotoRightsResource and AlbumRightsResource with face rights fields (FR-030-45, FR-030-46, DO-030-12, DO-030-13). + _Intent:_ `PhotoRightsResource`: add optional `?Photo $photo = null` second constructor parameter. Add four new public bool properties defaulting to `false`: `can_view_face_overlays`, `can_dismiss_face`, `can_assign_face`, `can_trigger_scan`. When AI Vision feature is active and `$photo` is not null, populate via `Gate::check(PhotoPolicy::CAN_VIEW_FACE_OVERLAYS, $photo)` etc. Update all construction call sites that create `PhotoRightsResource` to pass the `Photo` instance (primarily `PhotoResource`). `AlbumRightsResource`: add four new bool properties: `can_view_album_people`, `can_trigger_scan`, `can_assign_face`, `can_batch_face_ops` — computed from `AlbumPolicy` gate checks using the existing `$abstract_album`. Regenerate TypeScript declarations (`npm run generate-types` or `php artisan typescript:transform`). + _Verification commands:_ + - `php artisan test --filter=RightsResourceTest` + - `make phpstan` + - `npm run check` + +- [x] T-030-103 – Update request authorizers to use per-resource gates (FR-030-47). + _Intent:_ Six targeted changes — remove all `// TODO: Make sure FacePermissionMode applies here` comments by replacing the gate check with the correct scoped gate: + (a) `AssignFaceRequest::authorize()`: `Gate::check(PhotoPolicy::CAN_ASSIGN_FACE_ON_PHOTO, $this->face->photo)`. + (b) `ToggleDismissedRequest::authorize()`: remove inline ownership check + `// TODO`; replace entirely with `Gate::check(PhotoPolicy::CAN_DISMISS_FACE, $this->face->photo)`. + (c) `BatchFaceRequest::authorize()`: when `album_id` is provided → `Gate::check(AlbumPolicy::CAN_BATCH_FACE_OPS, $this->album)` (add optional `album_id` field to the request, resolved to `?Album`); when `album_id` is null → load each face's photo and call `Gate::check(PhotoPolicy::CAN_ASSIGN_FACE_ON_PHOTO, $face->photo)` — deny if any photo fails (Q-030-79). + (d) `ScanPhotosRequest::authorize()`: when album provided → `Gate::check(AlbumPolicy::CAN_TRIGGER_SCAN_ON_ALBUM, $this->album)`; when photo IDs only (no album) → load each photo and call `Gate::check(PhotoPolicy::CAN_TRIGGER_SCAN_ON_PHOTO, $photo)` — deny if any photo fails (Q-030-79). + (e) `GetAlbumPersonsRequest::authorize()`: existing album-access check **AND** `Gate::check(AlbumPolicy::CAN_VIEW_ALBUM_PEOPLE, $this->album)`. + (f) `PhotoResource::buildFaceData()`: replace the inline mode-resolution block with `Gate::check(PhotoPolicy::CAN_VIEW_FACE_OVERLAYS, $photo)`. + _Verification commands:_ + - `php artisan test --filter=FaceAccessRightsTest` + - `php artisan test --filter=PhotoPolicyFaceTest` + - `php artisan test --filter=AlbumPolicyFaceTest` + - `make phpstan` ## Notes / TODOs -**All Q-030-01 through Q-030-48 have been resolved.** All decisions are encoded in spec.md normative sections. No blocking questions remain. - -**Previously blocking items — now resolved:** -- Q-030-13: `lychee_face_id` returned by `/match`; used in selfie claim flow. *(resolved, I8)* -- Q-030-14: Re-scan IoU-matches old faces to preserve `person_id`; configurable via `VISION_FACE_RESCAN_IOU_THRESHOLD`. *(resolved, I10)* -- Q-030-15: Single shared symmetric API key, both directions via `X-API-Key` header. *(resolved, I3/I10)* -- Q-030-16: `is_dismissed` boolean on Face; dismiss via `PATCH /Face/{id}`, hard-delete via `DELETE /Face/dismissed`. *(resolved, I9)* -- Q-030-17: Error callback payload defined (`ErrorCallbackPayload`); sets `face_scan_status = failed`. *(resolved, I10)* -- Q-030-18: Face.person_id type is `string` (consistent with string PKs). *(resolved, no code impact)* -- Q-030-19: `VISION_FACE_*` env prefix; `ai_vision_face_*` / `ai_vision_*` config keys. *(resolved, I3/I4)* -- Q-030-20: Four-mode permission matrix defined. *(resolved, I7)* -- Q-030-21: `DELETE /Person/{id}/claim` unclaim endpoint. *(resolved, I8)* -- Q-030-22: `{id}` = target Person (kept); `source_person_id` in body. *(resolved, I8)* -- Q-030-23: State machine documented; `face_scan_status VARCHAR(16)` + ScanStatus enum cast. *(resolved, I4/I10)* -- Q-030-24: Suggestions pre-computed via NN cosine similarity search (Python side); stored in `face_suggestions` table; embedded in FaceResource. *(resolved, I2/I10/I6)* -- Q-030-25: crop stored at `uploads/faces/{tok[0:2]}/{tok[2:4]}/{tok}.jpg`; served nginx-direct. *(resolved, I10)* -- Q-030-26: ThreadPoolExecutor concurrency model in Python. *(resolved, I2)* -- Q-030-27: Fire-and-forget callback; stuck-pending recovery via CLI-030-03 `--stuck-pending` and Maintenance endpoint. *(resolved, I11)* -- Q-030-28: `callback_url` removed from DetectRequest body; Python reads from env. *(resolved, I2/I10)* -- Q-030-29–48: All resolved; see spec.md and open-questions.md. +**Q-030-01 through Q-030-53 have been resolved.** All decisions are encoded in spec.md normative sections. + +**Q-030-54 through Q-030-64 resolved** (2026-04-04): dismiss UX, maintenance blocks, uncluster, unassign, batch ops, person miniatures, face circles in detail panel, overlay config, album people, merge UI, policy refinement (deferred). + +**Q-030-65 through Q-030-73 resolved** (2026-04-04): +- Q-030-65 (A): P key confirmed free — F maps to fullscreen. Use `onKeyStroke('p', ...)`. +- Q-030-66 (A): Direct photos only (non-recursive) for album people endpoint. +- Q-030-67 (A): Selection mode toggle (not always-on checkboxes). +- Q-030-68 (A): Merge modal triggered from PersonDetail page with person search dropdown. +- Q-030-69 (A): Compact layout — 24px circle + name + face count with type-ahead filter. +- Q-030-70 (B): No touch shortcut — CTRL+click dismiss on **desktop only** (use `isTouchDevice()` guard); touch users dismiss via modal button. +- Q-030-71 (A): Horizontal scrollable row (`overflow-x: auto`) for face circles in detail panel. +- Q-030-72 (B): Policy refinement deferred (same as Q-030-63). +- Q-030-73 (A with grouping): ONE combined "Reset Face Scan Status" maintenance block for both stuck-pending AND failed (not three separate blocks, not two separate stuck/failed blocks). + +**No active questions remain for feature 030.** All 79 questions resolved. Implementation may proceed. + +**I38 added (2026-04-11):** Denormalized face and photo counter columns on `persons` and `photos`; FaceObserver to maintain them; PersonResource updated to read columns directly. Tasks T-030-93 through T-030-97. + +**I39 added (2026-04-11):** Per-resource face access rights in `PhotoPolicy` and `AlbumPolicy`; `PhotoRightsResource` and `AlbumRightsResource` extended with face flags; all `// TODO: FacePermissionMode` gaps in request authorizers closed. Resolves Q-030-63 and Q-030-72. Q-030-77/78/79 raised and resolved same day. Tasks T-030-98 through T-030-103. diff --git a/docs/specs/4-architecture/knowledge-map.md b/docs/specs/4-architecture/knowledge-map.md index fd56752624d..e80c20bc777 100644 --- a/docs/specs/4-architecture/knowledge-map.md +++ b/docs/specs/4-architecture/knowledge-map.md @@ -15,6 +15,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) @@ -54,6 +64,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) @@ -77,16 +88,24 @@ 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 (`resources/js/views/admin/`): AdminDashboard, Settings, Users, UserGroups, Purchasables, ContactMessages, Webhooks, Moderation, Maintenance, Jobs - Diagnostics remains at top-level (`views/Diagnostics.vue`) + - 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 - **useAdminTiles** (`resources/js/composables/useAdminTiles.ts`) - Returns `AdminTile[]` with per-tile visibility driven by capability flags; used by `AdminDashboard.vue`. - **Services** (`resources/js/services/`) - API communication layer using axios - - **admin-stats-service.ts** - `getStats(force)` → `GET /api/v2/Admin/Stats` + - `admin-stats-service.ts` - `getStats(force)` → `GET /api/v2/Admin/Stats` + - `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 @@ -111,6 +130,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 @@ -236,6 +263,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 @@ -261,6 +303,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 @@ -300,4 +343,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 1cac222e089..f8884bb6c83 100644 --- a/docs/specs/4-architecture/open-questions.md +++ b/docs/specs/4-architecture/open-questions.md @@ -6,64 +6,11 @@ Track unresolved high- and medium-impact questions here. Remove each row as soon | Question ID | Feature | Priority | Summary | Status | Opened | Updated | |-------------|---------|----------|---------|--------|--------|---------| + _No active questions._ ## Question Details -### ~~Q-037-08: Partial-Admin Users — Dashboard Behaviour & Stats Visibility~~ ✅ RESOLVED - -**Feature:** 037 – Admin Dashboard & `/admin/` URL Reorganisation -**Priority:** High -**Status:** Resolved -**Opened:** 2026-04-22 - -**Resolution:** **Option A** — the collapsed "Admin" menu entry appears whenever the existing `canSeeAdmin` composite is true. The dashboard tile grid renders per-capability (tiles for tools the operator cannot access are hidden). The stats overview block and `GET /api/v2/Admin/Stats` endpoint are gated on `settings.can_edit`; partial-admins receive 403 on the stats call and do not see the stats section. This keeps the fine-grained capability model intact and prevents leaking global telemetry to limited roles. - -**Spec Impact:** Updated FR-037-02 (stats endpoint auth = `settings.can_edit`), FR-037-04 (tile gating detail), FR-037-05 (menu collapse honours existing `canSeeAdmin`), added NFR-037-05 (capability gating), UI-037-01a variant (no-stats view), and scenarios S-037-16 … S-037-18. - -**Resolved:** 2026-04-22 - ---- - -Lychee treats the "admin" area as a union of five fine-grained capabilities (see [`SettingsRightsResource`](../../../../app/Http/Resources/Rights/SettingsRightsResource.php) + [`UserManagementRightsResource`](../../../../app/Http/Resources/Rights/UserManagementRightsResource.php)): - -1. `settings.can_edit` — full config editor (typical super-admin). -2. `user_management.can_edit` — can manage users. -3. `settings.can_see_diagnostics` — can read diagnostics. -4. `settings.can_see_logs` — can read logs. -5. `settings.can_acess_user_groups` — can manage user groups (e.g., a team lead who is **not** a full admin). - -Today's left-menu `canSeeAdmin` composite is a permissive OR of those five, but each submenu entry has its own `access` flag so a user only sees items their capability permits (e.g., a User-Groups-only operator sees just the "User Groups" entry nested under "Admin"). When we collapse the menu to a single link → `/admin`, that user lands on a dashboard that needs to: -- **Only** expose tiles they are authorised to reach, and -- Decide whether the stats overview (which exposes global photo/album/user counts and storage/job telemetry) is visible to them. - -Today's `GET /api/v2/Admin/Stats` spec line says "Auth: admin (existing `AdminMiddleware`)", which is ambiguous: does "admin" mean full `settings.can_edit`, or the union that `canSeeAdmin` represents? - -**Options (ordered by preference):** - -- **Option A (Recommended) — Dashboard always available to anyone passing `canSeeAdmin`; tiles + stats are each permission-gated.** - - Menu: collapsed "Admin" link appears whenever `canSeeAdmin` is true (unchanged semantics). - - Dashboard tiles: rendered per existing per-tool flags (User Groups only → single User Groups tile; Diagnostics-only → single Diagnostics tile; etc.). - - Stats block: rendered only when `settings.can_edit` is true. Partial-admins (groups-only, logs-only, diagnostics-only) see the dashboard header + tile grid but no stats section, and the `GET /api/v2/Admin/Stats` endpoint requires `settings.can_edit` (returns 403 otherwise). - - Pros: preserves the fine-grained capability model; one menu entry for all admin flavours; avoids leaking global telemetry to limited roles. - - Cons: a partial-admin may land on a dashboard with just one tile — simple but minimal. - -- **Option B — Skip the dashboard for single-capability users and deep-link the menu entry.** - - Menu: if only one of the five capabilities is present, the "Admin" link is rewritten to target that specific page (e.g., `/admin/user-groups`) instead of `/admin`. If two or more are present, use `/admin` (dashboard). - - Dashboard: still tile-filtered + stats-gated on `settings.can_edit` (same gating as Option A for the multi-capability case). - - Pros: one-click access for limited operators; feels "smart". - - Cons: extra branching in the composable; users who gain a second capability suddenly see a different destination; harder to localise the single link label (does it still say "Admin" or "User Groups"?). - -- **Option C — Restrict dashboard to full admins (`settings.can_edit` only).** - - Menu: collapsed "Admin" link only appears for full admins. Partial-admins keep seeing the legacy nested submenu regardless of the toggle. - - Dashboard: single render path; stats always visible. - - Pros: simplest dashboard implementation. - - Cons: two menu styles coexist indefinitely, breaks the "single toggle" UX, contradicts the user's clean-replacement intent. - -**Spec Impact (after resolution):** Updates FR-037-02 (stats auth), FR-037-04 (tile gating), FR-037-05 (menu behaviour for partial-admins), a new NFR on capability gating, UI-037-01 variant for the no-stats view, and new scenarios S-037-16 … S-037-18 covering partial-admin paths. - ---- - ### ~~Q-037-01: Scope of Admin Pages to Move Under `/admin/...`~~ ✅ RESOLVED **Feature:** 037 – Admin Dashboard & `/admin/` URL Reorganisation @@ -169,6 +116,175 @@ Today's `GET /api/v2/Admin/Stats` spec line says "Auth: admin (existing `AdminMi --- +### ~~Q-037-08: Partial-Admin Users — Dashboard Behaviour & Stats Visibility~~ ✅ RESOLVED + +**Feature:** 037 – Admin Dashboard & `/admin/` URL Reorganisation +**Priority:** High +**Status:** Resolved +**Opened:** 2026-04-22 + +**Resolution:** **Option A** — the collapsed "Admin" menu entry appears whenever the existing `canSeeAdmin` composite is true. The dashboard tile grid renders per-capability (tiles for tools the operator cannot access are hidden). The stats overview block and `GET /api/v2/Admin/Stats` endpoint are gated on `settings.can_edit`; partial-admins receive 403 on the stats call and do not see the stats section. This keeps the fine-grained capability model intact and prevents leaking global telemetry to limited roles. + +**Spec Impact:** Updated FR-037-02 (stats endpoint auth = `settings.can_edit`), FR-037-04 (tile gating detail), FR-037-05 (menu collapse honours existing `canSeeAdmin`), added NFR-037-05 (capability gating), UI-037-01a variant (no-stats view), and scenarios S-037-16 … S-037-18. + +**Resolved:** 2026-04-22 + +--- + +Lychee treats the "admin" area as a union of five fine-grained capabilities (see [`SettingsRightsResource`](../../../../app/Http/Resources/Rights/SettingsRightsResource.php) + [`UserManagementRightsResource`](../../../../app/Http/Resources/Rights/UserManagementRightsResource.php)): + +1. `settings.can_edit` — full config editor (typical super-admin). +2. `user_management.can_edit` — can manage users. +3. `settings.can_see_diagnostics` — can read diagnostics. +4. `settings.can_see_logs` — can read logs. +5. `settings.can_acess_user_groups` — can manage user groups (e.g., a team lead who is **not** a full admin). + +Today's left-menu `canSeeAdmin` composite is a permissive OR of those five, but each submenu entry has its own `access` flag so a user only sees items their capability permits (e.g., a User-Groups-only operator sees just the "User Groups" entry nested under "Admin"). When we collapse the menu to a single link → `/admin`, that user lands on a dashboard that needs to: +- **Only** expose tiles they are authorised to reach, and +- Decide whether the stats overview (which exposes global photo/album/user counts and storage/job telemetry) is visible to them. + +Today's `GET /api/v2/Admin/Stats` spec line says "Auth: admin (existing `AdminMiddleware`)", which is ambiguous: does "admin" mean full `settings.can_edit`, or the union that `canSeeAdmin` represents? + +**Options (ordered by preference):** + +- **Option A (Recommended) — Dashboard always available to anyone passing `canSeeAdmin`; tiles + stats are each permission-gated.** + - Menu: collapsed "Admin" link appears whenever `canSeeAdmin` is true (unchanged semantics). + - Dashboard tiles: rendered per existing per-tool flags (User Groups only → single User Groups tile; Diagnostics-only → single Diagnostics tile; etc.). + - Stats block: rendered only when `settings.can_edit` is true. Partial-admins (groups-only, logs-only, diagnostics-only) see the dashboard header + tile grid but no stats section, and the `GET /api/v2/Admin/Stats` endpoint requires `settings.can_edit` (returns 403 otherwise). + - Pros: preserves the fine-grained capability model; one menu entry for all admin flavours; avoids leaking global telemetry to limited roles. + - Cons: a partial-admin may land on a dashboard with just one tile — simple but minimal. + +- **Option B — Skip the dashboard for single-capability users and deep-link the menu entry.** + - Menu: if only one of the five capabilities is present, the "Admin" link is rewritten to target that specific page (e.g., `/admin/user-groups`) instead of `/admin`. If two or more are present, use `/admin` (dashboard). + - Dashboard: still tile-filtered + stats-gated on `settings.can_edit` (same gating as Option A for the multi-capability case). + - Pros: one-click access for limited operators; feels "smart". + - Cons: extra branching in the composable; users who gain a second capability suddenly see a different destination; harder to localise the single link label (does it still say "Admin" or "User Groups"?). + +- **Option C — Restrict dashboard to full admins (`settings.can_edit` only).** + - Menu: collapsed "Admin" link only appears for full admins. Partial-admins keep seeing the legacy nested submenu regardless of the toggle. + - Dashboard: single render path; stats always visible. + - Pros: simplest dashboard implementation. + - Cons: two menu styles coexist indefinitely, breaks the "single toggle" UX, contradicts the user's clean-replacement intent. + +**Spec Impact (after resolution):** Updates FR-037-02 (stats auth), FR-037-04 (tile gating), FR-037-05 (menu behaviour for partial-admins), a new NFR on capability gating, UI-037-01 variant for the no-stats view, and new scenarios S-037-16 … S-037-18 covering partial-admin paths. + +--- + +### ~~Q-035-01: Behaviour of GET /Zip (no chunk param) when chunked mode is ON~~ ✅ RESOLVED + +**Feature:** 035 – Chunked Archive Download +**Priority:** Medium +**Status:** Resolved +**Opened:** 2026-04-12 + +**Context:** When `download_archive_chunked` is enabled, a client that calls `GET /Zip` without a `chunk` parameter may be a legacy client or an incorrect integration. We need a defined contract for this case. + +**Resolution:** **Option A** — Treat missing `chunk` as a regular single-archive download, regardless of the chunked-mode setting. This is backward-compatible: legacy frontends and direct URL downloads work without modification. + +**Spec Impact:** Encoded in FR-035-05 and FR-035-07. + +**Resolved:** 2026-04-12 +### ~~Q-034-01: TagAlbum Rows in Bulk Edit List~~ ✅ RESOLVED + +**Feature:** 034 – Bulk Album Edit +**Priority:** Medium +**Status:** Resolved +**Opened:** 2026-04-12 +**Resolved:** 2026-04-14 + +**Resolution:** **Option A** — Show only regular `Album` records (no TagAlbums). The list query joins only the `albums` table. A note on the page explains TagAlbums are excluded. + +**Spec Impact:** FR-034-01 clarified; plan and tasks updated to confirm only `albums` table is queried. + +--- + +### ~~Q-034-02: Depth Indicator Computation Strategy~~ ✅ RESOLVED + +**Feature:** 034 – Bulk Album Edit +**Priority:** Medium +**Status:** Resolved +**Opened:** 2026-04-12 +**Resolved:** 2026-04-14 + +**Resolution:** **Option B** — Compute depth client-side, **linearly**, by scanning `_lft` values in descending order. The server returns `_lft` in each `BulkAlbumResource` row (already included). The frontend performs a single O(n) pass over the sorted-by-`_lft` result set: maintain a stack of ancestor `_rgt` values; pop the stack whenever the current row's `_lft` exceeds the stack top's `_rgt`; depth = stack length. This avoids an extra server-side `withDepth()` join and keeps computation in the client where the full page of records is already available. + +**Spec Impact:** `BulkAlbumResource` includes `_lft` and `_rgt` (not `depth`). FR-034-14 updated to specify client-side linear depth computation. T-034-03 and T-034-17 updated accordingly. + +--- + +### ~~Q-034-03: Confirmation for Bulk Delete~~ ✅ RESOLVED + +**Feature:** 034 – Bulk Album Edit +**Priority:** High +**Status:** Resolved +**Opened:** 2026-04-12 +**Resolved:** 2026-04-14 + +**Resolution:** **Option B** — Bulk delete shows a minimal confirmation modal: a dialog displaying the count of selected albums and requiring the admin to click a second **"Confirm Delete"** button. No text input required. This is consistent with the spirit of the no-confirmation rule (field edits apply immediately) while protecting against accidental mass-delete. + +**Spec Impact:** FR-034-10 updated to require confirmation modal for delete. UI-034-05 (delete confirmation dialog state) added. T-034-20 updated. + +--- + +### ~~Q-034-04: Scope of "Select All Matching"~~ ✅ RESOLVED + +**Feature:** 034 – Bulk Album Edit +**Priority:** Low +**Status:** Resolved +**Opened:** 2026-04-12 +**Resolved:** 2026-04-14 + +**Resolution:** **Option A** — Return all albums in the gallery regardless of owner. Admin page; admin has authority over all albums. + +**Spec Impact:** FR-034-12 confirmed: no owner filter applied on `GET /BulkAlbumEdit::ids`. + +--- + +### ~~Q-033-01: Monitor Trust Level Behaviour~~ ✅ RESOLVED + +**Feature:** 033 – Upload Trust Level +**Priority:** High +**Status:** Resolved +**Opened:** 2026-04-09 + +**Resolution:** **Option A** — Photos from `monitor`-level users are immediately validated (public), but flagged for periodic admin review. A separate "monitoring queue" shows recently uploaded photos from `monitor` users for the admin to spot-check. No photos are hidden; this is a soft-audit mechanism. + +**Spec Impact:** Updated FR-033-03 to clarify that `monitor` behaves as `trusted` (uploads immediately validated) in this iteration. The monitoring queue is deferred to a follow-up. Updated Non-Goals and Appendix Trust Level Decision Matrix. + +**Resolved:** 2026-04-09 + +--- + +### ~~Q-033-02: Retroactive Trust Level Changes~~ ✅ RESOLVED + +**Feature:** 033 – Upload Trust Level +**Priority:** Medium +**Status:** Resolved +**Opened:** 2026-04-09 + +**Resolution:** **Option A** — No retroactive changes. Only future uploads are affected by the new trust level. Existing photos retain their `is_validated` status. This is the simplest and safest approach. + +**Spec Impact:** Confirmed as a non-goal in spec.md. No additional follow-up tasks needed. + +**Resolved:** 2026-04-09 + +--- + +### ~~Q-033-03: Admin Photo Uploads and Trust Level~~ ✅ RESOLVED + +**Feature:** 033 – Upload Trust Level +**Priority:** Medium +**Status:** Resolved +**Opened:** 2026-04-09 + +**Resolution:** **Option A** — Admin uploads are always immediately validated (`is_validated = true`) regardless of the admin's `upload_trust_level` setting. Admins are inherently trusted — they can approve their own photos anyway. The `SetUploadValidated` pipe checks `may_administrate` first and short-circuits to `true`. + +**Spec Impact:** Updated FR-033-03 to explicitly state that admin uploads bypass trust level checks. Updated Appendix Trust Level Decision Matrix. Updated task T-033-07 to include the admin short-circuit logic. + +**Resolved:** 2026-04-09 + +--- + ### ~~Q-032-01: Advisory URL Field Missing from DTO/Resource~~ ✅ RESOLVED **Feature:** 032 – Security Advisories Check @@ -319,23 +435,6 @@ Today's `GET /api/v2/Admin/Stats` spec line says "Auth: admin (existing `AdminMi --- -### ~~Q-031-08: `size_variants` Encoding in Query-String Payload Format~~ ✅ RESOLVED - -**Feature:** 031 – Configurable Webhooks -**Priority:** High -**Status:** Resolved -**Opened:** 2026-03-25 - -**Context:** `payload_format = query_string` delivers all payload fields as URL query parameters. Simple scalar fields (`photo_id`, `album_id`, `title`) serialize trivially. However, `size_variants` is an array of objects (`[{type, url}]`), which has no single canonical query-string encoding. - -**Resolution:** The URL of each size variant is **base64-encoded** (standard base64, not URL-safe) and delivered as a flat named query parameter using the pattern `size_variant_{type}=`. For example: `size_variant_original=aHR0cHM6Ly9leGFtcGxlLmNvbS91cGxvYWRzL29yaWdpbmFsL3Bob3RvLmpwZw==&size_variant_medium=aHR0cHM6Ly9leGFtcGxlLmNvbS91cGxvYWRzL21lZGl1bS9waG90by5qcGc=`. Base64 encoding avoids any URL-encoding ambiguity for complex S3/CDN URLs. - -**Spec Impact:** Updated FR-031-09, S-031-15, `WebhookPayloadBuilder`, and `WebhookDispatchJob`. Spec DSL updated. - -**Resolved:** 2026-03-25 - ---- - ### ~~Q-031-01: HTTPS Enforcement for Webhook URLs~~ ✅ RESOLVED **Resolution:** **Option A** — Allow both HTTP and HTTPS. Plain HTTP URLs are accepted at the server; the admin UI displays a security warning ("Plain HTTP transmits your secret key in cleartext.") when a non-HTTPS URL is entered. No backend enforcement. @@ -406,2825 +505,1999 @@ Today's `GET /api/v2/Admin/Stats` spec line says "Auth: admin (existing `AdminMi --- -### ~~Q-030-33: `face_suggestions` Schema Wrong — Face-to-Face, Not Face-to-Person~~ ✅ RESOLVED +### ~~Q-031-08: `size_variants` Encoding in Query-String Payload Format~~ ✅ 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. +**Feature:** 031 – Configurable Webhooks +**Priority:** High +**Status:** Resolved +**Opened:** 2026-03-25 -**Spec Impact:** Updated DO-030-05 (domain object table and DSL). Updated `SuggestionResult` Pydantic model comment. `face_suggestions` migration will use `suggested_face_id` (FK→faces) instead of `person_id` (FK→persons). +**Context:** `payload_format = query_string` delivers all payload fields as URL query parameters. Simple scalar fields (`photo_id`, `album_id`, `title`) serialize trivially. However, `size_variants` is an array of objects (`[{type, url}]`), which has no single canonical query-string encoding. -**Resolved:** 2026-03-18 +**Resolution:** The URL of each size variant is **base64-encoded** (standard base64, not URL-safe) and delivered as a flat named query parameter using the pattern `size_variant_{type}=`. For example: `size_variant_original=aHR0cHM6Ly9leGFtcGxlLmNvbS91cGxvYWRzL29yaWdpbmFsL3Bob3RvLmpwZw==&size_variant_medium=aHR0cHM6Ly9leGFtcGxlLmNvbS91cGxvYWRzL21lZGl1bS9waG90by5qcGc=`. Base64 encoding avoids any URL-encoding ambiguity for complex S3/CDN URLs. ---- +**Spec Impact:** Updated FR-031-09, S-031-15, `WebhookPayloadBuilder`, and `WebhookDispatchJob`. Spec DSL updated. -### ~~Q-030-34: Crop Serving Route Undefined~~ ✅ RESOLVED +**Resolved:** 2026-03-25 -**Resolution:** **Option B** — crops served directly by nginx with no application-level auth. The crop token stored in the Face model is a random high-entropy identifier (not a sequential ID), so enumeration of `uploads/faces/` is not feasible. Path structure mirrors Lychee's existing size-variant pattern: `uploads/faces/{token[0:2]}/{token[2:4]}/{token}.jpg` (e.g. `uploads/faces/aa/bb/aabbccddeeff0011223344.jpg`). `FaceResource.crop_url` returns this path directly; no dedicated controller route needed. API-030-16 slot is therefore free for the dismissed-face bulk delete (Q-030-43). - -**Spec Impact:** Update DO-030-02 and DSL `crop_token` constraint to reflect the two-level hash path and nginx-direct serving. +--- -**Resolved:** 2026-03-18 +### ~~Q-030-01: Communication Protocol Between Python Face-Recognition Service and Lychee~~ ✅ RESOLVED ---- +**Feature:** 030 – Facial Recognition +**Priority:** High +**Status:** Resolved +**Opened:** 2026-03-15 -### ~~Q-030-35: IoU Threshold for Re-scan Face Matching Not Defined~~ ✅ RESOLVED +**Resolution:** **Option A** — REST API with webhook callbacks. Lychee sends scan requests to the Python service's REST API; the Python service calls back to Lychee's `/api/v2/FaceDetection/results` endpoint when results are ready. -**Resolution:** **Option B** — add `VISION_FACE_RESCAN_IOU_THRESHOLD` env var (default `0.5`) mapped to `AppSettings.rescan_iou_threshold`. Allows operators to tune matching sensitivity for re-scans without rebuilding the image. +**Rationale:** Simplest architecture, stateless, easy to debug, works with existing HTTP infrastructure. No additional broker dependencies. -**Spec Impact:** Add `rescan_iou_threshold: float = 0.5` to `AppSettings`. Add `VISION_FACE_RESCAN_IOU_THRESHOLD` row to the env var table. Update FR-030-07 resolved note to reference the configurable threshold. +**Spec Impact:** FR-030-07, FR-030-08 confirmed with REST+callback pattern. Inter-service contract in spec appendix is authoritative. -**Resolved:** 2026-03-18 +**Resolved:** 2026-03-15 --- -### ~~Q-030-36: "Claim Person" in Restricted Mode Listed as "All Users" — Contradictory~~ ✅ RESOLVED +### ~~Q-030-02: Face Detection Trigger Mechanism~~ ✅ RESOLVED -**Resolution:** Fixed in permission matrix — `Claim person` now reads `logged users` for all four modes. "All users" (including unauthenticated guests) would make no sense since claiming requires a User record to link. +**Feature:** 030 – Facial Recognition +**Priority:** High +**Status:** Resolved +**Opened:** 2026-03-15 -**Spec Impact:** Spec line 78 updated. No further changes needed. +**Resolution:** **Option A** — Multiple triggers: automatic on upload (via queue job), manual scan (photo/album), and admin bulk-scan command. -**Resolved:** 2026-03-18 +**Rationale:** Covers all use cases. New photos auto-processed; existing libraries backfilled via bulk scan; manual scan for on-demand needs. + +**Spec Impact:** FR-030-08 (manual scan), FR-030-09 (bulk scan) confirmed. Auto-on-upload trigger added to plan as I7 sub-task. + +**Resolved:** 2026-03-15 --- -### ~~Q-030-37: "Unknown" Group in People Page Not Designed~~ ✅ RESOLVED +### ~~Q-030-03: Face Clustering and Assignment Workflow~~ ✅ RESOLVED -**Resolution:** **Option A** — virtual aggregate. `GET /api/v2/People` always appends a synthetic `{id: null, name: "Unknown", face_count: N}` entry where `N = COUNT(faces WHERE person_id IS NULL)`. No DB record required. Clicking the tile navigates to `GET /api/v2/Face?unassigned=true`. The entry is omitted when `N = 0`. +**Feature:** 030 – Facial Recognition +**Priority:** High +**Status:** Resolved +**Opened:** 2026-03-15 -**Spec Impact:** Update API-030-01 notes. Add `GET /api/v2/Face?unassigned=true` filter note. Update UI-030-01 description. +**Resolution:** **Option A** — Auto-cluster with manual confirmation. Python service clusters face embeddings and suggests groupings. Users review, name clusters (creating Person records), and can merge/split. Unknown faces grouped as "Unknown" until assigned. -**Resolved:** 2026-03-18 +**Rationale:** Best balance of automation and user control. Leverages ML capability while keeping human in the loop. + +**Spec Impact:** Clustering result ingestion added to inter-service contract. UI for cluster review added to frontend increments. + +**Resolved:** 2026-03-15 --- -### ~~Q-030-38: `face_scan_status` Column Type and DSL Entry Missing~~ ✅ RESOLVED +### ~~Q-030-04: Face Embedding Storage Location~~ ✅ RESOLVED -**Resolution:** **Option A** — `VARCHAR(16)`, nullable, with a PHP-side `ScanStatus` Enum cast. Portable across MySQL, PostgreSQL, and SQLite. Consistent with Lychee's existing enum-as-string column pattern. +**Feature:** 030 – Facial Recognition +**Priority:** Medium +**Status:** Resolved +**Opened:** 2026-03-15 -**Spec Impact:** Add `face_scan_status` field to the `photos` table addendum in the Spec DSL (`type: string (VARCHAR 16)`, nullable, `cast: ScanStatus`). Document the cast in the state machine section. +**Resolution:** **Option A** — Python service owns embeddings in its own storage. Lychee's `faces` table stores only bounding box, confidence, person_id, photo_id. No raw embedding data in Lychee DB. -**Resolved:** 2026-03-18 +**Rationale:** Keeps Lychee DB lean; vector similarity search belongs in the Python service; clean separation of concerns. + +**Spec Impact:** DO-030-02 (Face) confirmed without embedding column. NFR-030-05 (versioned contract) covers embedding_id reference. + +**Resolved:** 2026-03-15 --- -### ~~Q-030-39: Crop Inline Base64 Payload Size Limit Undefined~~ ✅ RESOLVED +### ~~Q-030-05: "Non-Searchable" Person Semantics~~ ✅ RESOLVED -**Resolution:** **Option A** — cap at N faces per callback, default `N = 10` (configurable via `VISION_FACE_MAX_FACES_PER_PHOTO`). Python keeps the top-N faces by confidence and drops the rest from the callback payload. Operators may raise the limit but must accept the corresponding body size increase. +**Feature:** 030 – Facial Recognition +**Priority:** Medium +**Status:** Resolved +**Opened:** 2026-03-15 -**Spec Impact:** Add `VISION_FACE_MAX_FACES_PER_PHOTO` env var (default `10`) and `max_faces_per_photo: int = 10` to `AppSettings`. Update `FaceResult` / `DetectCallbackPayload` comments to note the cap. +**Resolution:** **Option A** — Non-searchable Person hidden from search results AND People browsing page for all users except the Person's linked User and admins. Faces still detected and stored internally. -**Resolved:** 2026-03-18 +**Rationale:** Privacy-respecting; person can opt out of being discoverable; data remains available for the linked user and administrators. + +**Spec Impact:** FR-030-06 updated with full visibility rules. NFR-030-04 confirmed. S-030-05, S-030-15 test scenarios confirmed. + +**Resolved:** 2026-03-15 --- -### ~~Q-030-40: Bulk Scan Scope — `IS NULL` Only or Include `failed`?~~ ✅ RESOLVED +### ~~Q-030-06: Person-User Tie Purpose and Semantics~~ ✅ RESOLVED -**Resolution:** **Option A** — bulk scan targets `IS NULL` only. A separate **Maintenance page action** ("Re-scan failed photos") handles `face_scan_status = 'failed'` recovery, keeping bulk scan fast and predictable. +**Feature:** 030 – Facial Recognition +**Priority:** Medium +**Status:** Resolved +**Opened:** 2026-03-15 -**Spec Impact:** FR-030-09 stays as IS NULL. Add CLI-030-03 `php artisan lychee:rescan-failed-faces` and a corresponding admin Maintenance page action. +**Resolution:** **Option A (extended)** — Self-identification ("this Person is me") with two additions: +1. **Admin override:** Admins can link/unlink any Person-User pair, overriding user claims. +2. **Selfie-upload claim:** Users can upload a photo of themselves; the Python service matches the selfie against existing face embeddings to find and assign the matching Person record. -**Resolved:** 2026-03-18 +**Rationale:** Self-identification enables privacy self-service and "find photos of me". Admin override provides governance. Selfie-upload leverages the face recognition service for convenient self-assignment without manual browsing. + +**Spec Impact:** FR-030-05 updated with admin override. New FR-030-12 added for selfie-upload claim flow. New API endpoint (API-030-13) and UI state (UI-030-07) added. Plan increment I5 extended with selfie-upload sub-tasks. + +**Resolved:** 2026-03-15 --- -### ~~Q-030-41: Album Scan Depth — Recursive Through Sub-Albums?~~ ✅ RESOLVED +### ~~Q-030-07: How Does the Python Service Access Photo Files?~~ ✅ RESOLVED -**Resolution:** **Option C** — user-selectable scope. Bulk scan UI offers two options: (1) **Library scan** — all unscanned photos across the entire library; (2) **Album scan** — all unscanned photos directly in the selected album (non-recursive). Sub-album scans are triggered explicitly. Matches existing CLI-030-01 / CLI-030-02 pattern. +**Feature:** 030 – Facial Recognition +**Priority:** High +**Status:** Resolved +**Opened:** 2026-03-15 -**Spec Impact:** Update FR-030-09 to describe both scope options. Update API-030-12 notes to clarify non-recursive album scope. +**Resolution:** **Option A** — Shared Docker volume. Both containers mount the same storage volume. The scan request includes a `photo_path` (filesystem path) instead of a URL. Python service reads directly from disk. -**Resolved:** 2026-03-18 +**Rationale:** Fastest access; no auth complexity; works with private photos; no network overhead. Deployment requires both containers to share the photos volume. + +**Spec Impact:** Inter-service contract updated: `photo_url` replaced with `photo_path` in scan request. Deployment docs must specify shared volume configuration. NFR added for S3/remote storage documentation (FUSE mount or alternative). + +**Resolved:** 2026-03-15 --- -### ~~Q-030-42: Face Reassignment Authorization Across Users~~ ✅ RESOLVED +### ~~Q-030-08: Permission Model for People/Face Operations~~ ✅ RESOLVED -**Resolution:** **Option C** — mode-governed. In `public` and `private` modes, any user who passes the "Assign face" permission check (NFR-030-07 matrix) may reassign any face. In `privacy-preserving` and `restricted` modes, only the photo owner or admin may reassign. No `assigned_by_user_id` field needed. +**Feature:** 030 – Facial Recognition +**Priority:** High +**Status:** Resolved +**Opened:** 2026-03-15 -**Spec Impact:** Add a clarifying note to the permission matrix that the "Assign face" row governs cross-user reassignment as well. Add comment to FR-030-04/FR-030-10. +**Resolution:** **Option C** — Configurable via admin setting (`face_recognition_permission_mode`). Two modes: +- **"open"** (default): Any authenticated user can perform all CRUD/assign/merge operations. Only bulk scan restricted to admin. +- **"restricted"**: Photo-owner-centric with admin escalation: + - Create Person: Any authenticated user. + - Update/Delete Person: Linked User, creator, or admin. + - Assign Face: Photo owner or admin. + - Trigger scan: Photo/album owner or admin. + - Bulk scan: Admin only. + - Merge Persons: Admin only. + - Claim Person: Any authenticated user. -**Resolved:** 2026-03-18 +**Rationale:** Accommodates both single-user/family instances (open mode) and multi-user deployments (restricted mode). Default is "open" since most Lychee instances are single-user. + +**Spec Impact:** New config entry `face_recognition_permission_mode` (enum: open, restricted). FR-030-05/08/10/11 updated with conditional authorization. New NFR for permission mode testing (both modes covered by feature tests). + +**Resolved:** 2026-03-15 --- -### ~~Q-030-43: Admin Bulk Hard-Delete of Dismissed Faces Missing from API Catalogue~~ ✅ RESOLVED +### ~~Q-030-09: Face Crop Thumbnail Generation~~ ✅ RESOLVED -**Resolution:** **Option A** — add `DELETE /api/v2/Face/dismissed` as **API-030-16**. Admin-only; hard-deletes all `is_dismissed = true` Face records and their crop files. +**Feature:** 030 – Facial Recognition +**Priority:** High +**Status:** Resolved +**Opened:** 2026-03-15 -**Spec Impact:** Add API-030-16 to API catalogue table and DSL routes. +**Resolution:** **Option B** — Server-side crop stored as a new asset. The Python service generates a cropped face thumbnail (150x150px) during face detection and includes it in the scan result callback. The crop is stored alongside size variants. The Face record includes a `crop_path` field. -**Resolved:** 2026-03-18 +**Rationale:** Crisp thumbnails optimized for People page grid; fast rendering from small pre-generated files; Python service already has the image loaded during detection so the crop is essentially free. + +**Spec Impact:** DO-030-02 (Face) gains `crop_path` field. Inter-service contract updated: scan result includes `crop` (base64 JPEG) per face. New migration adds `crop_path` to faces table. I16 Python service includes crop generation. + +**Resolved:** 2026-03-15 --- -### ~~Q-030-44: Selfie Upload Has No Rate Limiting~~ ✅ RESOLVED +### ~~Q-030-10: Non-Searchable Person Face Overlay Behavior~~ ✅ RESOLVED -**Resolution:** Rate limiting applied at the **Lychee PHP layer** via Laravel's built-in throttle middleware on API-030-13 (`POST /api/v2/Person/claim-by-selfie`). No changes to the Python service needed. +**Feature:** 030 – Facial Recognition +**Priority:** Medium +**Status:** Resolved +**Opened:** 2026-03-15 -**Spec Impact:** Add `throttle:5,1` (5 requests/minute per user) to the API-030-13 route definition note. Document in deployment guide. +**Resolution:** **Option B (extended)** — Hide the overlay entirely for non-searchable persons, but include a summary indicator: "N faces detected but hidden for privacy reasons" displayed below the photo or in the faces info bar. The count does not reveal which specific persons are hidden. -**Resolved:** 2026-03-18 +**Rationale:** Maximum privacy — no hint about which specific face was identified. The summary count maintains transparency about face detection having occurred without leaking person-specific data. + +**Spec Impact:** FR-030-04 updated: photo detail response excludes Face records for non-searchable persons (for unauthorized viewers), but includes `hidden_face_count` (integer). Frontend displays "{N} face(s) hidden for privacy" when count > 0. NFR-030-04 test cases updated. + +**Resolved:** 2026-03-15 --- -### ~~Q-030-45: `photo_ids[]` Batch in API-030-10 Has No Maximum~~ ✅ RESOLVED +### ~~Q-030-11: Selfie Image Lifecycle~~ ✅ RESOLVED -**Resolution:** **Option B** — accept any count, dispatch in configurable chunks. The job dispatcher slices the photo ID list into chunks of size `ai_vision_face_scan_batch_size` (Lychee `configs` table, default `200`). No hard caller limit; queue load controlled by chunk size + queue concurrency. +**Feature:** 030 – Facial Recognition +**Priority:** Medium +**Status:** Resolved +**Opened:** 2026-03-15 -**Spec Impact:** Add `ai_vision_face_scan_batch_size` to the Lychee `configs` table (integer, default `200`). Update API-030-10 notes to describe chunked dispatch. +**Resolution:** **Option A** — Discard immediately after match. The selfie is held in memory/temp storage only during the matching request. Once the Python service returns its result, the image is deleted. No permanent record. -**Resolved:** 2026-03-18 +**Rationale:** Privacy-friendly; no unnecessary data retention; simpler storage. Users can re-upload if they want to retry. ---- +**Spec Impact:** FR-030-12 confirmed: selfie is transient. No storage schema changes needed for selfie retention. Implementation uses temp file or in-memory buffer. -### ~~Q-030-26: Python Concurrency Model — CPU-Bound Face Detection Blocks Event Loop~~ ✅ RESOLVED +**Resolved:** 2026-03-15 -**Resolution:** **Option A** — inference runs in a `ThreadPoolExecutor` via `asyncio.run_in_executor`, keeping the FastAPI event loop responsive while CPU-bound detection executes on a background thread. Pool size is configurable via `VISION_FACE_THREAD_POOL_SIZE` env var (default `1`). The service must emit structured log entries at three checkpoints: job received (`INFO`), detection started (`INFO`), and detection finished (`INFO` with face count and elapsed milliseconds). Callback failures are logged at `ERROR` level. +--- -**Spec Impact:** Add `thread_pool_size: int = 1` to `AppSettings`. Add `VISION_FACE_THREAD_POOL_SIZE` to env var table. Add "Concurrency Model" subsection to Python Service Technical Specification documenting the `run_in_executor` pattern and the structured logging checkpoints table. +### ~~Q-030-12: Selfie Match Inter-Service Contract~~ ✅ RESOLVED -**Resolved:** 2026-03-17 +**Feature:** 030 – Facial Recognition +**Priority:** Medium +**Status:** Resolved +**Opened:** 2026-03-15 ---- +**Resolution:** **Option A** — Dedicated match endpoint on Python service. `POST /match` accepts an image file (multipart) and returns top-N matching embedding references with confidence scores. -### ~~Q-030-27: Callback Retry Policy — Stuck-Pending Risk When Python→Lychee POST Fails~~ ✅ RESOLVED +Contract: +```json +// Request: POST /match (multipart form with "image" file field) +// Response: +{ + "matches": [ + { "embedding_id": "emb_001", "person_suggestion": "cluster_42", "confidence": 0.963 }, + { "embedding_id": "emb_002", "person_suggestion": "cluster_17", "confidence": 0.412 } + ] +} +``` -**Resolution:** **Option B** — fire-and-forget. Python makes one callback attempt. If the request fails (network error, 5xx), the failure is logged at `ERROR` level and discarded. The photo's `face_scan_status` remains `pending` indefinitely; operators must reset stuck records manually. No retry logic in the Python service; no outbox table. +Lychee maps `embedding_id` back to Face records (which have person_id) to identify the matching Person. The `person_suggestion` field is advisory (from clustering) and may be null. -**Spec Impact:** Document fire-and-forget policy in the "Concurrency Model" subsection. Add `ERROR` log entry for callback failure in the structured logging table. Note in state machine documentation that `pending` can become permanently stuck on callback failure; add an operator note. +**Rationale:** Clean separation; Python service owns matching logic; single round trip; Lychee just consumes results. -**Resolved:** 2026-03-17 +**Spec Impact:** Inter-service contract appendix updated with `/match` endpoint. I17 Python service implements the endpoint. I5 Lychee SelfieClaimController consumes it. + +**Resolved:** 2026-03-15 --- -### ~~Q-030-28: Security — `photo_path` Path Traversal and `callback_url` SSRF~~ ✅ RESOLVED +### ~~Q-030-13: Embedding ID → Person Mapping Gap in Selfie Match Flow~~ ✅ RESOLVED -**Resolution:** **Option A, extended** — validate `photo_path` resolves within `VISION_FACE_PHOTOS_PATH` (resolve symlinks, reject traversals with 422). `callback_url` is **removed from the `DetectRequest` body entirely** — Python reads the callback endpoint from `VISION_FACE_LYCHEE_API_URL` env var. Since the callback URL is operator-supplied via env and not present in the request payload, the SSRF vector is eliminated structurally rather than via allowlist validation. +**Resolution:** **Option A** — Store `lychee_face_id` in Python's embedding DB. When Lychee ingests a scan callback it creates Face records and returns the `embedding_id → lychee_face_id` mapping in the HTTP 200 response body. Python persists each mapping. The `/match` endpoint returns `lychee_face_id` (not `embedding_id`); Lychee resolves `lychee_face_id → Face → person_id`. -**Spec Impact:** Remove `callback_url` field from `DetectRequest` Pydantic model. Remove `callback_url` from Scan Request JSON example. Add path-traversal validation note to `DetectRequest.photo_path` field comment. Update inter-service contract description and the scan request JSON example. +**Spec Impact:** Update `DetectCallbackPayload` response body to include `{"faces": [{"embedding_id": "...", "lychee_face_id": "..."}]}`. Update `MatchResult` Pydantic model: replace `embedding_id` with `lychee_face_id`. Update FR-030-12, API-030-13, I2, I8, inter-service contract. **Resolved:** 2026-03-17 --- -### ~~Q-030-29: Suggestion Items — `embedding_id` vs. `lychee_face_id` in Callback Suggestions~~ ✅ RESOLVED +### ~~Q-030-14: Re-scan Destroys Manual Face Assignments~~ ✅ RESOLVED -**Resolution:** **Option A** — Python sends `lychee_face_id` in suggestion items (it already stores them from prior callback 200 responses). Rename `SuggestionResult.embedding_id` → `lychee_face_id`. Lychee stores `(face_id, suggested_face_id, confidence)` in `face_suggestions` using `lychee_face_id` directly — no cross-callback resolution needed. +**Resolution:** **Options A + C** — On re-scan, new faces are matched to existing faces by bounding box IoU (≥ threshold); matched old face's `person_id` is carried over to the new face record; truly gone faces are deleted. Additionally, if a photo has any faces with a `person_id` assigned, re-scan is blocked unless the request includes `force: true`. Without `force: true` a 409 Conflict is returned listing the number of assigned faces at risk. -**Spec Impact:** Rename `SuggestionResult.embedding_id` → `lychee_face_id` in Pydantic schemas. Update suggestion examples in the callback JSON. Update `FaceResult.suggestions` comment. Update `face_suggestions` table schema note (`DO-030-05`). +**Spec Impact:** Update FR-030-07 (re-scan idempotency now caveated with IoU preservation + force flag). Update S-030-14. Update `ProcessFaceDetectionResults` action description. Update API-030-10 to document optional `force` parameter. **Resolved:** 2026-03-17 --- -### ~~Q-030-30: Clustering Trigger — When Does DBSCAN Run and How Does It Feed Suggestions?~~ ✅ RESOLVED +### ~~Q-030-15: Two API Keys but Lychee Config Only Defines One~~ ✅ RESOLVED -**Resolution:** **Option A** — per-scan suggestions use **nearest-neighbour cosine similarity search** against stored embeddings via `sqlite-vec`/`pgvector` (fast, inline with the detection job). DBSCAN is a **separate offline batch operation** grouping unassigned faces for the People browse UI; triggered manually via `POST /cluster` and never invoked per scan request. +**Resolution:** **Option A** — Single shared symmetric key for both directions. Header: `X-API-Key: `. The key is defined in `.env` as `AI_VISION_API_KEY` (after Q-030-19 renaming). **Critical separation of concerns:** the AI vision callback endpoints (`POST /api/v2/FaceDetection/results`) are authenticated **exclusively** via the API key header — no user session, no admin session. Even authenticated admins cannot reach these endpoints through the normal auth middleware. Lychee-to-Python requests likewise send `X-API-Key` with the same shared key. -**Spec Impact:** Update `clustering/clusterer.py` description in project structure (offline batch, not per-scan). Update DBSCAN tech stack table entry. Add `POST /cluster` to routes list. Clarify `SuggestionResult` data source as NN cosine similarity search. +**Spec Impact:** Update config migration to single key `ai_vision_api_key`. Add note that FaceDetection/results middleware skips session auth. Update NFR-030-07, I3, I4, I10, inter-service contract, AppSettings. **Resolved:** 2026-03-17 --- -### ~~Q-030-31: `VISION_CONFIDENCE_THRESHOLD` — Detection Filter vs. Matching Threshold~~ ✅ RESOLVED +### ~~Q-030-16: Missing Face Deletion Endpoint for False Positives~~ ✅ RESOLVED -**Resolution:** **Option B** — two separate thresholds. Rename `VISION_CONFIDENCE_THRESHOLD` → `VISION_FACE_DETECTION_THRESHOLD` (bounding box filter: faces below threshold excluded from callback payloads) and add `VISION_FACE_MATCH_THRESHOLD` (similarity search cutoff: suggestions and selfie match results below threshold excluded). Independent configuration allows operators to tune detection sensitivity and identity matching independently. +**Resolution:** **Option C (dismiss-first)** — Users dismiss false positives via `PATCH /api/v2/Face/{id}` (toggles `is_dismissed`). Dismissed faces are hidden from face overlays and assignment UI. Admin can hard-delete all dismissed faces in bulk from the Maintenance page (a new maintenance action); this permanently removes the Face records + crop files. -**Spec Impact:** Remove `VISION_CONFIDENCE_THRESHOLD` from env var table. Add `VISION_FACE_DETECTION_THRESHOLD` (default `0.5`) and `VISION_FACE_MATCH_THRESHOLD` (default `0.5`). Rename `AppSettings.confidence_threshold` → `detection_threshold` + add `match_threshold`. Update `app/detection/detector.py` and `app/matching/matcher.py` references. +**Spec Impact:** Add `is_dismissed` boolean (default `false`) to DO-030-02 and Face migration. Add API-030-14 (`PATCH /api/v2/Face/{id}` dismiss toggle). Add admin maintenance action for bulk hard-delete of dismissed faces. Update UI-030-03 (face overlay hides dismissed faces). **Resolved:** 2026-03-17 --- -### ~~Q-030-32: InsightFace Model Acquisition — Baked Into Docker Image vs. Runtime Download~~ ✅ RESOLVED +### ~~Q-030-17: Error Callback Shape Undefined~~ ✅ RESOLVED -**Resolution:** **Option A** — bake `buffalo_l` model weights into the Docker image at build time via a `RUN` step in the builder stage. The multi-stage Dockerfile copies the downloaded model folder from builder to runtime. Image is significantly larger (~1GB+) but starts instantly and works in airgapped environments. Model updates require an image rebuild (acceptable given model stability). +**Resolution:** **Option A** — Python posts an error callback payload to the same `callback_url`: `{"photo_id": "abc", "status": "error", "error_code": "corrupt_file", "message": "..."}`. Lychee sets `face_scan_status = failed`. Python defines `ErrorCallbackPayload` Pydantic model. No timeout mechanism; status transitions only occur via explicit callbacks. -**Spec Impact:** Update Dockerfile spec: add `RUN uv run python -c "..."` model download step in builder stage; add `COPY --from=builder /root/.insightface /root/.insightface` in runtime stage. Note model size and rebuild requirement in Docker configuration section. +**Spec Impact:** Add `ErrorCallbackPayload` Pydantic model. Update FR-030-07 (result endpoint handles both success and error payloads). Update `face_scan_status` state machine in spec. Update I2, I10. **Resolved:** 2026-03-17 --- -### ~~Q-030-46: `FaceResource` (DO-030-04) Field Specification Missing~~ ✅ RESOLVED +### ~~Q-030-18: Spec DSL Type Mismatch — Face.person_id~~ ✅ RESOLVED -**Resolution:** **Option A** — suggestions are embedded in FaceResource. Fields exposed: `id` (Face ID), `photo_id`, `person_id` (nullable), `x`/`y`/`width`/`height` (float 0.0–1.0 bounding box), `confidence`, `is_dismissed`, `crop_url` (computed nginx-direct path from crop_token). Embedded `suggestions[]` array — each item: `suggested_face_id`, `crop_url` (suggested face's own crop or null), `person_name` (nullable, LEFT JOIN on persons), `confidence`. Suggestions are always included (pre-computed, stored in `face_suggestions`) — no N+1 risk. +**Resolution:** **Option A** — Fix `person_id` field in DO-030-02 DSL from `type: integer` to `type: string`. -**Spec Impact:** Expanded DO-030-04 in narrative domain objects table. +**Spec Impact:** Update DO-030-02 Spec DSL `person_id` type field. Low impact. -**Resolved:** 2026-03-18 +**Resolved:** 2026-03-17 --- -### ~~Q-030-47: Missing Telemetry Events for Face Dismiss/Undismiss and Bulk Delete~~ ✅ RESOLVED +### ~~Q-030-19: Naming Inconsistency — FACE_* Prefix vs ai-vision-service~~ ✅ RESOLVED -**Resolution:** **Option A** — three new events added: `TE-030-10` → `face.dismissed` (`face_id`, `photo_id`), `TE-030-11` → `face.undismissed` (`face_id`, `photo_id`), `TE-030-12` → `face.bulk_deleted` (`deleted_count`). +**Resolution:** **Option B** — Rename for future-proofing. Python env vars use `VISION_*` prefix; Lychee config keys use `ai_vision_*` prefix. All documentation, docker-compose, and AppSettings updated accordingly. -**Spec Impact:** Added TE-030-10, TE-030-11, TE-030-12 to telemetry events table and DSL. +**Spec Impact:** Rename `FACE_*` → `VISION_*` throughout Python service config and docker-compose. Rename `face_recognition_*` → `ai_vision_*` for all Lychee config keys. Update AppSettings `env_prefix`. Update all env variable tables in spec and docs. -**Resolved:** 2026-03-18 +**Resolved:** 2026-03-17 --- -### ~~Q-030-48: No CLI/UI Path for Photos Stuck in `pending` Indefinitely~~ ✅ RESOLVED +### ~~Q-030-20: Permission Mode Scope per Operation Is Ambiguous~~ ✅ RESOLVED -**Resolution:** **Options B + C** combined — (B) `CLI-030-03` extended with optional `--stuck-pending [--older-than=N]` flag to reset pending records older than N minutes (default 60) back to `null`. (C) Admin Maintenance page action via **`GET /api/v2/Maintenance::resetStuckFaces`** (check: count of stuck records) + **`POST /api/v2/Maintenance::resetStuckFaces`** (do: reset them). Follows the existing check/do Maintenance route pattern. Endpoint added as API-030-17 / API-030-17b. +**Resolution:** **Option C** — Four-mode enum (`public`, `private`, `privacy-preserving`, `restricted`) with a per-operation matrix: -**Spec Impact:** Extended CLI-030-03 description. Added API-030-17 and API-030-17b to API catalogue and DSL routes. +| Operation | public | private | privacy-preserving | restricted | +|--------------------|--------------|--------------|---------------------------|---------------------------| +| View People page | guest | logged users | photo/album owner + admin | admin only | +| View face overlays | album access | logged users | photo/album owner + admin | photo/album owner + admin | +| Create/edit Person | logged users | logged users | photo/album owner + admin | admin only | +| Assign face | logged users | logged users | photo/album owner + admin | admin only | +| Trigger scan | logged users | logged users | photo/album owner + admin | photo/album owner + admin | +| Claim person | logged users | logged users | logged users | all users | +| Merge persons | logged users | logged users | photo/album owner + admin | admin only | -**Resolved:** 2026-03-18 +**Spec Impact:** Update `ai_vision_permission_mode` to a 4-value enum. Update NFR-030-07 with full matrix. Update FR-030-08 authorization description. Update all controller authorization references (I7, I8, I9, I10). ---- +**Resolved:** 2026-03-17 -### Q-030-14: Re-scan Destroys Manual Face Assignments +--- -**Question:** FR-030-07 says re-scanning a photo replaces old Face records (idempotent). But if a user manually assigned Face → Person, re-scan deletes those records and creates new unassigned ones. All manual assignment work is lost silently. Is this acceptable? +### ~~Q-030-21: Missing Person Unclaim Endpoint~~ ✅ RESOLVED -**Impact:** Affects I10 (scan result ingestion). Could cause significant user frustration with no recourse. +**Resolution:** **Option A** — Add `DELETE /api/v2/Person/{id}/claim` as API-030-15. Removes `person.user_id` (sets to null). Linked user or admin only. -**Options:** -- **(A)** Preserve assignments: match new faces to old faces by bounding box IoU overlap (≥ threshold), carry over `person_id` from old → new face. Delete truly gone faces. -- **(B)** Soft-delete old faces — mark as `superseded` but keep records. Let user review changes. -- **(C)** Block re-scan on photos with any assigned faces unless user explicitly confirms (force flag). -- **(D)** Accept data loss — document it as expected behavior. User must re-assign after re-scan. +**Spec Impact:** Add API-030-15 to API catalogue and Spec DSL routes. Update FR-030-05 to reference unclaim. Update I8. -**Affects:** FR-030-07, S-030-14, I10, ProcessFaceDetectionResults action. +**Resolved:** 2026-03-17 --- -### Q-030-15: Two API Keys but Lychee Config Only Defines One - -**Question:** The inter-service contract requires two authentication directions: -1. **Lychee → Python** (scan requests): Python validates incoming requests via `FACE_API_KEY`. -2. **Python → Lychee** (callbacks): Lychee validates incoming results via... what? - -The Lychee config migration only defines `face_recognition_api_key` (singular). Which direction does it authenticate? What HTTP header format is used (`Authorization: Bearer `, `X-API-Key: `, etc.)? +### ~~Q-030-22: Merge Direction Ambiguity on API-030-06~~ ✅ RESOLVED -**Impact:** Blocks I3 (Python API key auth), I4 (Lychee config migration), I10 (result ingestion auth). +**Resolution:** **Option A** — `{id}` = target (kept). Body parameter renamed to `source_person_id`. Follows REST convention: the URL resource is the one preserved. -**Options:** -- **(A)** Single shared symmetric key — same key used in both directions. Simpler but less secure (compromise exposes both directions). Header: `X-API-Key: `. -- **(B)** Two separate keys — Lychee config gets `face_recognition_api_key` (Lychee sends to Python) + `face_recognition_callback_key` (Python sends to Lychee). Header: `X-API-Key: `. +**Spec Impact:** Update API-030-06 body param from `target_person_id` to `source_person_id`. Update FR-030-11. Update I8 and I14 (frontend merge action). -**Affects:** FR-030-07, FR-030-08, I3, I4, I10, inter-service contract, Pydantic `AppSettings`. +**Resolved:** 2026-03-17 --- -### Q-030-16: Missing Face Deletion Endpoint for False Positives - -**Question:** There is no API to delete a Face record. If the detector produces a false positive (e.g., a face detected in tree bark, a painting, etc.), the user has no way to remove it. This is a basic UX requirement for any face detection system. +### ~~Q-030-23: face_scan_status State Machine Transitions Undefined~~ ✅ RESOLVED -**Impact:** Affects I9 (FaceController), frontend face overlay UX. +**Resolution:** **Option A** — Explicit state machine: +1. `null → pending`: set on **dispatch** (when the scan job is enqueued, before the HTTP request to Python is sent). +2. `pending → completed`: set when Lychee receives a **success** callback from the Python service. +3. `pending → failed`: set when Lychee receives an **error** callback from Python. No timeout mechanism (async model; Lychee never waits for a response). +4. Retry/re-scan: `failed → pending` (retry) and `completed → pending` (re-scan) are both **allowed**. +5. Duplicate pending: **reset** to `pending` (do not ignore); the earlier `pending` could be a silent timeout. -**Options:** -- **(A)** Add `DELETE /api/v2/Face/{id}` — hard-delete Face record + crop file. Authorization: photo owner or admin. Add to API catalogue as API-030-14. -- **(B)** Add `is_dismissed` boolean to Face model — dismissed faces hidden from UI but record retained for re-scan deduplication. Toggle via `PATCH /api/v2/Face/{id}`. -- **(C)** Both — dismiss by default, hard-delete as admin action. +**Spec Impact:** Document state machine in FR-030-07/NFR section. Update I10, I11, DispatchFaceScanJob, ProcessFaceDetectionResults. -**Affects:** FR-030-02, I9, I15 (face overlay UI needs a "dismiss" or "delete" action), migrations (if option B). +**Resolved:** 2026-03-17 --- -### Q-030-17: Error Callback Shape Undefined - -**Question:** If the Python service fails to process a photo (corrupt file, unsupported format, OOM, model error), what does it POST back to Lychee? The inter-service contract only defines the success payload (`DetectCallbackPayload`). Without an error callback, `face_scan_status` will remain `pending` indefinitely for failed photos. +### ~~Q-030-24: Similar Faces in Assignment Modal — Data Source Unspecified~~ ✅ RESOLVED -**Impact:** Blocks I2 (Python callback flow), I10 (result ingestion — needs to handle errors), I11 (bulk scan progress tracking). +**Resolution:** **Option A, stored in a dedicated suggestions table** — Python includes a `suggestions` array per face in the `DetectCallbackPayload`. Lychee persists these in a `face_suggestions` table (`face_id`, `person_id`, `confidence`). The assignment modal reads from this table. New domain object `FaceSuggestion` added. -**Options:** -- **(A)** Define error callback payload: `{"photo_id": "abc", "status": "error", "error_code": "corrupt_file", "message": "..."}`. Lychee sets `face_scan_status = failed`. Add `ErrorCallbackPayload` Pydantic model. -- **(B)** Python doesn't callback on failure; Lychee has a configurable timeout (e.g., `face_recognition_scan_timeout` = 5 min) that marks stale `pending` → `failed` via scheduled job. -- **(C)** Both — Python best-effort error callback + Lychee timeout as safety net. +**Spec Impact:** Add `FaceSuggestion` domain object (DO-030-05). Add `face_suggestions` table to migrations. Update `FaceResult` Pydantic model to include `suggestions: list[SuggestionResult]`. Update UI-030-04. Update I2, I10, I16. -**Affects:** Inter-service contract, `face_scan_status` transitions, I2, I10, I11, Pydantic schemas. +**Resolved:** 2026-03-17 --- -### Q-030-18: Spec DSL Type Mismatch — Face.person_id - -**Question:** In the Spec DSL (line ~338), DO-030-02 declares `person_id` with `type: integer` but the actual FK target (Person PK) is `string`. The constraints say `"FK→persons (string)"` contradicting the type field. This is a copy-paste error that could generate wrong migrations if the DSL is used as a generation source. +### ~~Q-030-25: Crop Storage Path Pattern Undefined~~ ✅ RESOLVED -**Impact:** Low runtime risk (DSL is documentary), but misleading if used for code generation. +**Resolution:** **Option B** — Crops stored at `uploads/faces/{face_id}.jpg` in a dedicated `faces/` subdirectory under the main uploads directory. Served via a separate media controller route (not the standard photo size-variant pipeline). -**Options:** -- **(A)** Fix: change `type: integer` → `type: string` on `person_id` in DO-030-02. +**Spec Impact:** Update DO-030-02 `crop_path` description. Update `crop_url` accessor. Add a new route for serving face crops. Update I6, I10, I16. -**Affects:** Spec DSL only. +**Resolved:** 2026-03-17 --- -### Q-030-19: Naming Inconsistency — FACE_* Prefix vs ai-vision-service - -**Question:** The service directory is `ai-vision-service` (chosen for future extensibility: tagging, scene detection, etc.), but all environment variables use `FACE_*` prefix and all Lychee config keys use `face_recognition_*`. Should these be renamed for consistency and extensibility? +### ~~Q-030-26: Python Concurrency Model — CPU-Bound Face Detection Blocks Event Loop~~ ✅ RESOLVED -**Impact:** Naming decision that becomes harder to change after v1 ships. +**Resolution:** **Option A** — inference runs in a `ThreadPoolExecutor` via `asyncio.run_in_executor`, keeping the FastAPI event loop responsive while CPU-bound detection executes on a background thread. Pool size is configurable via `VISION_FACE_THREAD_POOL_SIZE` env var (default `1`). The service must emit structured log entries at three checkpoints: job received (`INFO`), detection started (`INFO`), and detection finished (`INFO` with face count and elapsed milliseconds). Callback failures are logged at `ERROR` level. -**Options:** -- **(A)** Keep `FACE_*` / `face_recognition_*` — scope is facial recognition for now; rename later if/when new capabilities added. -- **(B)** Rename to `VISION_*` / `ai_vision_*` — future-proof now. More churn in spec but cleaner long-term. -- **(C)** Hybrid: service-level config uses `VISION_*` (generic), Lychee-side config stays `face_recognition_*` (feature-specific). +**Spec Impact:** Add `thread_pool_size: int = 1` to `AppSettings`. Add `VISION_FACE_THREAD_POOL_SIZE` to env var table. Add "Concurrency Model" subsection to Python Service Technical Specification documenting the `run_in_executor` pattern and the structured logging checkpoints table. -**Affects:** Pydantic `AppSettings` (env_prefix), Lychee config migration, docker-compose, all documentation. +**Resolved:** 2026-03-17 --- -### Q-030-20: Permission Mode Scope per Operation Is Ambiguous - -**Question:** The `face_recognition_permission_mode` setting (`open` / `restricted`) is defined but the spec doesn't enumerate which operations each mode governs. Specifically: - -- **open**: Any authenticated user can do what exactly? CRUD persons? Assign faces? Trigger scans? View all persons? -- **restricted**: Only photo/album owner or admin — but for which operations? Can a non-owner VIEW persons in restricted mode? Can they see face overlays on photos they have album access to? +### ~~Q-030-27: Callback Retry Policy — Stuck-Pending Risk When Python→Lychee POST Fails~~ ✅ RESOLVED -**Impact:** Affects I7, I8, I9, I10 — every controller needs to know what to gate. +**Resolution:** **Option B** — fire-and-forget. Python makes one callback attempt. If the request fails (network error, 5xx), the failure is logged at `ERROR` level and discarded. The photo's `face_scan_status` remains `pending` indefinitely; operators must reset stuck records manually. No retry logic in the Python service; no outbox table. -**Options:** -- **(A)** Define a per-operation matrix: - | Operation | open | restricted | - |-----------|------|-----------| - | View People page | all users | all users | - | View face overlays | album access | album access | - | Create/edit Person | all users | admin only | - | Assign face | all users | photo owner + admin | - | Trigger scan | all users | photo/album owner + admin | - | Claim person | all users | all users | - | Merge persons | all users | admin only | -- **(B)** Simpler: `open` = all authenticated users for everything; `restricted` = admin-only for all write operations, read follows album access. +**Spec Impact:** Document fire-and-forget policy in the "Concurrency Model" subsection. Add `ERROR` log entry for callback failure in the structured logging table. Note in state machine documentation that `pending` can become permanently stuck on callback failure; add an operator note. -**Affects:** NFR-030-07, I7, I8, I9, I10, form request authorization. +**Resolved:** 2026-03-17 --- -### Q-030-21: Missing Person Unclaim Endpoint - -**Question:** FR-030-05 describes claim behavior and test intents reference "unclaim", but there's no API route for unclaiming a Person (removing the User link). How does a user or admin remove a Person-User link? +### ~~Q-030-28: Security — `photo_path` Path Traversal and `callback_url` SSRF~~ ✅ RESOLVED -**Impact:** Affects I8 (claim controller). +**Resolution:** **Option A, extended** — validate `photo_path` resolves within `VISION_FACE_PHOTOS_PATH` (resolve symlinks, reject traversals with 422). `callback_url` is **removed from the `DetectRequest` body entirely** — Python reads the callback endpoint from `VISION_FACE_LYCHEE_API_URL` env var. Since the callback URL is operator-supplied via env and not present in the request payload, the SSRF vector is eliminated structurally rather than via allowlist validation. -**Options:** -- **(A)** Add `DELETE /api/v2/Person/{id}/claim` — removes `person.user_id`. Linked user or admin only. Add as API-030-15. -- **(B)** Use existing `PATCH /api/v2/Person/{id}` with `user_id: null` — no new route needed, but less semantic. +**Spec Impact:** Remove `callback_url` field from `DetectRequest` Pydantic model. Remove `callback_url` from Scan Request JSON example. Add path-traversal validation note to `DetectRequest.photo_path` field comment. Update inter-service contract description and the scan request JSON example. -**Affects:** FR-030-05, API catalogue, I8. +**Resolved:** 2026-03-17 --- -### Q-030-22: Merge Direction Ambiguity on API-030-06 +### ~~Q-030-29: Suggestion Items — `embedding_id` vs. `lychee_face_id` in Callback Suggestions~~ ✅ RESOLVED -**Question:** `POST /api/v2/Person/{id}/merge` with body `{target_person_id}`. Which person is destroyed? +**Resolution:** **Option A** — Python sends `lychee_face_id` in suggestion items (it already stores them from prior callback 200 responses). Rename `SuggestionResult.embedding_id` → `lychee_face_id`. Lychee stores `(face_id, suggested_face_id, confidence)` in `face_suggestions` using `lychee_face_id` directly — no cross-callback resolution needed. -- Reading 1: `{id}` is the **source** (destroyed), faces moved to `target_person_id` (kept). -- Reading 2: `{id}` is the **target** (kept), body's `source_person_id` provides the one destroyed. +**Spec Impact:** Rename `SuggestionResult.embedding_id` → `lychee_face_id` in Pydantic schemas. Update suggestion examples in the callback JSON. Update `FaceResult.suggestions` comment. Update `face_suggestions` table schema note (`DO-030-05`). -REST convention: the URL resource (`{id}`) is typically the one acted upon and preserved. The current spec text says "merge source into target" with `{id}` and body `target_person_id`, which implies `{id}` = source (destroyed). This contradicts the convention. +**Resolved:** 2026-03-17 -**Impact:** Affects I8 (merge implementation), frontend merge UI. +--- -**Options:** -- **(A)** `{id}` = target (kept). Rename body param to `source_person_id`. Follows REST convention. -- **(B)** `{id}` = source (destroyed). Keep body as `target_person_id`. Document explicitly. +### ~~Q-030-30: Clustering Trigger — When Does DBSCAN Run and How Does It Feed Suggestions?~~ ✅ RESOLVED -**Affects:** API-030-06, FR-030-11, I8, I14 (frontend merge action). +**Resolution:** **Option A** — per-scan suggestions use **nearest-neighbour cosine similarity search** against stored embeddings via `sqlite-vec`/`pgvector` (fast, inline with the detection job). DBSCAN is a **separate offline batch operation** grouping unassigned faces for the People browse UI; triggered manually via `POST /cluster` and never invoked per scan request. ---- +**Spec Impact:** Update `clustering/clusterer.py` description in project structure (offline batch, not per-scan). Update DBSCAN tech stack table entry. Add `POST /cluster` to routes list. Clarify `SuggestionResult` data source as NN cosine similarity search. -### Q-030-23: face_scan_status State Machine Transitions Undefined +**Resolved:** 2026-03-17 -**Question:** The `face_scan_status` enum (`null` / `pending` / `completed` / `failed`) is added to the photos table but its state transitions are not documented: +--- -1. What sets `pending`? (DispatchFaceScanJob dispatch? Or the HTTP request to Python?) -2. What sets `completed`? (The callback handler in ProcessFaceDetectionResults?) -3. What sets `failed`? (Error callback? Timeout? Exception in job?) -4. Can `failed` → `pending` (retry)? Can `completed` → `pending` (re-scan)? -5. If a photo is `pending` and user triggers another scan, what happens? Ignore? Reset? +### ~~Q-030-31: `VISION_CONFIDENCE_THRESHOLD` — Detection Filter vs. Matching Threshold~~ ✅ RESOLVED -**Impact:** Affects I10, I11, bulk scan progress reporting. +**Resolution:** **Option B** — two separate thresholds. Rename `VISION_CONFIDENCE_THRESHOLD` → `VISION_FACE_DETECTION_THRESHOLD` (bounding box filter: faces below threshold excluded from callback payloads) and add `VISION_FACE_MATCH_THRESHOLD` (similarity search cutoff: suggestions and selfie match results below threshold excluded). Independent configuration allows operators to tune detection sensitivity and identity matching independently. -**Options:** -- **(A)** Define explicit state machine: - - `null` → `pending` (scan requested) - - `pending` → `completed` (success callback received) - - `pending` → `failed` (error callback or timeout) - - `failed` → `pending` (retry allowed) - - `completed` → `pending` (re-scan allowed) - - `pending` → `pending` (duplicate request ignored — no-op) +**Spec Impact:** Remove `VISION_CONFIDENCE_THRESHOLD` from env var table. Add `VISION_FACE_DETECTION_THRESHOLD` (default `0.5`) and `VISION_FACE_MATCH_THRESHOLD` (default `0.5`). Rename `AppSettings.confidence_threshold` → `detection_threshold` + add `match_threshold`. Update `app/detection/detector.py` and `app/matching/matcher.py` references. -**Affects:** I10, I11, DispatchFaceScanJob, ProcessFaceDetectionResults. +**Resolved:** 2026-03-17 --- -### Q-030-24: Similar Faces in Assignment Modal — Data Source Unspecified - -**Question:** UI-030-04 (Face Assignment Modal) shows "Similar faces found: [Alice (94%)] [Bob (12%)]". This implies a similarity query — given an unassigned face, find the most similar existing persons. But there's no Lychee API endpoint that provides this data. Where does it come from? +### ~~Q-030-32: InsightFace Model Acquisition — Baked Into Docker Image vs. Runtime Download~~ ✅ RESOLVED -**Impact:** Affects I16 (frontend assignment modal), possibly I2 (Python service), possibly new API endpoint. +**Resolution:** **Option A** — bake `buffalo_l` model weights into the Docker image at build time via a `RUN` step in the builder stage. The multi-stage Dockerfile copies the downloaded model folder from builder to runtime. Image is significantly larger (~1GB+) but starts instantly and works in airgapped environments. Model updates require an image rebuild (acceptable given model stability). -**Options:** -- **(A)** Pre-computed during scan: Python includes `cluster_suggestion` or `similar_embedding_ids` in the callback. Lychee stores these on the Face record or a separate suggestions table. -- **(B)** On-demand query: when user opens assignment modal, frontend calls a new endpoint (e.g., `GET /api/v2/Face/{id}/suggestions`) which queries Python service for similar embeddings → resolves to Persons. -- **(C)** Frontend-only heuristic: no similarity data. Drop the "Similar faces found" from the modal. User picks from a Person dropdown only. +**Spec Impact:** Update Dockerfile spec: add `RUN uv run python -c "..."` model download step in builder stage; add `COPY --from=builder /root/.insightface /root/.insightface` in runtime stage. Note model size and rebuild requirement in Docker configuration section. -**Affects:** UI-030-04, possibly new API endpoint, I2 (if pre-computed), I16. +**Resolved:** 2026-03-17 --- -### Q-030-25: Crop Storage Path Pattern Undefined +### ~~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. -**Question:** Face crops (150×150 JPEG) are described as "stored alongside size variants" but the actual filesystem path pattern is not specified. This matters for: -- Generating crop URLs for frontend display. -- Cleanup on Face deletion or re-scan. -- Serving via Lychee's existing media serving pipeline. +**Spec Impact:** Updated DO-030-05 (domain object table and DSL). Updated `SuggestionResult` Pydantic model comment. `face_suggestions` migration will use `suggested_face_id` (FK→faces) instead of `person_id` (FK→persons). -**Impact:** Affects I10 (ProcessFaceDetectionResults — where to write), I6 (FaceResource crop_url), I16 (frontend crop display). +**Resolved:** 2026-03-18 -**Options:** -- **(A)** Store under photo's size variant directory: `{photo_variant_dir}/faces/{face_id}.jpg`. Served via same media controller. -- **(B)** Dedicated faces directory: `uploads/faces/{face_id}.jpg`. Separate serving route. -- **(C)** Store in `storage/app/faces/{face_id}.jpg` — Laravel storage disk, served via signed URL or controller. +**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. -**Affects:** FR-030-02, I10, I6, Face model `crop_url` accessor, frontend. +**Resolved:** 2026-03-23 --- -### ~~Q-030-13: Embedding ID → Person Mapping Gap in Selfie Match Flow~~ ✅ RESOLVED +### ~~Q-030-34: Crop Serving Route Undefined~~ ✅ RESOLVED -**Resolution:** **Option A** — Store `lychee_face_id` in Python's embedding DB. When Lychee ingests a scan callback it creates Face records and returns the `embedding_id → lychee_face_id` mapping in the HTTP 200 response body. Python persists each mapping. The `/match` endpoint returns `lychee_face_id` (not `embedding_id`); Lychee resolves `lychee_face_id → Face → person_id`. +**Resolution:** **Option B** — crops served directly by nginx with no application-level auth. The crop token stored in the Face model is a random high-entropy identifier (not a sequential ID), so enumeration of `uploads/faces/` is not feasible. Path structure mirrors Lychee's existing size-variant pattern: `uploads/faces/{token[0:2]}/{token[2:4]}/{token}.jpg` (e.g. `uploads/faces/aa/bb/aabbccddeeff0011223344.jpg`). `FaceResource.crop_url` returns this path directly; no dedicated controller route needed. API-030-16 slot is therefore free for the dismissed-face bulk delete (Q-030-43). -**Spec Impact:** Update `DetectCallbackPayload` response body to include `{"faces": [{"embedding_id": "...", "lychee_face_id": "..."}]}`. Update `MatchResult` Pydantic model: replace `embedding_id` with `lychee_face_id`. Update FR-030-12, API-030-13, I2, I8, inter-service contract. +**Spec Impact:** Update DO-030-02 and DSL `crop_token` constraint to reflect the two-level hash path and nginx-direct serving. -**Resolved:** 2026-03-17 +**Resolved:** 2026-03-18 --- -### ~~Q-030-14: Re-scan Destroys Manual Face Assignments~~ ✅ RESOLVED +### ~~Q-030-35: IoU Threshold for Re-scan Face Matching Not Defined~~ ✅ RESOLVED -**Resolution:** **Options A + C** — On re-scan, new faces are matched to existing faces by bounding box IoU (≥ threshold); matched old face's `person_id` is carried over to the new face record; truly gone faces are deleted. Additionally, if a photo has any faces with a `person_id` assigned, re-scan is blocked unless the request includes `force: true`. Without `force: true` a 409 Conflict is returned listing the number of assigned faces at risk. +**Resolution:** **Option B** — add `VISION_FACE_RESCAN_IOU_THRESHOLD` env var (default `0.5`) mapped to `AppSettings.rescan_iou_threshold`. Allows operators to tune matching sensitivity for re-scans without rebuilding the image. -**Spec Impact:** Update FR-030-07 (re-scan idempotency now caveated with IoU preservation + force flag). Update S-030-14. Update `ProcessFaceDetectionResults` action description. Update API-030-10 to document optional `force` parameter. +**Spec Impact:** Add `rescan_iou_threshold: float = 0.5` to `AppSettings`. Add `VISION_FACE_RESCAN_IOU_THRESHOLD` row to the env var table. Update FR-030-07 resolved note to reference the configurable threshold. -**Resolved:** 2026-03-17 +**Resolved:** 2026-03-18 --- -### ~~Q-030-15: Two API Keys but Lychee Config Only Defines One~~ ✅ RESOLVED +### ~~Q-030-36: "Claim Person" in Restricted Mode Listed as "All Users" — Contradictory~~ ✅ RESOLVED -**Resolution:** **Option A** — Single shared symmetric key for both directions. Header: `X-API-Key: `. The key is defined in `.env` as `AI_VISION_API_KEY` (after Q-030-19 renaming). **Critical separation of concerns:** the AI vision callback endpoints (`POST /api/v2/FaceDetection/results`) are authenticated **exclusively** via the API key header — no user session, no admin session. Even authenticated admins cannot reach these endpoints through the normal auth middleware. Lychee-to-Python requests likewise send `X-API-Key` with the same shared key. +**Resolution:** Fixed in permission matrix — `Claim person` now reads `logged users` for all four modes. "All users" (including unauthenticated guests) would make no sense since claiming requires a User record to link. -**Spec Impact:** Update config migration to single key `ai_vision_api_key`. Add note that FaceDetection/results middleware skips session auth. Update NFR-030-07, I3, I4, I10, inter-service contract, AppSettings. +**Spec Impact:** Spec line 78 updated. No further changes needed. -**Resolved:** 2026-03-17 +**Resolved:** 2026-03-18 --- -### ~~Q-030-16: Missing Face Deletion Endpoint for False Positives~~ ✅ RESOLVED +### ~~Q-030-37: "Unknown" Group in People Page Not Designed~~ ✅ RESOLVED -**Resolution:** **Option C (dismiss-first)** — Users dismiss false positives via `PATCH /api/v2/Face/{id}` (toggles `is_dismissed`). Dismissed faces are hidden from face overlays and assignment UI. Admin can hard-delete all dismissed faces in bulk from the Maintenance page (a new maintenance action); this permanently removes the Face records + crop files. +**Resolution:** **Option A** — virtual aggregate. `GET /api/v2/People` always appends a synthetic `{id: null, name: "Unknown", face_count: N}` entry where `N = COUNT(faces WHERE person_id IS NULL)`. No DB record required. Clicking the tile navigates to `GET /api/v2/Face?unassigned=true`. The entry is omitted when `N = 0`. -**Spec Impact:** Add `is_dismissed` boolean (default `false`) to DO-030-02 and Face migration. Add API-030-14 (`PATCH /api/v2/Face/{id}` dismiss toggle). Add admin maintenance action for bulk hard-delete of dismissed faces. Update UI-030-03 (face overlay hides dismissed faces). +**Spec Impact:** Update API-030-01 notes. Add `GET /api/v2/Face?unassigned=true` filter note. Update UI-030-01 description. -**Resolved:** 2026-03-17 +**Resolved:** 2026-03-18 --- -### ~~Q-030-17: Error Callback Shape Undefined~~ ✅ RESOLVED +### ~~Q-030-38: `face_scan_status` Column Type and DSL Entry Missing~~ ✅ RESOLVED -**Resolution:** **Option A** — Python posts an error callback payload to the same `callback_url`: `{"photo_id": "abc", "status": "error", "error_code": "corrupt_file", "message": "..."}`. Lychee sets `face_scan_status = failed`. Python defines `ErrorCallbackPayload` Pydantic model. No timeout mechanism; status transitions only occur via explicit callbacks. +**Resolution:** **Option A** — `VARCHAR(16)`, nullable, with a PHP-side `ScanStatus` Enum cast. Portable across MySQL, PostgreSQL, and SQLite. Consistent with Lychee's existing enum-as-string column pattern. -**Spec Impact:** Add `ErrorCallbackPayload` Pydantic model. Update FR-030-07 (result endpoint handles both success and error payloads). Update `face_scan_status` state machine in spec. Update I2, I10. +**Spec Impact:** Add `face_scan_status` field to the `photos` table addendum in the Spec DSL (`type: string (VARCHAR 16)`, nullable, `cast: ScanStatus`). Document the cast in the state machine section. -**Resolved:** 2026-03-17 +**Resolved:** 2026-03-18 --- -### ~~Q-030-18: Spec DSL Type Mismatch — Face.person_id~~ ✅ RESOLVED +### ~~Q-030-39: Crop Inline Base64 Payload Size Limit Undefined~~ ✅ RESOLVED -**Resolution:** **Option A** — Fix `person_id` field in DO-030-02 DSL from `type: integer` to `type: string`. +**Resolution:** **Option A** — cap at N faces per callback, default `N = 10` (configurable via `VISION_FACE_MAX_FACES_PER_PHOTO`). Python keeps the top-N faces by confidence and drops the rest from the callback payload. Operators may raise the limit but must accept the corresponding body size increase. -**Spec Impact:** Update DO-030-02 Spec DSL `person_id` type field. Low impact. +**Spec Impact:** Add `VISION_FACE_MAX_FACES_PER_PHOTO` env var (default `10`) and `max_faces_per_photo: int = 10` to `AppSettings`. Update `FaceResult` / `DetectCallbackPayload` comments to note the cap. -**Resolved:** 2026-03-17 +**Resolved:** 2026-03-18 --- -### ~~Q-030-19: Naming Inconsistency — FACE_* Prefix vs ai-vision-service~~ ✅ RESOLVED +### ~~Q-030-40: Bulk Scan Scope — `IS NULL` Only or Include `failed`?~~ ✅ RESOLVED -**Resolution:** **Option B** — Rename for future-proofing. Python env vars use `VISION_*` prefix; Lychee config keys use `ai_vision_*` prefix. All documentation, docker-compose, and AppSettings updated accordingly. +**Resolution:** **Option A** — bulk scan targets `IS NULL` only. A separate **Maintenance page action** ("Re-scan failed photos") handles `face_scan_status = 'failed'` recovery, keeping bulk scan fast and predictable. -**Spec Impact:** Rename `FACE_*` → `VISION_*` throughout Python service config and docker-compose. Rename `face_recognition_*` → `ai_vision_*` for all Lychee config keys. Update AppSettings `env_prefix`. Update all env variable tables in spec and docs. +**Spec Impact:** FR-030-09 stays as IS NULL. Add CLI-030-03 `php artisan lychee:rescan-failed-faces` and a corresponding admin Maintenance page action. -**Resolved:** 2026-03-17 +**Resolved:** 2026-03-18 --- -### ~~Q-030-20: Permission Mode Scope per Operation Is Ambiguous~~ ✅ RESOLVED - -**Resolution:** **Option C** — Four-mode enum (`public`, `private`, `privacy-preserving`, `restricted`) with a per-operation matrix: +### ~~Q-030-41: Album Scan Depth — Recursive Through Sub-Albums?~~ ✅ RESOLVED -| Operation | public | private | privacy-preserving | restricted | -|--------------------|--------------|--------------|---------------------------|---------------------------| -| View People page | guest | logged users | photo/album owner + admin | admin only | -| View face overlays | album access | logged users | photo/album owner + admin | photo/album owner + admin | -| Create/edit Person | logged users | logged users | photo/album owner + admin | admin only | -| Assign face | logged users | logged users | photo/album owner + admin | admin only | -| Trigger scan | logged users | logged users | photo/album owner + admin | photo/album owner + admin | -| Claim person | logged users | logged users | logged users | all users | -| Merge persons | logged users | logged users | photo/album owner + admin | admin only | +**Resolution:** **Option C** — user-selectable scope. Bulk scan UI offers two options: (1) **Library scan** — all unscanned photos across the entire library; (2) **Album scan** — all unscanned photos directly in the selected album (non-recursive). Sub-album scans are triggered explicitly. Matches existing CLI-030-01 / CLI-030-02 pattern. -**Spec Impact:** Update `ai_vision_permission_mode` to a 4-value enum. Update NFR-030-07 with full matrix. Update FR-030-08 authorization description. Update all controller authorization references (I7, I8, I9, I10). +**Spec Impact:** Update FR-030-09 to describe both scope options. Update API-030-12 notes to clarify non-recursive album scope. -**Resolved:** 2026-03-17 +**Resolved:** 2026-03-18 --- -### ~~Q-030-21: Missing Person Unclaim Endpoint~~ ✅ RESOLVED +### ~~Q-030-42: Face Reassignment Authorization Across Users~~ ✅ RESOLVED -**Resolution:** **Option A** — Add `DELETE /api/v2/Person/{id}/claim` as API-030-15. Removes `person.user_id` (sets to null). Linked user or admin only. +**Resolution:** **Option C** — mode-governed. In `public` and `private` modes, any user who passes the "Assign face" permission check (NFR-030-07 matrix) may reassign any face. In `privacy-preserving` and `restricted` modes, only the photo owner or admin may reassign. No `assigned_by_user_id` field needed. -**Spec Impact:** Add API-030-15 to API catalogue and Spec DSL routes. Update FR-030-05 to reference unclaim. Update I8. +**Spec Impact:** Add a clarifying note to the permission matrix that the "Assign face" row governs cross-user reassignment as well. Add comment to FR-030-04/FR-030-10. -**Resolved:** 2026-03-17 +**Resolved:** 2026-03-18 --- -### ~~Q-030-22: Merge Direction Ambiguity on API-030-06~~ ✅ RESOLVED +### ~~Q-030-43: Admin Bulk Hard-Delete of Dismissed Faces Missing from API Catalogue~~ ✅ RESOLVED -**Resolution:** **Option A** — `{id}` = target (kept). Body parameter renamed to `source_person_id`. Follows REST convention: the URL resource is the one preserved. +**Resolution:** **Option A** — add `DELETE /api/v2/Face/dismissed` as **API-030-16**. Admin-only; hard-deletes all `is_dismissed = true` Face records and their crop files. -**Spec Impact:** Update API-030-06 body param from `target_person_id` to `source_person_id`. Update FR-030-11. Update I8 and I14 (frontend merge action). +**Spec Impact:** Add API-030-16 to API catalogue table and DSL routes. -**Resolved:** 2026-03-17 +**Resolved:** 2026-03-18 --- -### ~~Q-030-23: face_scan_status State Machine Transitions Undefined~~ ✅ RESOLVED +### ~~Q-030-44: Selfie Upload Has No Rate Limiting~~ ✅ RESOLVED -**Resolution:** **Option A** — Explicit state machine: -1. `null → pending`: set on **dispatch** (when the scan job is enqueued, before the HTTP request to Python is sent). -2. `pending → completed`: set when Lychee receives a **success** callback from the Python service. -3. `pending → failed`: set when Lychee receives an **error** callback from Python. No timeout mechanism (async model; Lychee never waits for a response). -4. Retry/re-scan: `failed → pending` (retry) and `completed → pending` (re-scan) are both **allowed**. -5. Duplicate pending: **reset** to `pending` (do not ignore); the earlier `pending` could be a silent timeout. +**Resolution:** Rate limiting applied at the **Lychee PHP layer** via Laravel's built-in throttle middleware on API-030-13 (`POST /api/v2/Person/claim-by-selfie`). No changes to the Python service needed. -**Spec Impact:** Document state machine in FR-030-07/NFR section. Update I10, I11, DispatchFaceScanJob, ProcessFaceDetectionResults. +**Spec Impact:** Add `throttle:5,1` (5 requests/minute per user) to the API-030-13 route definition note. Document in deployment guide. -**Resolved:** 2026-03-17 +**Resolved:** 2026-03-18 --- -### ~~Q-030-24: Similar Faces in Assignment Modal — Data Source Unspecified~~ ✅ RESOLVED +### ~~Q-030-46: `FaceResource` (DO-030-04) Field Specification Missing~~ ✅ RESOLVED -**Resolution:** **Option A, stored in a dedicated suggestions table** — Python includes a `suggestions` array per face in the `DetectCallbackPayload`. Lychee persists these in a `face_suggestions` table (`face_id`, `person_id`, `confidence`). The assignment modal reads from this table. New domain object `FaceSuggestion` added. +**Resolution:** **Option A** — suggestions are embedded in FaceResource. Fields exposed: `id` (Face ID), `photo_id`, `person_id` (nullable), `x`/`y`/`width`/`height` (float 0.0–1.0 bounding box), `confidence`, `is_dismissed`, `crop_url` (computed nginx-direct path from crop_token). Embedded `suggestions[]` array — each item: `suggested_face_id`, `crop_url` (suggested face's own crop or null), `person_name` (nullable, LEFT JOIN on persons), `confidence`. Suggestions are always included (pre-computed, stored in `face_suggestions`) — no N+1 risk. -**Spec Impact:** Add `FaceSuggestion` domain object (DO-030-05). Add `face_suggestions` table to migrations. Update `FaceResult` Pydantic model to include `suggestions: list[SuggestionResult]`. Update UI-030-04. Update I2, I10, I16. +**Spec Impact:** Expanded DO-030-04 in narrative domain objects table. -**Resolved:** 2026-03-17 +**Resolved:** 2026-03-18 --- -### ~~Q-030-25: Crop Storage Path Pattern Undefined~~ ✅ RESOLVED +### ~~Q-030-47: Missing Telemetry Events for Face Dismiss/Undismiss and Bulk Delete~~ ✅ RESOLVED -**Resolution:** **Option B** — Crops stored at `uploads/faces/{face_id}.jpg` in a dedicated `faces/` subdirectory under the main uploads directory. Served via a separate media controller route (not the standard photo size-variant pipeline). +**Resolution:** **Option A** — three new events added: `TE-030-10` → `face.dismissed` (`face_id`, `photo_id`), `TE-030-11` → `face.undismissed` (`face_id`, `photo_id`), `TE-030-12` → `face.bulk_deleted` (`deleted_count`). -**Spec Impact:** Update DO-030-02 `crop_path` description. Update `crop_url` accessor. Add a new route for serving face crops. Update I6, I10, I16. +**Spec Impact:** Added TE-030-10, TE-030-11, TE-030-12 to telemetry events table and DSL. -**Resolved:** 2026-03-17 +**Resolved:** 2026-03-18 --- -### ~~Q-029-01: Destination album for camera capture from root view~~ ✅ RESOLVED +### ~~Q-030-48: No CLI/UI Path for Photos Stuck in `pending` Indefinitely~~ ✅ RESOLVED -**Question:** When the user takes a photo from the root albums view (not inside any album), where should the captured photo be stored? +**Resolution:** **Options B + C** combined — (B) `CLI-030-03` extended with optional `--stuck-pending [--older-than=N]` flag to reset pending records older than N minutes (default 60) back to `null`. (C) Admin Maintenance page action via **`GET /api/v2/Maintenance::resetStuckFaces`** (check: count of stuck records) + **`POST /api/v2/Maintenance::resetStuckFaces`** (do: reset them). Follows the existing check/do Maintenance route pattern. Endpoint added as API-030-17 / API-030-17b. -**Resolution:** Upload with no album ID — photo lands in the "Unsorted" smart album, consistent with existing upload behaviour at root level. +**Spec Impact:** Extended CLI-030-03 description. Added API-030-17 and API-030-17b to API catalogue and DSL routes. **Resolved:** 2026-03-18 --- -### ~~Q-026-01: TagAlbum and Smart Album Support Scope~~ ✅ RESOLVED +### ~~Q-030-49: Cluster Storage Model — Resolved~~ ✅ RESOLVED — How Should the Backend Know About Existing Clusters? -**Question:** Should TagAlbums and Smart Albums support tag filtering in the future, or is "only regular Albums" a permanent architectural decision? +**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. -**Resolution:** Tag filtering applies to **all album types** (regular Albums, TagAlbums, and Smart Albums) in v1. +**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.** -**Rationale:** User specified "This is for all albums: regular, tags, smart." The feature should provide consistent filtering UX across all album types. +**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. -**Spec Impact:** Remove "Filtering TagAlbums or Smart Albums" from Non-Goals; update FR-026-01 to clarify support for all album types; add test scenarios for TagAlbum and SmartAlbum filtering. +**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.** -**Resolved:** 2026-03-09 +**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-026-02: Large Tag List UX Strategy (100+ Tags)~~ ✅ RESOLVED +### ~~Q-030-50: `PersonResource.representative_crop_url` — Selection Rule Unspecified~~ ✅ RESOLVED -**Question:** How should the tag filter UI handle albums with 100+ unique tags (beyond the spec's "up to 20 unique tags" performance target)? +**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. -**Resolution:** **Option B** - Add search/filter to tag dropdown in v1 (enable PrimeVue MultiSelect `filter` prop). +**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. -**Rationale:** PrimeVue MultiSelect has built-in filter capability; minimal implementation effort for better UX. +**Impact:** If implementors pick different strategies independently, the result will differ from what product design expects. Affects I6 (FaceResource), I13 (People page thumbnails). -**Spec Impact:** Update NFR-026-02 (Usability) to note that tag dropdown includes search/filter for large tag lists. +**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. -**Resolved:** 2026-03-09 +**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-026-03: URL-based Filter State Representation~~ ✅ RESOLVED +### ~~Q-030-51: `ai_vision_enabled` / `ai_vision_face_enabled` Gating Hierarchy~~ ✅ RESOLVED -**Question:** Should the active tag filter be represented in the URL query string (e.g., `/gallery/album-id?tag_ids=1,2&tag_logic=OR`) to enable bookmarking and sharing, or should it remain in component state only? +**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`. -**Resolution:** **Option A** - Component state only; no URL representation in v1. +**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. -**Rationale:** Simpler implementation for v1. Filter state stored in component `ref()` without Vue Router query param synchronization. Users cannot bookmark/share filtered views (accepted limitation). +**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). -**Spec Impact:** Non-Goals already documents this; no change needed. +**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`." -**Resolved:** 2026-03-09 +**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-026-04: Album::tags Security Filtering Approach~~ ✅ RESOLVED +### ~~Q-030-52: Embedding Deletion Dispatch Hook — Observer vs. Photo Pipeline~~ ✅ RESOLVED -**Question:** For the `Album::tags` endpoint, should it apply per-photo security filters when fetching tags (e.g., only include tags from public photos when viewing as guest), or rely solely on album-level access check? +**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. -**Resolution:** **Album-level access only** (Option A). Album::tags returns tags from photos directly attached to that album. Album-level access rights determine which photos are accessible, and thus which tags should be returned. +**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: -**Rationale:** User clarified: "Album::tags should return the list of tags which are associated to the photos directly attached to that album. The access rights on the album_id determine directly what photos are accessible, thus which tags should be returned." +- **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. -**Spec Impact:** Clarify FR-026-01 to explicitly state album-level access model; no per-photo filtering required. +**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. -**Resolved:** 2026-03-09 +**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-026-05: Behavior When All Tag IDs Are Invalid~~ ✅ RESOLVED +### ~~Q-030-54: Dismiss Face — Button Placement and CTRL+Click Shortcut~~ ✅ RESOLVED -**Question:** When a user provides tag IDs via `tag_ids[]` parameter and ALL of them are invalid (don't exist in database), should the endpoint return all photos (treating invalid IDs as "no filter") or an empty result? +**Resolution:** **Option A** — Add a "Dismiss" button in the FaceAssignmentModal. Additionally, when the user holds CTRL, face overlay rectangles switch to red dashed borders; clicking a rectangle in this state directly dismisses the face without opening the modal. Captured in FR-030-16, UI-030-08, S-030-33/34. -**Resolution:** **Option C** - Return validation error (422 Unprocessable Entity) when all tag IDs are invalid. +**Context:** The spec provides `PATCH /Face/{id}` to toggle `is_dismissed`, but the UI only allows toggling via API. Users need a convenient visual way to dismiss false-positive faces during browsing. -**Rationale:** Clear feedback to client that the request was invalid. Individual invalid IDs are still silently ignored, but if the entire filter set is invalid, return error. +**Impact:** Affects I15 (FaceOverlay.vue), I16 (FaceAssignmentModal.vue), new UI interactions. -**Spec Impact:** Update FR-026-02 to clarify: "Invalid tag IDs individually ignored; if ALL provided tag IDs are invalid, return 422 validation error." +**Option A (Recommended) — Dismiss button in modal + CTRL+click overlay shortcut** +- FaceAssignmentModal gets a "Dismiss" button alongside "Assign". +- FaceOverlay.vue listens for CTRL key state; when held, overlays turn red/dashed; click triggers dismiss API. +- Clear visual feedback on CTRL state change. -**Resolved:** 2026-03-09 -### ~~Q-027-04: Named-Colour Name→Hex Mapping Mechanism~~ ✅ RESOLVED +**Option B — Dismiss only via modal button** +- Simpler, but requires two clicks (open modal + click dismiss) for every false positive. -**Decision:** Option A — Hardcode a PHP `ColourNameMap` class (e.g. `app/Actions/Search/ColourNameMap.php`) containing a `const` array mapping lowercase CSS colour names to `#rrggbb` hex strings, covering the 16 basic CSS Level 1 colours. `ColourStrategy` consults this map when the token value does not start with `#`. Unknown names throw `InvalidTokenException` → HTTP 400. No schema migration required. -**Rationale:** No DB dependency; stateless; testable in isolation; fast. The `colours` table has no `name` column and `Colour::fromHex()` only accepts hex strings, so a hardcoded PHP map is the only viable no-migration path. -**Updated in spec:** FR-027-09 (named-colour resolution description updated), T-027-03 and T-027-22 notes updated. +**Resolved:** 2026-04-04 --- -### ~~Q-027-05: Invalid SQL Syntax in Colour-Similarity EXISTS Subquery~~ ✅ RESOLVED +### ~~Q-030-55: Maintenance Block — Destroy Dismissed Faces + Reset Stuck/Failed Scans~~ ✅ RESOLVED -**Decision:** Option A — Replace the invalid `JOIN … ON c.id IN (…)` with an explicit OR expansion in the `ON` clause: +**Resolution:** **Option A** — Add a maintenance block for destroying all dismissed faces (calls `DELETE /Face/dismissed`). The block should only appear when there are dismissed faces to destroy (conditional rendering via the check endpoint). Additionally, add maintenance blocks to reset photos with face scan status "stuck" (pending too long) and "failed" so they can be re-scanned. Captured in API-030-21, API-030-22, API-030-23. -```sql -EXISTS ( - SELECT 1 FROM palette p - JOIN colours c ON (c.id = p.colour_1 OR c.id = p.colour_2 OR c.id = p.colour_3 OR c.id = p.colour_4 OR c.id = p.colour_5) - WHERE p.photo_id = photos.id - AND ABS(c.R - :R) + ABS(c.G - :G) + ABS(c.B - :B) <= :dist -) -``` +**Context:** The `DELETE /Face/dismissed` endpoint exists (API-030-16) but has no maintenance UI block. Users need a convenient way to clean up dismissed faces. Similarly, photos stuck in "pending" or "failed" need to be resettable from the UI. -**Rationale:** Standard SQL valid across SQLite, MySQL, and PostgreSQL. Within an `EXISTS` the five-OR join is harmless — multiple matching `colours` rows per palette row are irrelevant since `EXISTS` short-circuits on the first match. -**Updated in spec:** FR-027-09, NFR-027-04 (both SQL snippets corrected); plan.md I7; tasks.md T-027-22. +**Impact:** Affects Maintenance.vue, new maintenance controller endpoints. ---- +**Option A (Recommended) — Three conditional maintenance blocks: dismiss cleanup + reset stuck + reset failed** +- `MaintenanceDestroyDismissedFaces.vue`: check returns count of dismissed faces; do calls `DELETE /Face/dismissed`; hidden when count is 0. +- `MaintenanceResetStuckFaces.vue`: (already exists) check returns count of stuck-pending; do resets them. +- `MaintenanceResetFailedFaces.vue`: check returns count of failed scans; do resets `face_scan_status` to null. -### ~~Q-027-01: Colour Distance Metric and Named-Colour Lookup~~ ✅ RESOLVED +**Option B — Single combined maintenance block** +- One block with multiple actions. Less granular but simpler UI. -**Decision:** `palette.colour_N` values are foreign keys to `colours.id` (the packed 0xRRGGBB integer); the `colours` table already has separate `R`, `G`, `B` integer columns. Use a JOIN `palette → colours ON colours.id IN (p.colour_1, …, p.colour_5)` and compute Manhattan distance directly on `colours.R/G/B`. No schema migration required. Named colours resolved via `Colour::fromHex()` / `colours` table lookup. -**Rationale:** The separate R/G/B columns are already present in the DB; no bit-shift needed, fully portable across SQLite/MySQL/PostgreSQL. -**Updated in spec:** FR-027-09 (colour query mechanism), NFR-027-04 (SQL portability note updated). +**Resolved:** 2026-04-04 --- -### ~~Q-027-02: Rating Filter — Own Rating vs Average Rating~~ ✅ RESOLVED - -**Decision:** Option C — Support both sub-modifier forms: `rating:avg:>=4` (filters by `photos.rating_avg`) and `rating:own:>=4` (filters by the requesting user's own rating via JOIN on `photo_ratings WHERE user_id = Auth::id()`). Unauthenticated users may only use `rating:avg:`. -**Rationale:** Maximum flexibility; users with personal rating habits benefit from `own:` while gallery visitors can still filter by average. -**Updated in spec:** FR-027-14 (rating sub-modifiers), grammar reference updated, scenarios S-027-21/S-027-22 added. +### ~~Q-030-56: Uncluster Faces from a Cluster — Batch Selection + Uncluster Action~~ ✅ RESOLVED ---- +**Resolution:** **Option A** with batch selection — In the Cluster Review UI, users can select individual faces within a cluster (checkbox/multi-select), then choose to "uncluster" them. Unclustering sets `cluster_label = NULL` on the selected faces, removing them from the cluster without dismissing them. This allows fine-grained curation of clusters before bulk-assigning. Captured in FR-030-17, API-030-24, S-030-35. -### ~~Q-027-03: Album Search Modifier Support — This Feature or Follow-up?~~ ✅ RESOLVED +**Context:** DBSCAN may group unrelated faces in the same cluster. Users need to remove incorrect faces from a cluster before bulk-assigning the rest to a Person. -**Decision:** Option B — Include album modifier support (`title:`, `description:`, `date:`) in Feature 027. `AlbumSearch` is wired to the same `SearchTokenParser`; a new `AlbumSearchTokenStrategy` interface mirrors `PhotoSearchTokenStrategy`. -**Rationale:** Consistent user experience in a single release; the token infrastructure from the photo search is directly reusable. -**Updated in spec:** FR-027-15 (album modifiers), Non-Goals updated (album modifiers removed), scenarios S-027-23/S-027-24 added. +**Impact:** Affects I22 (FaceClusters.vue), new API endpoint. ---- +**Option A (Recommended) — Select faces in cluster, then uncluster selected** +- Multi-select UI in cluster card (checkbox on each face crop). +- "Uncluster selected" button sets `cluster_label = NULL` on selected face IDs. +- New API: `POST /FaceDetection/clusters/{cluster_id}/uncluster` with body `{face_ids: []}`. -### ~~Q-020-01: RAW Conversion Failure Behavior~~ ✅ RESOLVED +**Option B — Drag faces out of cluster** +- Drag-and-drop UX. More intuitive but harder to implement and inaccessible. -**Decision:** Option C — Fall back to existing `raw_formats` behavior (store unprocessed, no conversion) -**Rationale:** Graceful degradation preserves the uploaded file. If Imagick cannot convert the RAW file, it is stored as-is using the existing accepted-raw path (the raw file becomes the ORIGINAL with no thumbnails). Additionally, a data migration will move existing files that are currently stored as ORIGINAL but match raw format extensions to the new RAW size variant type. -**Updated in spec:** FR-020-03 (failure path), FR-020-16 (migration of existing raw-format files from ORIGINAL to RAW type) +**Resolved:** 2026-04-04 --- -### ~~Q-020-02: RAW Conversion Tooling & Imagick Delegate Requirements~~ ✅ RESOLVED +### ~~Q-030-57: Remove Face from Person — Face Becomes Unassigned~~ ✅ RESOLVED -**Decision:** Option A — Require Imagick with libraw/dcraw delegates; document system requirements -**Rationale:** Single code path through Imagick. Existing `HeifToJpeg` already uses Imagick. System requirement: `apt install libraw-dev` (or equivalent) for camera RAW delegate support. If a specific format is unsupported by the installed Imagick delegates, the fallback from Q-020-01 applies (file stored as-is). -**Updated in spec:** NFR-020-04 (Imagick requirement), FR-020-09 (conversion tooling) +**Resolution:** **Option A** — Users can remove a face from a person, which sets `face.person_id = NULL`. The face becomes unassigned (not dismissed). This is distinct from dismissing: dismiss marks the face as a false positive; unassign returns it to the pool of unassigned faces. Captured in FR-030-18, API-030-25, S-030-36. ---- +**Context:** After assigning faces to persons, users may discover incorrect assignments. They need to unlink a face from a person without dismissing it entirely. -### ~~Q-020-03: Async Conversion for Large RAW Files~~ ✅ RESOLVED +**Impact:** Affects PersonDetail.vue, FaceOverlay.vue, new/updated API endpoint. -**Decision:** Option A — Synchronous conversion (already async via job pipeline) -**Rationale:** Lychee already processes uploads through queued jobs, so conversion is inherently asynchronous from the user's perspective. No additional async infrastructure is needed. The conversion runs within the existing job pipeline. -**Updated in spec:** NFR-020-02 (clarified: conversion happens in existing job pipeline) +**Option A (Recommended) — Set `person_id = NULL` via existing assign endpoint** +- `POST /Face/{id}/assign` with `person_id: null` (or a dedicated `unassign` action). +- The face returns to the unassigned pool and may appear in future cluster runs. + +**Resolved:** 2026-04-04 --- -### ~~Q-020-04: Interaction with Existing `raw_formats` Config~~ ✅ RESOLVED +### ~~Q-030-58: Batch Face Operations — Select Multiple Faces, Unassign/Assign/Create Person~~ ✅ RESOLVED -**Decision:** Option A — Keep both systems separate, with refinement -**Rationale:** The `raw_formats` config continues to define accepted extra formats. However, files matching `raw_formats` are now stored as **RAW size variants** (not ORIGINAL) — unless they are PDF, which remains stored as ORIGINAL (since PDF can be rendered/displayed). The new convertible-RAW pipeline (camera RAW + HEIC/HEIF) is a separate hardcoded list that triggers conversion. If an extension is in both lists, the new RAW pipeline takes precedence. -**Updated in spec:** FR-020-03, FR-020-04, FR-020-09, FR-020-16 (unprocessed raw_formats files stored as RAW type, PDF exception) +**Resolution:** **Option A** — For a person or cluster view, users can select a set of faces (multi-select with checkboxes), then choose from: (a) unassign all selected (set `person_id = NULL`), (b) assign all selected to another existing person, (c) assign all selected to a new person. This applies in both Person Detail and Cluster Review contexts. Captured in FR-030-19, API-030-26, S-030-37. ---- +**Context:** One-by-one face operations are tedious for large datasets. Batch operations dramatically improve UX for face curation. -### ~~Q-019-01: Hierarchical vs Flat Slugs~~ ✅ RESOLVED +**Impact:** Affects PersonDetail.vue, FaceClusters.vue, batch API endpoint. -**Decision:** Option A — Flat globally-unique slugs -**Rationale:** Simpler implementation with a single `slug` column and unique index on `base_albums`. No dependency on parent album structure — renaming/moving a parent doesn't invalidate child slugs. Easier to reason about uniqueness and collisions. -**Updated in spec:** FR-019-01 (slug on `base_albums`), FR-019-03 (global uniqueness), Non-Goals (hierarchical paths explicitly excluded) +**Option A (Recommended) — Batch action bar with select mode** +- Toggle "select mode" in person/cluster views. +- Checkbox overlay on each face crop. +- Action bar appears: "Unassign (N)", "Reassign to...", "Assign to new person". +- `POST /Face/batch` with `{face_ids: [], action: "unassign"|"assign", person_id?: string, new_person_name?: string}`. + +**Resolved:** 2026-04-04 --- -### ~~Q-019-02: Top-Level Route Support~~ ✅ RESOLVED +### ~~Q-030-59: Person Miniature in Face Assignment Dropdown~~ ✅ RESOLVED -**Decision:** Option A — Gallery-prefixed only (`/gallery/{slug}`) -**Rationale:** No collision risk with existing routes (`/settings`, `/profile`, `/login`, etc.). No changes to web route definitions — slug resolution happens inside the existing `{albumId}` parameter. Simpler, safer, ships faster. -**Updated in spec:** FR-019-05 (resolution within existing route), FR-019-10 (Vue Router `/gallery/{slug}`), Non-Goals (top-level routes excluded) +**Resolution:** **Option A** — When listing persons in the face assignment modal dropdown, each entry shows a small circular face crop miniature (the representative crop) next to the person name. This helps differentiate people with the same name. Captured in FR-030-20, UI-030-09. ---- +**Context:** Multiple persons can share the same name (e.g., two people named "John"). Without a visual differentiator, users cannot distinguish them in the dropdown. -### ~~Q-019-03: Tag Album Slug Support~~ ✅ RESOLVED +**Impact:** Affects I16 (FaceAssignmentModal.vue), PersonResource (already includes `representative_crop_url`). -**Decision:** Option A — Both Album and TagAlbum (via shared `base_albums` table) -**Rationale:** The `slug` column lives on `base_albums`, which is shared by both Album and TagAlbum. Consistent behaviour — any album-like entity can have a friendly URL. No special-casing needed in the factory or validation. -**Updated in spec:** FR-019-01 (column on `base_albums`), FR-019-03 (uniqueness across Album + TagAlbum), S-019-14 (tag album scenario) +**Option A (Recommended) — Circular miniature + name in dropdown** +- PrimeVue Dropdown with custom `option` template slot. +- Each option: 24px circular `` (representative_crop_url) + person name + face count. +- Fallback placeholder icon when no representative crop exists. + +**Resolved:** 2026-04-04 --- -### ~~Q-017-01: Context Menu Scope Behaviour for Photos vs Albums~~ ✅ RESOLVED +### ~~Q-030-60: Face Circles in Photo Detail Panel~~ ✅ RESOLVED -**Decision:** Option A — Scope radio hidden for photos, shown for albums -**Rationale:** Most intuitive UX. Photos have no descendants so scope is meaningless — hide it. Albums support "Current level" (rename only selected album titles) and "All descendants" (selected albums + sub-albums recursively). Backend receives `album_ids[]` + `scope` for the album path; `photo_ids[]` only (no scope) for the photo path. -**Updated in spec:** FR-017-07 (scope hidden for photos), FR-017-08 (scope shown for albums), FR-017-09 (contract split by target type) +**Resolution:** **Option A** — When the photo details panel (sidebar) is open and the photo has detected faces, display them as circular face crop thumbnails with the person name underneath. Clicking a face circle opens the FaceAssignmentModal. CTRL+clicking a face circle dismisses it (same pattern as CTRL+click on overlay). Captured in FR-030-21, UI-030-10, S-030-38/39. ---- +**Context:** Face overlays on the main photo image may be hard to interact with (small faces, precise clicking). The detail panel provides a more accessible interface for face management. -### ~~Q-017-02: No Renamer Rules Configured Edge Case~~ ✅ RESOLVED +**Impact:** Affects PhotoDetails.vue, new sub-component, FaceAssignmentModal integration. -**Decision:** Option A — Show the empty preview with an enhanced message -**Rationale:** Simplest approach with no extra API calls. The empty-state message is enhanced: "No titles would change. If you haven't configured renamer rules yet, visit Settings → Renamer Rules." Minimal code change, no additional data dependencies. -**Updated in spec:** FR-017-05 (enhanced empty-state message), UI-017-05 +**Option A (Recommended) — Circular crops in detail panel with click/CTRL+click** +- New section "People in this photo" in PhotoDetails.vue. +- Row of circular face crops (48px diameter) with name label below. +- Click → open FaceAssignmentModal for that face. +- CTRL+click → dismiss face directly. +- "Unknown" label for unassigned faces. + +**Resolved:** 2026-04-04 --- -### ~~Q-011-02: Default Sort Order for My Rated Pictures Album~~ ✅ RESOLVED +### ~~Q-030-61: Face Overlay Global Config Settings~~ ✅ RESOLVED -**Decision:** Option A - Sort by rating DESC, then by created_at DESC -**Rationale:** Shows highest-rated photos first, consistent with "favorites" concept. Most intuitive for users wanting to see their best-rated photos at the top. -**Updated in spec:** FR-011-01, query implementation details +**Resolution:** **Option A** with modification — Two **global** config settings (both in `configs` table, not per-user): (1) `ai_vision_face_overlay_enabled` (0|1, default 1): master toggle that enables/disables the face overlay feature entirely. When 0, no face overlays are rendered anywhere. (2) `ai_vision_face_overlay_default_visibility` (enum: `visible`|`hidden`, default `visible`): sets whether face overlays are shown or hidden by default when viewing a photo. Users can toggle visibility with the `P` key. Per-user configuration deferred to a future enhancement. Captured in NFR-030-11, config table entries. ---- +**Context:** Some users may find face overlays distracting. A global toggle and default visibility setting provide admin control over the face overlay UX. -### ~~Q-011-01: Config Key Naming for My Best Pictures Count~~ ✅ RESOLVED +**Impact:** Affects config migration, FaceOverlay.vue, PhotoDetails.vue, keybinding. -**Decision:** Option A - Separate config key `my_best_pictures_count` -**Rationale:** Allows independent configuration. Users might want different counts for overall best pictures vs personal favorites. Clearer semantics with each album having its own setting. -**Updated in spec:** CFG-011-03, DO-011-02 implementation +**Resolved:** 2026-04-04 --- -### ~~Q-010-12: TLS/StartTLS Configuration~~ ✅ RESOLVED +### ~~Q-030-62: Album People Endpoint~~ ✅ RESOLVED -**Decision:** Option A - Single `LDAP_USE_TLS` flag, protocol determined by port -**Rationale:** Simpler configuration with fewer env vars. Protocol auto-detected: port 636 = LDAPS, port 389 = StartTLS. Documentation in .env.example clarifies both scenarios. -**Updated in spec:** ENV-010-13, I10 documentation deliverables +**Resolution:** **Option A** with modification — New endpoint `GET /api/v2/Album/{id}/people` returns the list of people found in a given album. The response uses the same `PaginatedPersonsResource` pattern as the People listing (consistent with `CollectionPhotoResource` style responses, not `ResourceName::collect()`). Photos are linked to albums via the `photo_albums` pivot table (not a direct `album_id` on photos). The query joins `photo_albums → photos → faces → persons` to collect distinct persons. Captured in FR-030-22, API-030-27, S-030-40. ---- +**Context:** When browsing an album, users want to see which people appear in it. This enables a "People in this album" section in the album detail view. -### ~~Q-010-11: Authentication Flow Sequence~~ ✅ RESOLVED +**Impact:** New API endpoint, possible album detail UI enhancement. -**Decision:** Option A - Search-first pattern (username → search → DN → bind → groups) -**Rationale:** Flexible approach supporting diverse LDAP schemas. Flow: 1) User submits username+password, 2) Search LDAP using `LDAP_USER_FILTER`, 3) Get userDn from search result, 4) Bind with userDn+password, 5) Query groups using userDn, 6) Retrieve user attributes. -**Updated in spec:** FR-010-01, I2 LdapService `authenticate()` method, I4 `getUserGroups()` signature +**Option A (Recommended) — Distinct persons via photo_albums join** +- `SELECT DISTINCT persons.* FROM persons JOIN faces ON faces.person_id = persons.id JOIN photos ON faces.photo_id = photos.id JOIN photo_albums ON photo_albums.photo_id = photos.id WHERE photo_albums.album_id = ?` +- Returns `PaginatedPersonsResource`. +- Respects `ai_vision_face_permission_mode` visibility and `is_searchable` filtering. + +**Resolved:** 2026-04-04 --- -### ~~Q-010-10: Testing Strategy~~ ✅ RESOLVED +### ~~Q-030-63: Policy Refinement — Album/Photo Rights vs Face-Level Policy~~ ✅ RESOLVED -**Decision:** Option A - LdapRecord testing utilities for unit tests, skip Docker integration tests -**Rationale:** Fast unit tests using LdapRecord's `DirectoryEmulator` or test helpers. Mock LDAP responses at service boundary. Docker integration tests deferred to future enhancement. -**Updated in spec:** I2-I7 test implementation, no Docker CI configuration needed +**Resolution:** ~~Deferred for now~~ **→ Fully resolved 2026-04-11 by I39.** `PhotoPolicy` gains four per-photo face gate constants (`CAN_VIEW_FACE_OVERLAYS`, `CAN_DISMISS_FACE`, `CAN_ASSIGN_FACE_ON_PHOTO`, `CAN_TRIGGER_SCAN_ON_PHOTO`) and `AlbumPolicy` gains four per-album face gate constants (`CAN_VIEW_ALBUM_PEOPLE`, `CAN_TRIGGER_SCAN_ON_ALBUM`, `CAN_ASSIGN_FACE_IN_ALBUM`, `CAN_BATCH_FACE_OPS`), each evaluating ownership against the concrete photo/album model. `PhotoRightsResource` and `AlbumRightsResource` surface the resulting booleans to the frontend. All face-related `Request` authorizers are updated to use these per-resource gates (FR-030-43 through FR-030-47). ---- +**Context:** The `AiVisionPolicy` currently checks `ai_vision_face_permission_mode` globally but does not cross-check whether the user has edit rights on the specific album or photo. For example, in `privacy-preserving` mode, "photo/album owner + admin" should mean the owner of the specific album/photo, but the current policy may not check actual album ownership. -### ~~Q-010-09: Connection Pooling Implementation~~ ✅ RESOLVED +**Impact:** Resolved — see NFR-030-07 policy refinement note, FR-030-43 through FR-030-47, DO-030-12, DO-030-13, S-030-61 through S-030-65, I39. -**Decision:** Option A - Configure LdapRecord's built-in connection management -**Rationale:** Leverage existing, tested library features. Configure timeouts and connection caching via LdapRecord config. No custom pooling code needed. -**Updated in spec:** I2 implementation approach, NFR-010-04 +**Resolved:** 2026-04-04 (initially deferred); **re-resolved 2026-04-11 (I39 implements full fix)** --- -### ~~Q-010-08: LdapConfiguration DTO Purpose~~ ✅ RESOLVED +### ~~Q-030-65: Face Overlay Toggle Key Binding Conflict — Does P Already Have a Mapping?~~ ✅ RESOLVED -**Decision:** Option A - LdapConfiguration validates/transforms .env values -**Rationale:** Clean validation layer providing type-safe value object. Single source of truth: .env → LdapConfiguration::fromEnv() validates → values passed to LdapRecord config. Prevents invalid config, provides testability. -**Updated in spec:** I1 LdapConfiguration DTO implementation, validation strategy +**Resolution:** **Option A** — Use `P`. Confirmed that `P` has no existing binding. `F` is mapped to fullscreen (`togglableStore.toggleFullScreen()` in `Album.vue`). `P` is free and is used for toggling face overlay visibility. Captured in NFR-030-11, FR-030-21, I24. ---- +**Context:** FR-030-21 specifies mapping the `P` key to toggle face overlay visibility. The existing Lychee photo viewer may already use `P` for another action (e.g., play slideshow, or some other shortcut). If there is a conflict, we need to choose a different key. -### ~~Q-010-07: LdapRecord Integration Strategy~~ ✅ RESOLVED +**Impact:** If `P` conflicts with an existing binding, the implementation would override existing behaviour. Affects I23 (keybinding setup), FaceOverlay.vue. -**Decision:** Option A - Service layer wrapping LdapRecord -**Rationale:** Better separation of concerns and testability. `LdapService` acts as facade/adapter over LdapRecord's Connection and query builder. Business logic abstracted from LDAP library details. Easier to test (mock LdapService interface) and swap libraries if needed. -**Updated in spec:** I2-I5 architecture, LdapService design as wrapper pattern +**Option A (Recommended) — Use `P` if available; otherwise use `F` or another unbound key** +- Check existing key bindings in the photo viewer. If `P` is free, use it. If not, fall back to `F` (for "Faces"). ---- +**Option B — Always use `F` for "Faces" regardless** +- Avoids any conflict risk, but `P` is more intuitive for "People". -### ~~Q-010-07: LdapRecord Integration Strategy~~ (ARCHIVED - moved above) +**Affects:** FR-030-21, FaceOverlay.vue, keybinding system. -**Question:** How should `App\Services\Auth\LdapService` integrate with LdapRecord? +**Resolved:** 2026-04-04 -- **Option A (Recommended):** Service layer wrapping LdapRecord - - Create `LdapService` as a facade/adapter over LdapRecord's `Connection`, `Model`, and query builder - - Business logic lives in `LdapService`, LDAP library details abstracted - - Easier testing (mock LdapService interface) - - Easier to swap LDAP libraries in future if needed - -- **Option B:** Direct LdapRecord usage throughout codebase - - AuthController and Actions call LdapRecord directly - - Less abstraction, fewer layers - - Tighter coupling to LdapRecord API - - Testing requires mocking LdapRecord classes +--- -**Pros/Cons:** -- **A:** Better separation of concerns, testability; adds abstraction layer -- **B:** Simpler, fewer files; harder to test, tight coupling +### ~~Q-030-66: Album People Endpoint — Recursive vs Direct Photos Only~~ ✅ RESOLVED -**Impact:** HIGH - affects architecture, testing strategy, and implementation complexity across all increments (I2-I5) +**Resolution:** **Option A** — Direct photos only (non-recursive). Consistent with existing bulk scan behaviour (Q-030-41). Sub-album people can be viewed by navigating to each sub-album. Captured in FR-030-22, API-030-25 (renamed from API-030-27). ---- +**Context:** FR-030-22 adds `GET /Album/{id}/people`. Should it include people from sub-album photos (recursive) or only direct photos in the album (joined via `photo_albums` where `album_id = ?`)? -### Q-010-08: LdapConfiguration DTO Purpose +**Impact:** Recursive requires either a CTE or pre-computing the album tree. Direct is simpler and consistent with how bulk scan works (non-recursive per Q-030-41). Affects API-030-25. -**Question:** What is the relationship between `App\DTO\LdapConfiguration` and LdapRecord's `config/ldap.php`? +**Option A (Recommended) — Direct photos only (non-recursive)** +- Consistent with bulk scan behaviour. Sub-album people can be viewed by navigating to each sub-album. +- Query is simpler and faster. -- **Option A (Recommended):** LdapConfiguration validates/transforms .env values - - `LdapConfiguration` is a validated value object created from .env variables - - Values are passed to LdapRecord's config at runtime - - Single source of truth: .env → LdapConfiguration → LdapRecord config - - Validation happens in DTO constructor - -- **Option B:** LdapConfiguration duplicates LdapRecord config - - Separate parallel configuration system - - Risk of config drift between two systems - - More complex synchronization +**Option B — Recursive through sub-albums** +- More comprehensive but potentially expensive for deep album trees. May require album path pre-computation. -**Pros/Cons:** -- **A:** Clean validation layer, no duplication; .env values must be transformed -- **B:** More flexible; potential sync issues, redundant config +**Affects:** FR-030-22, API-030-25, AlbumPeopleController. -**Impact:** MEDIUM - affects I1 configuration setup and validation strategy +**Resolved:** 2026-04-04 --- -### Q-010-09: Connection Pooling Implementation +### ~~Q-030-67: Batch Face Selection UX — Checkbox Overlay or Selection Mode Toggle?~~ ✅ RESOLVED -**Question:** What does "implement connection pooling logic" mean given LdapRecord already manages connections? +**Resolution:** **Option A** — Selection mode toggle. A "Select" button toggles selection mode; checkboxes appear on face crops only when active. Action bar slides in at the bottom. Captured in FR-030-19, UI-030-12. -- **Option A (Recommended):** Configure LdapRecord's built-in connection management - - Use LdapRecord's connection caching and reuse features - - Configure timeouts via LdapRecord config - - No custom pooling code needed - -- **Option B:** Build custom connection pool - - Implement connection reuse, timeout, retry logic manually - - More control over pool behavior - - Significant additional complexity +**Context:** FR-030-19 specifies batch face selection in person/cluster views. Should selection be always-on (checkboxes always visible) or require entering a "select mode" first (like file managers)? -**Pros/Cons:** -- **A:** Leverage existing, tested library feature; less code -- **B:** Full control; reinventing the wheel, higher maintenance +**Impact:** Affects the visual density and usability of the face grid in PersonDetail.vue and FaceClusters.vue. -**Impact:** MEDIUM - affects I2 implementation complexity and testing +**Option A (Recommended) — Selection mode toggle** +- A "Select" button toggles selection mode. When active, checkbox overlays appear on each face crop. Action bar slides in at the bottom. +- Cleaner default view; explicit mode transition. ---- +**Option B — Always-visible checkboxes** +- No mode switch needed; faster for power users. But clutters the UI. -### Q-010-10: Testing Strategy for LDAP Operations +**Affects:** FR-030-19, PersonDetail.vue, FaceClusters.vue. -**Question:** How should LDAP server responses be mocked for deterministic testing? +**Resolved:** 2026-04-04 -- **Option A (Recommended):** LdapRecord's testing utilities for unit tests + optional Docker for integration - - Use LdapRecord's `DirectoryEmulator` or test helpers for unit tests - - Mock LDAP responses at service boundary - - Optional: `rroemhild/test-openldap` Docker image for integration tests - -- **Option B:** Docker LDAP server for all tests - - Realistic LDAP server in test environment - - Slower test execution - - More complex CI setup - -- **Option C:** PHP mock/stub classes only - - Fastest execution - - May not catch library integration issues - - No LdapRecord-specific testing utilities +--- -**Pros/Cons:** -- **A:** Fast unit tests + realistic integration tests; best of both worlds -- **B:** Most realistic; slowest, most complex -- **C:** Simplest, fastest; least realistic +### ~~Q-030-68: Person Merge UI — Location and Target Selection~~ ✅ RESOLVED -**Impact:** MEDIUM - affects I2-I7 test implementation and CI configuration +**Resolution:** **Option A** — "Merge into..." button on PersonDetail page, opens modal with person search dropdown. Captured in FR-030-25, UI-030-13, MergePersonModal.vue. ---- +**Context:** FR-030-11 allows merging two Person records. The backend supports `POST /Person/{id}/merge` with `source_person_id` in body. Where should the merge UI live? How should the user select the target person? -### Q-010-11: Authentication Flow Sequence +**Impact:** Affects PersonDetail.vue, possible new MergePersonModal. -**Question:** What is the complete flow from username to group membership, including how userDn is obtained? +**Option A (Recommended) — "Merge into..." button on PersonDetail page, opens modal with person search dropdown** +- PersonDetail page has a "Merge" button. Clicking opens a modal with a person search dropdown (same component as assignment modal). User selects target person; confirms merge. -Need to clarify the sequence: -1. User submits username + password -2. How do we get the userDn? - - **Option A:** Search for user first (`LDAP_USER_FILTER`) → get DN → bind with DN + password - - **Option B:** Construct DN from username (e.g., `uid={username},ou=people,dc=example,dc=com`) → bind directly -3. After successful bind, query groups using userDn -4. Retrieve user attributes -5. Map groups to roles +**Option B — Drag-and-drop between person cards on People page** +- More visual but hard to discover and inaccessible. -**Recommended:** Option A (search-first pattern) for flexibility with diverse LDAP schemas +**Affects:** PersonDetail.vue, new MergePersonModal.vue. -**Impact:** HIGH - affects I2-I4 implementation, especially `bind()` and `getUserGroups()` method signatures +**Resolved:** 2026-04-04 --- -### Q-010-12: TLS/StartTLS Configuration Clarity +### ~~Q-030-69: Person Miniature Size and Layout for Same-Name Persons~~ ✅ RESOLVED -**Question:** Does `LDAP_USE_TLS=true` cover both LDAPS (port 636) and StartTLS (port 389), or do we need separate configuration? +**Resolution:** **Option A** — Compact layout: 24px circle + name + face count, with type-ahead filter already built into PrimeVue Select/Dropdown. Captured in FR-030-20, UI-030-09. -- **Option A (Recommended):** Single `LDAP_USE_TLS` flag, protocol determined by port - - `LDAP_USE_TLS=true` + `LDAP_PORT=636` → LDAPS (SSL/TLS from start) - - `LDAP_USE_TLS=true` + `LDAP_PORT=389` → StartTLS (upgrade connection) - - `LDAP_USE_TLS=false` → plaintext (dev only) - - Document both scenarios in .env.example - -- **Option B:** Separate flags for LDAPS and StartTLS - - `LDAP_USE_LDAPS=true` for port 636 - - `LDAP_USE_STARTTLS=true` for port 389 - - More explicit configuration - - More environment variables +**Context:** FR-030-20 adds circular miniatures in the face assignment dropdown. If there are many persons with the same name, the dropdown may become long and hard to navigate. -**Pros/Cons:** -- **A:** Simpler configuration, fewer env vars; requires clear documentation -- **B:** More explicit; more complex, more env vars +**Impact:** Minor UX concern. Affects FaceAssignmentModal.vue dropdown template. -**Impact:** MEDIUM - affects I1 configuration, I2 TLS implementation, and documentation +**Option A (Recommended) — Compact layout: 24px circle + name + face count, with type-ahead filter** +- The PrimeVue Dropdown already supports filtering. The miniature helps differentiate same-name entries visually. No special layout needed beyond the custom option template. ---- +**Option B — Group same-name persons with sub-labels (e.g., "John (142 photos)" vs "John (3 photos)")** +- Adds face count as disambiguator alongside the miniature. -### ~~Q-010-06: Configuration Method~~ ✅ RESOLVED +**Affects:** FR-030-20, FaceAssignmentModal.vue. -**Decision:** Option A - Environment variables only -**Rationale:** LDAP is an expert/power-user setting; .env configuration is appropriate and avoids database complexity. -**Updated in spec:** All configuration options use .env variables, NFR-010-01 +**Resolved:** 2026-04-04 --- -### ~~Q-010-05: Password Storage~~ ✅ RESOLVED +### ~~Q-030-70: CTRL+Click Dismiss on Touch Devices~~ ✅ RESOLVED -**Decision:** Option A - Don't store LDAP passwords -**Rationale:** Most secure approach; authenticate only against LDAP server without password duplication. -**Updated in spec:** FR-010-01, authentication flow, security model +**Resolution:** **Option B** — No touch shortcut. Dismiss only via the modal button. On touch devices (detected via `isTouchDevice()` from `keybindings-utils.ts`), the CTRL+click behaviour is not implemented. Touch users open the modal and click the "Dismiss" button. Captured in FR-030-16 (updated), UI-030-08 (desktop-only note). ---- +**Context:** FR-030-16 uses CTRL+click as a shortcut for face dismissal on overlays and in the detail panel. Touch devices (tablets, phones) don't have a CTRL key. -### ~~Q-010-04: User Attribute Mapping~~ ✅ RESOLVED +**Impact:** Touch users would have no shortcut for face dismissal and must use the modal button instead. -**Decision:** Option C - Defaults with optional override via .env -**Rationale:** Provides sensible defaults (uid→username, mail→email, displayName→display_name) with .env configuration for LDAP schemas that differ. -**Updated in spec:** FR-010-02, attribute mapping configuration +**Option A — Long-press on touch devices triggers dismiss** +- Long-press (500ms+) on a face overlay or face circle opens a context menu with "Dismiss" option. +- Alternatively, long-press directly dismisses (with undo toast). ---- +**Option B (Chosen) — No touch shortcut; dismiss only via modal** +- Simplest approach. Touch users open the modal and click the dismiss button. -### ~~Q-010-03: LDAP Group Mapping~~ ✅ RESOLVED +**Affects:** FR-030-16, FaceOverlay.vue, PhotoDetails.vue face circles. -**Decision:** Option B - Map LDAP groups to Lychee roles (admin/user) -**Rationale:** Allows admin role assignment via LDAP groups; provides automatic role sync without complex user group management. -**Updated in spec:** FR-010-03, role mapping configuration +**Resolved:** 2026-04-04 --- -### ~~Q-010-02: User Provisioning~~ ✅ RESOLVED +### ~~Q-030-71: Face Circles in Photo Detail Panel — Layout When Panel Is Narrow~~ ✅ RESOLVED -**Decision:** Option C - User provisioning configurable via .env -**Rationale:** Flexibility for different deployment scenarios; allows auto-create or pre-existing-only mode via configuration. -**Updated in spec:** FR-010-04, user provisioning behavior +**Resolution:** **Option A** — Horizontal scrollable row with overflow indicator. Flex row with `overflow-x: auto`. When faces exceed visible width, a "+N more" badge is shown; the row is scrollable to reveal all faces. Captured in FR-030-21, UI-030-10. ---- +**Context:** FR-030-21 adds circular face crops to the PhotoDetails sidebar. The sidebar is fixed at `w-95` (380px). If a photo has many faces (10+), the circles may overflow. -### ~~Q-010-01: LDAP Authentication Method~~ ✅ RESOLVED +**Impact:** Layout overflow or truncation for photos with many detected faces. -**Decision:** Option C - Both basic auth and LDAP independently configurable via .env -**Rationale:** Maximum flexibility; allows deployments to use LDAP-only, basic-only, or both. LDAP enablement controlled by .env variables. -**Updated in spec:** FR-010-05, authentication method selection +**Option A (Recommended) — Horizontal scrollable row with overflow indicator** +- Flex row with `overflow-x: auto`. Shows "+N more" indicator when faces overflow. +- Clicking "+N more" expands to a grid view. ---- +**Option B — Wrapping grid layout** +- Faces wrap to multiple rows. May push other detail sections down significantly. -### ~~Q-009-06: NULLS LAST Cross-Database Strategy~~ ✅ RESOLVED +**Affects:** FR-030-21, PhotoDetails.vue face section. -**Decision:** Simple indexed ORDER BY with COALESCE pattern for fastest performance -**Rationale:** User specified "fastest ordering possible with indexing." Using `COALESCE(rating_avg, -1) DESC` allows the query to use the index on `rating_avg` efficiently across all databases. Since ratings are always positive (1-5), -1 as sentinel value is safe and pushes NULLs to the end. -**Updated in spec:** FR-009-02, sorting strategy, SortingDecorator implementation +**Resolved:** 2026-04-04 --- -### ~~Q-009-01: Average Rating Storage Strategy~~ ✅ RESOLVED - -**Decision:** Option B - Add denormalized rating_avg column to photos table -**Rationale:** Fast indexed sorting with simple ORDER BY. Application logic will keep it in sync when ratings are updated (same transaction as rating_sum/rating_count updates). -**Updated in spec:** FR-009-01, DO-009-01, migration strategy +### ~~Q-030-72: Policy Refinement — Album/Photo Edit Rights~~ ✅ RESOLVED ---- +**Resolution:** ~~Option B — Defer~~ **→ Fully resolved 2026-04-11 by I39** (same resolution as Q-030-63). `PhotoPolicy` and `AlbumPolicy` now provide per-resource face gate methods that check the concrete photo/album owner. All face-related request authorizers are wired to these new gates. See FR-030-43 through FR-030-47 and I39 in `plan.md`. -### ~~Q-009-02: Rating Smart Album Threshold Logic~~ ✅ RESOLVED +**Context:** The current `AiVisionPolicy` checks the global `ai_vision_face_permission_mode` but does not cross-reference the user's actual edit rights on the specific album or photo. In `privacy-preserving` and `restricted` modes, "photo/album owner" should mean the owner of that specific resource, but the current implementation may check ownership globally. -**Decision:** Option C - Hybrid (threshold for 3★+, exact for 1★-2★) -**Rationale:** Matches user's explicit statement that "3_stars album will contain all photos rated 3 stars or above." Low ratings (1★, 2★) use exact buckets so photos only appear in one album; high ratings (3★+) use threshold for cumulative view. -**Updated in spec:** FR-009-03 through FR-009-08, smart album filtering logic +**Impact:** High — could allow users to assign/dismiss faces on photos they don't own. Affects all face operations gated on "photo/album owner + admin". ---- +**Option A (Chosen) — Add album/photo ownership checks to policy methods** +- `PhotoPolicy` gains `CAN_VIEW_FACE_OVERLAYS`, `CAN_DISMISS_FACE`, `CAN_ASSIGN_FACE_ON_PHOTO`, `CAN_TRIGGER_SCAN_ON_PHOTO` methods taking `(?User, Photo)`. +- `AlbumPolicy` gains `CAN_VIEW_ALBUM_PEOPLE`, `CAN_TRIGGER_SCAN_ON_ALBUM`, `CAN_ASSIGN_FACE_IN_ALBUM`, `CAN_BATCH_FACE_OPS` methods taking `(?User, AbstractAlbum|null)`. +- All face request authorizers updated to use these per-resource gates. -### ~~Q-009-03: Best Pictures Cutoff Behavior~~ ✅ RESOLVED +**Affects:** AiVisionPolicy, PhotoPolicy, AlbumPolicy, PhotoRightsResource, AlbumRightsResource, all face-related request classes. -**Decision:** Option B - Top N by rating, include ties -**Rationale:** Fair behavior that doesn't arbitrarily exclude photos with the same rating as the Nth photo. May show more than N photos if ties exist, but ensures no photo is unfairly excluded. -**Updated in spec:** FR-009-09, Best Pictures smart album logic +**Resolved:** 2026-04-04 (initially deferred); **re-resolved 2026-04-11 (I39 implements full fix)** --- -### ~~Q-009-04: Smart Album Sorting Default~~ ✅ RESOLVED +### ~~Q-030-73: Reset Face Scan Status Maintenance Blocks — Separate or Combined?~~ ✅ RESOLVED -**Decision:** Custom - Rating smart albums and Best Pictures sorted by rating DESC -**Rationale:** Shows highest-rated photos first, which is the natural expectation for rating-based albums. -**Updated in spec:** FR-009-10, NFR-009-03 +**Resolution:** **Option A with grouping** — Group stuck-pending and failed resets into a **single** combined maintenance block, distinct from the "Destroy Dismissed Faces" block. The final UI has exactly two face maintenance action blocks: (1) "Destroy Dismissed Faces" and (2) "Reset Face Scan Status" (handles both stuck-pending and failed). The existing `Maintenance::resetStuckFaces` backend endpoint remains available for CLI use but no longer has a dedicated UI card. Captured in FR-030-24 (updated), API-030-22/22b (renamed to `resetFaceScanStatus`), UI-030-15. ---- +**Context:** Q-030-55 resolution requires maintenance blocks for: (a) destroying dismissed faces, (b) resetting stuck-pending scans, (c) resetting failed scans. Should these be three separate maintenance cards or combined into fewer? -### ~~Q-008-01: User Preference Storage Location~~ ✅ RESOLVED +**Impact:** Affects Maintenance.vue layout and number of maintenance controllers. -**Decision:** Option A - New column in users table -**Rationale:** Follows existing Lychee pattern (user attributes in users table), simple implementation with single query, no new tables needed. -**Updated in spec:** FR-008-02, COL-008-01, migration strategy +**Option A with grouping (Chosen) — Two conditional blocks: dismiss cleanup + combined reset stuck/failed** +- Block 1: `MaintenanceDestroyDismissedFaces.vue` — destroys dismissed faces (count > 0 to show). +- Block 2: `MaintenanceResetFaceScanStatus.vue` — combined reset of stuck-pending (>720 min) AND failed scans. + - check: `count_stuck + count_failed`; hidden when 0 + - do: resets both `PENDING` (older than 720 min) and `FAILED` photos to `null` ---- +**Option B — Three separate conditional blocks** +- Each block independently checks its count and hides when zero. Clear, granular control. -### ~~Q-008-02: Smart Albums in Tabbed View~~ ✅ RESOLVED +**Affects:** FR-030-24, Maintenance.vue, API-030-22/22b, new `ResetFaceScanStatus.php` controller. -**Decision:** Option D - Show above tabs (outside tab context) -**Rationale:** Smart albums span all content (photos from both owned and shared albums), so they should be displayed above the tab bar and remain always visible regardless of selected tab. -**Updated in spec:** UI mockups, FR-008-06, FR-008-07 +**Resolved:** 2026-04-04 ---- +### ~~Q-030-74: PersonDetail Lightbox Navigation Strategy~~ ✅ RESOLVED -### ~~Q-008-03: Tab Visibility When Empty~~ ✅ RESOLVED +**Feature:** 030 – AI Vision Service +**Priority:** High +**Status:** Resolved +**Opened:** 2026-04-07 +**Affects:** T-030-85 (I35), FR-030-39 -**Decision:** Option A - Hide empty tabs -**Rationale:** Cleaner UX - if "Shared with Me" has no albums, don't show tab bar at all (behave like SHOW mode). Simpler for users with no shared albums. -**Updated in spec:** S-008-08, UI-008-02 +**Resolution:** **Option A (server-side)** — `GET /Person/{id}/photos` computes and includes `next_photo_id` and `previous_photo_id` on each `PhotoResource`, ordered sequentially by collection position (access-filtered). First photo: `previous_photo_id = null`; last photo: `next_photo_id = null`. `PhotoPanel.vue` then uses these person-relative IDs natively for navigation within the person's collection. No client-side monkey-patching required. ---- +**Spec Impact:** Updated FR-030-03 success path to note that each `PhotoResource` includes `next_photo_id`/`previous_photo_id` relative to the person's collection. Updated FR-030-39 success path. Updated S-030-55 scenario. Updated plan.md I12 steps and exit criteria. Updated T-030-32 (test for next/previous IDs), T-030-33 (implementation), T-030-85 (lightbox navigation note). + +**Resolved:** 2026-04-07 --- -### ~~Q-007-01: Pagination Strategy (Offset vs Cursor) and Page Size Configuration~~ ✅ RESOLVED +### ~~Q-030-75: FaceCluster Detail View — Dialog vs. Sub-Route~~ ✅ RESOLVED -**Decision:** Option A - Offset-based pagination with config table page size -**Rationale:** Simple Laravel pagination pattern with standard LIMIT/OFFSET, easy navigation to specific pages, admin-configurable page sizes via config table. Performance acceptable for expected album sizes. -**Updated in spec:** FR-007-01 through FR-007-06, NFR-007-01, NFR-007-05, DO-007-01 +**Feature:** 030 – AI Vision Service +**Priority:** Medium +**Status:** Resolved +**Opened:** 2026-04-07 +**Affects:** T-030-78 (I32), FR-030-29 ---- +**Resolution:** **Option A** — PrimeVue ``. Clicking a cluster card opens a Dialog that fetches all faces via `GET /FaceDetection/clusters/{cluster_id}/faces`. URL does not change. No routing changes needed. -### ~~Q-007-02: API Endpoint Design (New Endpoints vs Modify Existing)~~ ✅ RESOLVED +**Spec Impact:** Updated FR-030-29 requirement and success path to specify PrimeVue Dialog. Updated plan.md I32 step 3. Updated T-030-78 intent (Dialog only, sub-route option removed). -**Decision:** Option B - New paginated endpoints (`/Album/{id}/head`, `/Album/{id}/albums`, `/Album/{id}/photos`) -**Rationale:** Clear separation of concerns, existing `/Album` endpoint unchanged for backward compatibility (avoiding test changes), consistent response structure per endpoint. Code duplication acceptable to minimize refactoring risk. -**Updated in spec:** FR-007-01, FR-007-02, FR-007-03, FR-007-12, NFR-007-04, NFR-007-06, API-007-01 through API-007-05 +**Resolved:** 2026-04-07 --- -### ~~Q-007-03: Frontend Loading Strategy (Load-More vs Page Navigation)~~ ✅ RESOLVED +### ~~Q-030-76: People.vue Context Menu "Assign to User" Action~~ ✅ RESOLVED -**Decision:** Configurable with infinite scroll as default -**Rationale:** User specified configurable UI modes: "infinite_scroll" (default), "load_more_button", "page_navigation". Infinite scroll provides smoothest UX for photo galleries. First page always loaded automatically, subsequent pages on demand based on UI mode. -**Updated in spec:** FR-007-07, FR-007-08, FR-007-09, FR-007-10, DO-007-02, UI mockups +**Feature:** 030 – AI Vision Service +**Priority:** Medium +**Status:** Resolved +**Opened:** 2026-04-07 +**Affects:** T-030-79 (I33), FR-030-32, FR-030-05 ---- +**Resolution:** **Option A** — User-picker dialog. "Assign to user" (admin-only) opens a PrimeVue `` with an autocomplete Dropdown listing user accounts (name + email). On confirm, calls `PATCH /Person/{id}` with `{ user_id: selectedUserId }`. Requires extending `UpdatePersonRequest` to accept nullable `user_id` with an admin-only validation gate. -### ~~Q-007-04: Config Key Naming and Default Values~~ ✅ RESOLVED +**Spec Impact:** Updated FR-030-32 success path to describe the user-picker dialog and `PATCH /Person/{id}` with `user_id`. Updated plan.md I33 step 1. Updated T-030-79 intent (user-picker dialog, UpdatePersonRequest extension noted). -**Decision:** Option C - Multiple granular configs -**Rationale:** User specified: `albums_per_page` (default 30), `photos_per_page` (default 100), Flexible tuning for different resource types with appropriate defaults based on typical usage patterns. -**Updated in spec:** FR-007-06, NFR-007-05, DO-007-01 +**Resolved:** 2026-04-07 --- -### ~~Q-007-05: Refactoring Scope (Extract Album/Photo Fetching Logic)~~ ✅ RESOLVED - -**Decision:** Option B - Repository pattern methods, code duplication acceptable -**Rationale:** User directive to avoid extensive refactoring, prioritize backward compatibility and minimal test changes. New endpoints can duplicate logic from existing implementation. Repository pattern methods for data access without extracting to separate service classes. -**Updated in spec:** NFR-007-06, Goals section, Non-Goals section +### ~~Q-030-77: Admin/Feature Short-Circuit Mechanism in PhotoPolicy and AlbumPolicy Face Gates~~ ✅ RESOLVED ---- +**Feature:** 030 – AI Vision Service +**Priority:** High +**Status:** Resolved +**Opened:** 2026-04-11 +**Affects:** T-030-100 (PhotoPolicy), T-030-101 (AlbumPolicy), FR-030-43, FR-030-44 -### ~~Q-007-06: Backward Compatibility Strategy for Existing Clients~~ ✅ RESOLVED +**Resolution:** FR-030-43/44 wording about `AiVisionPolicy::before()` is incorrect and is removed. The new `PhotoPolicy` and `AlbumPolicy` face gate methods rely on those policies' own `before()` hooks for the admin short-circuit. Side-effect: admins will bypass the gate even when AI Vision is disabled — **accepted risk**. No inline duplication or shared trait needed. -**Decision:** New endpoints default page=1, existing `/Album` endpoint unchanged -**Rationale:** User specified creating new endpoints only. Legacy `/Album?album_id=X` endpoint remains unchanged returning full data. New endpoints (`/Album/{id}/albums`, `/Album/{id}/photos`) default to page 1 if `?page=` parameter absent (not "return all"). -**Updated in spec:** FR-007-11, FR-007-12, API-007-02, API-007-03, API-007-04 +**Resolved:** 2026-04-11 --- -### ~~Q-006-01: Filter UI Control Design and Interaction Pattern~~ ✅ RESOLVED - -**Decision:** Option D - Hover star list with minimum threshold filtering and toggle-off -**Rationale:** User specified custom interaction: Display 5 hoverable stars. Empty stars = no filtering. Click on star N = show photos with rating ≥ N (minimum threshold). Click same star again = remove filtering. Combines visual clarity of inline stars with flexible threshold filtering. -**Updated in spec:** FR-006-01, FR-006-02, FR-006-03, UI mockup section +### ~~Q-030-78: "Album Access" Verification in Public/Private Mode for Face Gates~~ ✅ RESOLVED ---- +**Feature:** 030 – AI Vision Service +**Priority:** High +**Status:** Resolved +**Opened:** 2026-04-11 +**Affects:** T-030-100 (PhotoPolicy), T-030-101 (AlbumPolicy), FR-030-43, FR-030-44 -### ~~Q-006-02: Filter Behavior for Unrated Photos~~ ✅ RESOLVED +**Resolution:** Non-issue. There is no circular dependency — `AlbumPolicy::canViewAlbumPeople()` can call `$this->canAccess($user, $album)` directly on the same policy instance without going through `Gate::check()`. No proxy or workaround needed. -**Decision:** Addressed by Q-006-01 decision -**Rationale:** Minimum threshold filtering (≥ N stars) inherently excludes unrated photos (which have no rating value). Empty stars (no filter) shows all photos including unrated. -**Updated in spec:** FR-006-02, filtering logic section +**Resolved:** 2026-04-11 --- -### ~~Q-006-03: Filter State Persistence Strategy~~ ✅ RESOLVED - -**Decision:** Custom - State store persistence (like NSFW visibility) -**Rationale:** User specified to keep selection in state store, similar to existing NSFW visibility pattern. State persists during session but managed by Pinia store, not localStorage (follows existing Lychee patterns for view state). -**Updated in spec:** FR-006-04, NFR-006-01 +### ~~Q-030-79: BatchFaceRequest Album Context and ScanPhotosRequest Photo-Path Authorization~~ ✅ RESOLVED ---- +**Feature:** 030 – AI Vision Service +**Priority:** Medium +**Status:** Resolved +**Opened:** 2026-04-11 +**Affects:** T-030-103, FR-030-47 -### ~~Q-006-04: Multi-Rating Filter Support (AND vs OR)~~ ✅ RESOLVED +**Resolution:** Option A for both cases — check ownership of the concrete **photo** when no album is available. `BatchFaceRequest`: when `album_id` is null (no album context), check `Gate::check(PhotoPolicy::CAN_ASSIGN_FACE_ON_PHOTO, $face->photo)` for each resolved face. `ScanPhotosRequest` photo-only path: check `Gate::check(PhotoPolicy::CAN_TRIGGER_SCAN_ON_PHOTO, $photo)` for each photo. FR-030-47 (c) and (d) updated accordingly. -**Decision:** Option C - Range filter (minimum threshold) as explained in Q-006-01 -**Rationale:** User clarified in Q-006-01 that clicking star N shows photos with rating ≥ N (3+ stars shows 3, 4, 5 star photos). Simple single-selection UI with flexible filtering capability. -**Updated in spec:** FR-006-01, FR-006-02, filtering algorithm section +**Resolved:** 2026-04-11 --- -### ~~Q-005-01: List View Layout Structure and Information Display~~ ✅ RESOLVED - -**Decision:** Option A - Windows Details View Pattern -**Rationale:** Familiar file manager pattern with horizontal row layout: `[Thumb 64px] [Album Name - Full] [X photos] [Y sub-albums]`. Scannable, information-dense, shows full untruncated album names. -**Updated in spec:** FR-005-01, FR-005-02, UI mockup section +### ~~Q-029-01: Destination album for camera capture from root view~~ ✅ RESOLVED ---- +**Question:** When the user takes a photo from the root albums view (not inside any album), where should the captured photo be stored? -### ~~Q-005-02: Toggle Control Placement and Styling~~ ✅ RESOLVED +**Resolution:** Upload with no album ID — photo lands in the "Unsorted" smart album, consistent with existing upload behaviour at root level. -**Decision:** Custom - AlbumHero.vue icon row (same line as statistics/download toggles) -**Rationale:** User specified placement on the same line as the statistics and download toggle buttons in AlbumHero.vue (line 33, flex-row-reverse container). Follows existing icon pattern with px-3 spacing and hover animations. -**Updated in spec:** FR-005-03, UI implementation section +**Resolved:** 2026-03-18 --- -### ~~Q-005-03: View Preference Persistence Strategy~~ ✅ RESOLVED +### ~~Q-027-01: Colour Distance Metric and Named-Colour Lookup~~ ✅ RESOLVED -**Decision:** Option B - LocalStorage/session-only (no backend) -**Rationale:** Simple implementation, no backend changes needed, fast toggle response. User preference stored in browser localStorage per-device. -**Updated in spec:** FR-005-04, NFR-005-01 +**Decision:** `palette.colour_N` values are foreign keys to `colours.id` (the packed 0xRRGGBB integer); the `colours` table already has separate `R`, `G`, `B` integer columns. Use a JOIN `palette → colours ON colours.id IN (p.colour_1, …, p.colour_5)` and compute Manhattan distance directly on `colours.R/G/B`. No schema migration required. Named colours resolved via `Colour::fromHex()` / `colours` table lookup. +**Rationale:** The separate R/G/B columns are already present in the DB; no bit-shift needed, fully portable across SQLite/MySQL/PostgreSQL. +**Updated in spec:** FR-027-09 (colour query mechanism), NFR-027-04 (SQL portability note updated). --- -### ~~Q-003-09: Multi-user Cover Selection Strategy for computed_cover_id~~ ✅ RESOLVED +### ~~Q-027-02: Rating Filter — Own Rating vs Average Rating~~ ✅ RESOLVED -**Decision:** Option D - Store dual cover IDs with privilege-based selection (`auto_cover_id_max_privilege` and `auto_cover_id_least_privilege`) -**Rationale:** Balances performance (pre-computation) with security (no photo leakage). Two cover IDs stored per album: one for admin/owner view (max privilege), one for public view (least privilege). Display logic selects appropriate cover based on user permissions at query time (simple column read, no subquery). Simple schema (2 columns vs. per-user table), guaranteed safe (least-privilege cover never leaks private photos), good UX (admin/owner sees best possible cover). -**Updated in spec:** FR-003-01, FR-003-02, FR-003-04, FR-003-07, NFR-003-05, DO-003-03, DO-003-04, Migration Strategy, Cover Selection Logic appendix -**ADR:** ADR-0003-album-computed-fields-precomputation.md (to be updated with Q-003-09 resolution) +**Decision:** Option C — Support both sub-modifier forms: `rating:avg:>=4` (filters by `photos.rating_avg`) and `rating:own:>=4` (filters by the requesting user's own rating via JOIN on `photo_ratings WHERE user_id = Auth::id()`). Unauthenticated users may only use `rating:avg:`. +**Rationale:** Maximum flexibility; users with personal rating habits benefit from `own:` while gallery visitors can still filter by average. +**Updated in spec:** FR-027-14 (rating sub-modifiers), grammar reference updated, scenarios S-027-21/S-027-22 added. --- -### ~~Q-003-01: Recomputation Job Queue Priority~~ ✅ RESOLVED +### ~~Q-027-03: Album Search Modifier Support — This Feature or Follow-up?~~ ✅ RESOLVED -**Decision:** Option A - Use default queue, rely on worker scaling -**Rationale:** Simpler configuration, standard Laravel pattern, natural backpressure signaling. Operators scale worker count to meet 30-second consistency target. -**Updated in spec:** FR-003-02, JOB-003-01 +**Decision:** Option B — Include album modifier support (`title:`, `description:`, `date:`) in Feature 027. `AlbumSearch` is wired to the same `SearchTokenParser`; a new `AlbumSearchTokenStrategy` interface mirrors `PhotoSearchTokenStrategy`. +**Rationale:** Consistent user experience in a single release; the token infrastructure from the photo search is directly reusable. +**Updated in spec:** FR-027-15 (album modifiers), Non-Goals updated (album modifiers removed), scenarios S-027-23/S-027-24 added. --- -### ~~Q-003-02: Backfill Execution Strategy During Migration~~ ✅ RESOLVED +### ~~Q-027-04: Named-Colour Name→Hex Mapping Mechanism~~ ✅ RESOLVED -**Decision:** Option A - Manual trigger after migration (with `lychee:` prefix requirement) -**Rationale:** Operator controls timing during maintenance window, migration completes quickly, aligns with dual-read fallback pattern. All Lychee commands use `lychee:` namespace. -**Updated in spec:** FR-003-06, CLI-003-01, Migration Strategy appendix -**ADR:** ADR-0003-album-computed-fields-precomputation.md +**Decision:** Option A — Hardcode a PHP `ColourNameMap` class (e.g. `app/Actions/Search/ColourNameMap.php`) containing a `const` array mapping lowercase CSS colour names to `#rrggbb` hex strings, covering the 16 basic CSS Level 1 colours. `ColourStrategy` consults this map when the token value does not start with `#`. Unknown names throw `InvalidTokenException` → HTTP 400. No schema migration required. +**Rationale:** No DB dependency; stateless; testable in isolation; fast. The `colours` table has no `name` column and `Colour::fromHex()` only accepts hex strings, so a hardcoded PHP map is the only viable no-migration path. +**Updated in spec:** FR-027-09 (named-colour resolution description updated), T-027-03 and T-027-22 notes updated. --- -### ~~Q-003-03: Concurrent Album Mutation Deduplication~~ ✅ RESOLVED - -**Decision:** Option A - Laravel WithoutOverlapping middleware -**Rationale:** Built-in Laravel feature (same as Feature 002 Q-002-03), prevents wasted work, automatic lock release, simple implementation. -**Updated in spec:** FR-003-02, JOB-003-01 -**ADR:** ADR-0003-album-computed-fields-precomputation.md +### ~~Q-027-05: Invalid SQL Syntax in Colour-Similarity EXISTS Subquery~~ ✅ RESOLVED ---- +**Decision:** Option A — Replace the invalid `JOIN … ON c.id IN (…)` with an explicit OR expansion in the `ON` clause: -### ~~Q-003-04: Cover Selection Race Condition Handling~~ ✅ RESOLVED +```sql +EXISTS ( + SELECT 1 FROM palette p + JOIN colours c ON (c.id = p.colour_1 OR c.id = p.colour_2 OR c.id = p.colour_3 OR c.id = p.colour_4 OR c.id = p.colour_5) + WHERE p.photo_id = photos.id + AND ABS(c.R - :R) + ABS(c.G - :G) + ABS(c.B - :B) <= :dist +) +``` -**Decision:** Option A - Foreign key ON DELETE SET NULL (already in spec) -**Rationale:** Database handles automatically, simple, eventual consistency. Photo deletion events trigger recomputation for parent albums. -**Updated in spec:** FR-003-02 (added photo deletion event trigger), Migration Strategy appendix (FK constraint confirmed) +**Rationale:** Standard SQL valid across SQLite, MySQL, and PostgreSQL. Within an `EXISTS` the five-OR join is harmless — multiple matching `colours` rows per palette row are irrelevant since `EXISTS` short-circuits on the first match. +**Updated in spec:** FR-027-09, NFR-027-04 (both SQL snippets corrected); plan.md I7; tasks.md T-027-22. --- -### ~~Q-003-05: Propagation Chain Failure Handling~~ ✅ RESOLVED +### ~~Q-026-01: TagAlbum and Smart Album Support Scope~~ ✅ RESOLVED -**Decision:** Option A - Stop propagation, log error, manual recovery -**Rationale:** Prevents cascading errors, clear failure boundary, operator can investigate root cause before retrying via `lychee:recompute-album-stats`. -**Updated in spec:** FR-003-02, CLI-003-02 -**ADR:** ADR-0003-album-computed-fields-precomputation.md +**Question:** Should TagAlbums and Smart Albums support tag filtering in the future, or is "only regular Albums" a permanent architectural decision? ---- +**Resolution:** Tag filtering applies to **all album types** (regular Albums, TagAlbums, and Smart Albums) in v1. -### ~~Q-003-06: Soft-Deleted Photo Exclusion from Computations~~ ✅ RESOLVED +**Rationale:** User specified "This is for all albums: regular, tags, smart." The feature should provide consistent filtering UX across all album types. -**Decision:** N/A - Lychee does not use soft deletes -**Rationale:** Per user clarification, Lychee does not implement soft delete pattern for photos. Hard deletes only. -**Updated in spec:** FR-003-02 (removed soft-delete references) +**Spec Impact:** Remove "Filtering TagAlbums or Smart Albums" from Non-Goals; update FR-026-01 to clarify support for all album types; add test scenarios for TagAlbum and SmartAlbum filtering. + +**Resolved:** 2026-03-09 --- -### ~~Q-003-07: NULL taken_at Handling in Min/Max Calculations~~ ✅ RESOLVED +### ~~Q-026-02: Large Tag List UX Strategy (100+ Tags)~~ ✅ RESOLVED -**Decision:** Option A - Ignore NULL taken_at, use SQL MIN/MAX directly -**Rationale:** Mirrors existing AlbumBuilder.php behavior (lines 111, 125). SQL MIN/MAX ignores NULLs by default. Semantically correct (taken_at unknown = exclude from range). -**Updated in spec:** FR-003-02 validation path +**Question:** How should the tag filter UI handle albums with 100+ unique tags (beyond the spec's "up to 20 unique tags" performance target)? ---- +**Resolution:** **Option B** - Add search/filter to tag dropdown in v1 (enable PrimeVue MultiSelect `filter` prop). -### ~~Q-003-08: Migration Rollback Strategy for Multi-Phase Deployment~~ ✅ RESOLVED +**Rationale:** PrimeVue MultiSelect has built-in filter capability; minimal implementation effort for better UX. -**Decision:** Option B - Full rollback with down() migration -**Rationale:** Clean schema restoration, simple one-command rollback. Trade-off: data loss if backfill ran, but values can be regenerated. Critical constraint: do NOT rollback after Phase 4 cleanup. -**Updated in spec:** FR-003-06, Migration Strategy appendix (new Rollback Strategy section) -**ADR:** ADR-0003-album-computed-fields-precomputation.md +**Spec Impact:** Update NFR-026-02 (Usability) to note that tag dropdown includes search/filter for large tag lists. + +**Resolved:** 2026-03-09 --- -### ~~Q-002-01: Worker Auto-Restart Queue Priority~~ ✅ RESOLVED +### ~~Q-026-03: URL-based Filter State Representation~~ ✅ RESOLVED -**Decision:** Option A - Support multiple queue workers with priority via QUEUE_NAMES environment variable -**Rationale:** Allows time-sensitive jobs to be prioritized, standard Laravel pattern, operator flexibility. -**Updated in spec:** FR-002-02, DO-002-02, CLI-002-01, Spec DSL, Queue Connection Configuration appendix +**Question:** Should the active tag filter be represented in the URL query string (e.g., `/gallery/album-id?tag_ids=1,2&tag_logic=OR`) to enable bookmarking and sharing, or should it remain in component state only? ---- +**Resolution:** **Option A** - Component state only; no URL representation in v1. -### ~~Q-002-02: Worker Max-Time Configurability~~ ✅ RESOLVED +**Rationale:** Simpler implementation for v1. Filter state stored in component `ref()` without Vue Router query param synchronization. Users cannot bookmark/share filtered views (accepted limitation). -**Decision:** Option A - Configurable with sensible default via WORKER_MAX_TIME environment variable -**Rationale:** Operators can tune for their workload, no code changes needed to adjust restart interval. -**Updated in spec:** FR-002-02, DO-002-03, CLI-002-01, Spec DSL, Queue Connection Configuration appendix +**Spec Impact:** Non-Goals already documents this; no change needed. + +**Resolved:** 2026-03-09 --- -### ~~Q-002-03: Job Deduplication for Concurrent Mutations~~ ✅ RESOLVED +### ~~Q-026-04: Album::tags Security Filtering Approach~~ ✅ RESOLVED -**Decision:** Option A - Laravel job middleware with deduplication using WithoutOverlapping -**Rationale:** Built-in Laravel feature, prevents wasted work, automatic lock release. -**Updated in spec:** NFR-002-05, Documentation Deliverables +**Question:** For the `Album::tags` endpoint, should it apply per-photo security filters when fetching tags (e.g., only include tags from public photos when viewing as guest), or rely solely on album-level access check? ---- +**Resolution:** **Album-level access only** (Option A). Album::tags returns tags from photos directly attached to that album. Album-level access rights determine which photos are accessible, and thus which tags should be returned. -### ~~Q-002-04: Worker Healthcheck Failure Behavior~~ ✅ RESOLVED +**Rationale:** User clarified: "Album::tags should return the list of tags which are associated to the photos directly attached to that album. The access rights on the album_id determine directly what photos are accessible, thus which tags should be returned." -**Decision:** Option B - Healthcheck tracks restart count, fail after 10 restarts in 5 minutes -**Rationale:** Orchestrator can restart container if worker is fundamentally broken, prevents infinite crash loops. -**Updated in spec:** FR-002-05 +**Spec Impact:** Clarify FR-026-01 to explicitly state album-level access model; no per-photo filtering required. + +**Resolved:** 2026-03-09 --- -### ~~Q001-07: Statistics Record Creation Strategy~~ ✅ RESOLVED +### ~~Q-026-05: Behavior When All Tag IDs Are Invalid~~ ✅ RESOLVED -**Decision:** Option A - firstOrCreate in transaction -**Rationale:** Atomic operation with no race conditions, Laravel handles duplicate creation attempts automatically, simple implementation. -**Updated in spec:** Implementation plan I5 +**Question:** When a user provides tag IDs via `tag_ids[]` parameter and ALL of them are invalid (don't exist in database), should the endpoint return all photos (treating invalid IDs as "no filter") or an empty result? ---- +**Resolution:** **Option C** - Return validation error (422 Unprocessable Entity) when all tag IDs are invalid. -### ~~Q001-08: Transaction Rollback Error Handling~~ ✅ RESOLVED +**Rationale:** Clear feedback to client that the request was invalid. Individual invalid IDs are still silently ignored, but if the entire filter set is invalid, return error. -**Decision:** Option B - 409 Conflict for transaction errors -**Rationale:** More semantic HTTP status, indicates temporary issue that suggests retry, clearer to frontend. -**Updated in spec:** Implementation plan I5, I10 +**Spec Impact:** Update FR-026-02 to clarify: "Invalid tag IDs individually ignored; if ALL provided tag IDs are invalid, return 422 validation error." + +**Resolved:** 2026-03-09 +### ~~Q-023-01: Remember-me Cookie Duration and Admin Configurability~~ ✅ RESOLVED + +**Decision:** Option C — Use a shorter default (4 weeks) with env override +**Rationale:** A 4-week (40320 minutes) default is more security-conscious than Laravel's ~5-year default while still being practical for home/personal instances. The duration is configurable via `REMEMBER_LIFETIME` env variable, loaded by `config/auth.php` in the lychee guard config (`'remember' => (int) env('REMEMBER_LIFETIME', 40320)`). The existing `SessionOrTokenGuard::createGuard()` already reads this key via `setRememberDuration()`. No admin UI control — env/config only. +**Updated in spec:** Non-Goals (clarified no admin UI for duration), NFR-023-01 (cookie duration = 4 weeks default) --- -### ~~Q001-09: N+1 Query Performance for user_rating~~ ✅ RESOLVED +### ~~Q-020-01: RAW Conversion Failure Behavior~~ ✅ RESOLVED -**Decision:** Option A - Eager load with closure in controller -**Rationale:** Standard Laravel pattern, single additional query for all photos, no global scope side effects. -**Updated in spec:** Implementation plan I6 +**Decision:** Option C — Fall back to existing `raw_formats` behavior (store unprocessed, no conversion) +**Rationale:** Graceful degradation preserves the uploaded file. If Imagick cannot convert the RAW file, it is stored as-is using the existing accepted-raw path (the raw file becomes the ORIGINAL with no thumbnails). Additionally, a data migration will move existing files that are currently stored as ORIGINAL but match raw format extensions to the new RAW size variant type. +**Updated in spec:** FR-020-03 (failure path), FR-020-16 (migration of existing raw-format files from ORIGINAL to RAW type) --- -### ~~Q001-10: Concurrent Update Debouncing (Rapid Clicks)~~ ✅ RESOLVED +### ~~Q-020-02: RAW Conversion Tooling & Imagick Delegate Requirements~~ ✅ RESOLVED -**Decision:** Option A - Disable stars during API call -**Rationale:** Simple implementation, prevents concurrent requests, clear visual feedback with loading state. -**Updated in spec:** Implementation plan I8, I9a, I9c +**Decision:** Option A — Require Imagick with libraw/dcraw delegates; document system requirements +**Rationale:** Single code path through Imagick. Existing `HeifToJpeg` already uses Imagick. System requirement: `apt install libraw-dev` (or equivalent) for camera RAW delegate support. If a specific format is unsupported by the installed Imagick delegates, the fallback from Q-020-01 applies (file stored as-is). +**Updated in spec:** NFR-020-04 (Imagick requirement), FR-020-09 (conversion tooling) --- -### ~~Q001-11: Metrics Disabled Behavior (Can Still Rate?)~~ ✅ RESOLVED +### ~~Q-020-03: Async Conversion for Large RAW Files~~ ✅ RESOLVED -**Decision:** Option C - Admin setting controls independently -**Rationale:** Granular control allows enabling rating without showing aggregates, future-proof configuration. -**Updated in spec:** New config setting needed (separate `ratings_enabled` from `metrics_enabled`) +**Decision:** Option A — Synchronous conversion (already async via job pipeline) +**Rationale:** Lychee already processes uploads through queued jobs, so conversion is inherently asynchronous from the user's perspective. No additional async infrastructure is needed. The conversion runs within the existing job pipeline. +**Updated in spec:** NFR-020-02 (clarified: conversion happens in existing job pipeline) --- -### ~~Q001-12: Rating Display When Metrics Disabled~~ ✅ RESOLVED +### ~~Q-020-04: Interaction with Existing `raw_formats` Config~~ ✅ RESOLVED -**Decision:** Option B - Hide all rating data when metrics disabled -**Rationale:** Fully consistent with metrics disabled setting, simplest implementation, respects admin preference. -**Updated in spec:** UI components conditional rendering +**Decision:** Option A — Keep both systems separate, with refinement +**Rationale:** The `raw_formats` config continues to define accepted extra formats. However, files matching `raw_formats` are now stored as **RAW size variants** (not ORIGINAL) — unless they are PDF, which remains stored as ORIGINAL (since PDF can be rendered/displayed). The new convertible-RAW pipeline (camera RAW + HEIC/HEIF) is a separate hardcoded list that triggers conversion. If an extension is in both lists, the new RAW pipeline takes precedence. +**Updated in spec:** FR-020-03, FR-020-04, FR-020-09, FR-020-16 (unprocessed raw_formats files stored as RAW type, PDF exception) --- -### ~~Q001-13: Half-Star Display for Fractional Averages~~ ✅ RESOLVED +### ~~Q-019-01: Hierarchical vs Flat Slugs~~ ✅ RESOLVED -**Decision:** Option B - Half-star display using PrimeVue icons -**Rationale:** PrimeVue provides pi-star, pi-star-fill, pi-star-half, pi-star-half-fill icons. More precise visual representation, common rating pattern. -**Updated in spec:** UI mockups, component implementation uses PrimeVue star icons +**Decision:** Option A — Flat globally-unique slugs +**Rationale:** Simpler implementation with a single `slug` column and unique index on `base_albums`. No dependency on parent album structure — renaming/moving a parent doesn't invalidate child slugs. Easier to reason about uniqueness and collisions. +**Updated in spec:** FR-019-01 (slug on `base_albums`), FR-019-03 (global uniqueness), Non-Goals (hierarchical paths explicitly excluded) --- -### ~~Q001-14: Overlay Persistence on Active Interaction~~ ✅ RESOLVED +### ~~Q-019-02: Top-Level Route Support~~ ✅ RESOLVED -**Decision:** Option A - Persist while loading, then restart auto-hide timer -**Rationale:** User sees confirmation (success toast + updated rating), natural interaction flow. -**Updated in spec:** Implementation plan I9c, PhotoRatingOverlay behavior +**Decision:** Option A — Gallery-prefixed only (`/gallery/{slug}`) +**Rationale:** No collision risk with existing routes (`/settings`, `/profile`, `/login`, etc.). No changes to web route definitions — slug resolution happens inside the existing `{albumId}` parameter. Simpler, safer, ships faster. +**Updated in spec:** FR-019-05 (resolution within existing route), FR-019-10 (Vue Router `/gallery/{slug}`), Non-Goals (top-level routes excluded) --- -### ~~Q001-15: Rating Tooltip/Label Clarity~~ ✅ RESOLVED +### ~~Q-019-03: Tag Album Slug Support~~ ✅ RESOLVED -**Decision:** Option C - No labels/tooltips (stars are self-evident) -**Rationale:** Cleanest UI, stars are universal rating symbol, keeps overlays compact. -**Updated in spec:** UI components (no tooltip implementation needed) +**Decision:** Option A — Both Album and TagAlbum (via shared `base_albums` table) +**Rationale:** The `slug` column lives on `base_albums`, which is shared by both Album and TagAlbum. Consistent behaviour — any album-like entity can have a friendly URL. No special-casing needed in the factory or validation. +**Updated in spec:** FR-019-01 (column on `base_albums`), FR-019-03 (uniqueness across Album + TagAlbum), S-019-14 (tag album scenario) --- -### ~~Q001-16: Accessibility (Keyboard Navigation, ARIA)~~ ✅ RESOLVED +### ~~Q-017-01: Context Menu Scope Behaviour for Photos vs Albums~~ ✅ RESOLVED -**Decision:** Option C - Defer to post-MVP -**Rationale:** Ship faster with basic implementation, gather user feedback first, can enhance accessibility later. -**Updated in spec:** Out of scope (deferred enhancement) +**Decision:** Option A — Scope radio hidden for photos, shown for albums +**Rationale:** Most intuitive UX. Photos have no descendants so scope is meaningless — hide it. Albums support "Current level" (rename only selected album titles) and "All descendants" (selected albums + sub-albums recursively). Backend receives `album_ids[]` + `scope` for the album path; `photo_ids[]` only (no scope) for the photo path. +**Updated in spec:** FR-017-07 (scope hidden for photos), FR-017-08 (scope shown for albums), FR-017-09 (contract split by target type) --- -### ~~Q001-17: Optimistic UI Updates vs Server Confirmation~~ ✅ RESOLVED +### ~~Q-017-02: No Renamer Rules Configured Edge Case~~ ✅ RESOLVED -**Decision:** Option A - Wait for server confirmation -**Rationale:** Always shows accurate server state, clear error handling, no phantom updates. -**Updated in spec:** Implementation plan I8, I9a, I9c (loading state pattern) +**Decision:** Option A — Show the empty preview with an enhanced message +**Rationale:** Simplest approach with no extra API calls. The empty-state message is enhanced: "No titles would change. If you haven't configured renamer rules yet, visit Settings → Renamer Rules." Minimal code change, no additional data dependencies. +**Updated in spec:** FR-017-05 (enhanced empty-state message), UI-017-05 --- -### ~~Q001-18: Rating Count Threshold for Display~~ ✅ RESOLVED +### ~~Q-011-01: Config Key Naming for My Best Pictures Count~~ ✅ RESOLVED -**Decision:** Option A - Always show rating, regardless of count -**Rationale:** Transparent, simpler logic, users can judge significance from count displayed. -**Updated in spec:** UI components (no threshold logic needed) +**Decision:** Option A - Separate config key `my_best_pictures_count` +**Rationale:** Allows independent configuration. Users might want different counts for overall best pictures vs personal favorites. Clearer semantics with each album having its own setting. +**Updated in spec:** CFG-011-03, DO-011-02 implementation --- -### ~~Q001-19: Telemetry Event Granularity~~ ✅ RESOLVED +### ~~Q-011-02: Default Sort Order for My Rated Pictures Album~~ ✅ RESOLVED -**Decision:** No telemetry events / analytics -**Rationale:** Feature does not include telemetry or analytics tracking. -**Updated in spec:** Remove telemetry events from FR-001-01, FR-001-02, FR-001-03 +**Decision:** Option A - Sort by rating DESC, then by created_at DESC +**Rationale:** Shows highest-rated photos first, consistent with "favorites" concept. Most intuitive for users wanting to see their best-rated photos at the top. +**Updated in spec:** FR-011-01, query implementation details --- -### ~~Q001-20: Rating Analytics/Trending Features~~ ✅ RESOLVED +### ~~Q-010-01: LDAP Authentication Method~~ ✅ RESOLVED -**Decision:** Option B - Implement minimally for current scope -**Rationale:** Follows YAGNI principle, simpler initial implementation, faster to ship. -**Updated in spec:** Out of scope (no future analytics preparation) +**Decision:** Option C - Both basic auth and LDAP independently configurable via .env +**Rationale:** Maximum flexibility; allows deployments to use LDAP-only, basic-only, or both. LDAP enablement controlled by .env variables. +**Updated in spec:** FR-010-05, authentication method selection --- -### ~~Q001-21: Album Aggregate Rating Display~~ ✅ RESOLVED +### ~~Q-010-02: User Provisioning~~ ✅ RESOLVED -**Decision:** Option A - Defer to future feature -**Rationale:** Keeps current feature focused, can design properly later with user feedback on photo ratings. -**Updated in spec:** Out of scope, potential future Feature 00X +**Decision:** Option C - User provisioning configurable via .env +**Rationale:** Flexibility for different deployment scenarios; allows auto-create or pre-existing-only mode via configuration. +**Updated in spec:** FR-010-04, user provisioning behavior --- -### ~~Q001-22: Rating Export in Photo Backup~~ ✅ RESOLVED +### ~~Q-010-03: LDAP Group Mapping~~ ✅ RESOLVED -**Decision:** Option C - No export (ratings are ephemeral/server-side only) -**Rationale:** Simpler export logic, smaller export files. -**Updated in spec:** Out of scope (no export functionality) +**Decision:** Option B - Map LDAP groups to Lychee roles (admin/user) +**Rationale:** Allows admin role assignment via LDAP groups; provides automatic role sync without complex user group management. +**Updated in spec:** FR-010-03, role mapping configuration --- -### ~~Q001-23: Rating Notification to Photo Owner~~ ✅ RESOLVED +### ~~Q-010-04: User Attribute Mapping~~ ✅ RESOLVED -**Decision:** Option A - Defer to future feature (notifications system) -**Rationale:** Keeps feature scope focused, requires notifications infrastructure that may not exist yet. -**Updated in spec:** Out of scope (deferred to future notifications feature) +**Decision:** Option C - Defaults with optional override via .env +**Rationale:** Provides sensible defaults (uid→username, mail→email, displayName→display_name) with .env configuration for LDAP schemas that differ. +**Updated in spec:** FR-010-02, attribute mapping configuration --- -### ~~Q001-24: Statistics Recalculation Artisan Command~~ ✅ RESOLVED +### ~~Q-010-05: Password Storage~~ ✅ RESOLVED -**Decision:** Option B - No command, rely on transaction integrity -**Rationale:** Trust atomic transactions to maintain consistency, simpler implementation. -**Updated in spec:** Out of scope (no artisan command) +**Decision:** Option A - Don't store LDAP passwords +**Rationale:** Most secure approach; authenticate only against LDAP server without password duplication. +**Updated in spec:** FR-010-01, authentication flow, security model --- -### ~~Q001-25: Migration Strategy for Existing Installations~~ ✅ RESOLVED +### ~~Q-010-06: Configuration Method~~ ✅ RESOLVED -**Decision:** Option A - Migration adds columns with defaults, no backfill -**Rationale:** Clean state (accurate: no ratings yet), fast migration, no assumptions about historical data. -**Updated in spec:** Implementation plan I1 (migrations with default values) +**Decision:** Option A - Environment variables only +**Rationale:** LDAP is an expert/power-user setting; .env configuration is appropriate and avoids database complexity. +**Updated in spec:** All configuration options use .env variables, NFR-010-01 --- -### ~~Q001-05: Authorization Model for Rating~~ ✅ RESOLVED +### ~~Q-010-07: LdapRecord Integration Strategy~~ ✅ RESOLVED -**Decision:** Option B - Read access (anyone who can view can rate) -**Rationale:** Follows standard rating system patterns. Rating is a lightweight engagement action similar to favoriting, not a privileged edit operation. Makes ratings more accessible and useful. -**Updated in spec:** FR-001-01, NFR-001-04 +**Decision:** Option A - Service layer wrapping LdapRecord +**Rationale:** Better separation of concerns and testability. `LdapService` acts as facade/adapter over LdapRecord's Connection and query builder. Business logic abstracted from LDAP library details. Easier to test (mock LdapService interface) and swap libraries if needed. +**Updated in spec:** I2-I5 architecture, LdapService design as wrapper pattern --- -### ~~Q001-06: Rating Removal HTTP Status Code~~ ✅ RESOLVED +### ~~Q-010-08: LdapConfiguration DTO Purpose~~ ✅ RESOLVED -**Decision:** 200 OK (idempotent behavior) -**Rationale:** Removing a non-existent rating is a no-op and should return success (200 OK) rather than 404 error. This makes the endpoint idempotent and simpler to use. -**Updated in spec:** FR-001-02 +**Decision:** Option A - LdapConfiguration validates/transforms .env values +**Rationale:** Clean validation layer providing type-safe value object. Single source of truth: .env → LdapConfiguration::fromEnv() validates → values passed to LdapRecord config. Prevents invalid config, provides testability. +**Updated in spec:** I1 LdapConfiguration DTO implementation, validation strategy --- -### ~~Q001-01: Full-size Photo Overlay Positioning~~ ✅ RESOLVED +### ~~Q-010-09: Connection Pooling Implementation~~ ✅ RESOLVED -**Decision:** Option A - Bottom-center -**Rationale:** Centered position is more discoverable and doesn't compete with Dock buttons. Symmetrical with metadata overlay below. -**Updated in spec:** FR-001-10, UI mockup section 2, implementation plan I9c/I9d +**Decision:** Option A - Configure LdapRecord's built-in connection management +**Rationale:** Leverage existing, tested library features. Configure timeouts and connection caching via LdapRecord config. No custom pooling code needed. +**Updated in spec:** I2 implementation approach, NFR-010-04 --- -### ~~Q001-02: Auto-hide Timer Duration~~ ✅ RESOLVED +### ~~Q-010-10: Testing Strategy~~ ✅ RESOLVED -**Decision:** Option A - 3 seconds -**Rationale:** Standard UX pattern, balanced duration (not too fast, not too slow). -**Updated in spec:** FR-001-10, UI mockup section 2, implementation plan I9c +**Decision:** Option A - LdapRecord testing utilities for unit tests, skip Docker integration tests +**Rationale:** Fast unit tests using LdapRecord's `DirectoryEmulator` or test helpers. Mock LDAP responses at service boundary. Docker integration tests deferred to future enhancement. +**Updated in spec:** I2-I7 test implementation, no Docker CI configuration needed --- -### ~~Q001-03: Rating Removal Button Placement~~ ✅ RESOLVED +### ~~Q-010-11: Authentication Flow Sequence~~ ✅ RESOLVED -**Decision:** Option A - Inline [0] button -**Rationale:** Consistent button pattern, simple implementation, shown as "×" or "Remove" for clarity. -**Updated in spec:** FR-001-09, UI mockup section 1, implementation plan I9a +**Decision:** Option A - Search-first pattern (username → search → DN → bind → groups) +**Rationale:** Flexible approach supporting diverse LDAP schemas. Flow: 1) User submits username+password, 2) Search LDAP using `LDAP_USER_FILTER`, 3) Get userDn from search result, 4) Bind with userDn+password, 5) Query groups using userDn, 6) Retrieve user attributes. +**Updated in spec:** FR-010-01, I2 LdapService `authenticate()` method, I4 `getUserGroups()` signature --- -### ~~Q001-04: Overlay Visibility on Mobile Devices~~ ✅ RESOLVED +### ~~Q-010-12: TLS/StartTLS Configuration~~ ✅ RESOLVED -**Decision:** Option A - Details drawer only on mobile -**Rationale:** Follows existing Lychee pattern (overlays are desktop-only), simple and consistent experience. -**Updated in spec:** FR-001-09, FR-001-10, UI mockup sections 1-2, implementation plan I9a/I9c +**Decision:** Option A - Single `LDAP_USE_TLS` flag, protocol determined by port +**Rationale:** Simpler configuration with fewer env vars. Protocol auto-detected: port 636 = LDAPS, port 389 = StartTLS. Documentation in .env.example clarifies both scenarios. +**Updated in spec:** ENV-010-13, I10 documentation deliverables --- -### ~~Q001-01: Full-size Photo Overlay Positioning~~ (ARCHIVED) - -**Context:** When hovering over the lower area of a full-size photo, the rating overlay can be positioned in different locations. The spec currently presents two options. - -**Question:** Which positioning approach should we use for the full-size photo rating overlay? +### ~~Q-009-01: Average Rating Storage Strategy~~ ✅ RESOLVED -**Options (ordered by preference):** +**Decision:** Option B - Add denormalized rating_avg column to photos table +**Rationale:** Fast indexed sorting with simple ORDER BY. Application logic will keep it in sync when ratings are updated (same transaction as rating_sum/rating_count updates). +**Updated in spec:** FR-009-01, DO-009-01, migration strategy -**Option A: Bottom-center (Recommended)** -- **Position:** Horizontally centered, positioned above the metadata overlay (title/EXIF) -- **Layout:** `★★★★☆ 4.2 (15) Your rating: ★★★★☆ [0][1][2][3][4][5]` -- **Pros:** - - Centered position is intuitive and balanced - - Doesn't compete with Dock buttons for space - - More visible and discoverable - - Symmetrical with metadata overlay below it -- **Cons:** - - May obstruct central portion of photo - - Wider horizontal space required +--- -**Option B: Bottom-right (near Dock buttons)** -- **Position:** Bottom-right corner, adjacent to existing Dock action buttons -- **Layout:** Compact vertical or horizontal near Dock -- **Pros:** - - Groups with other photo actions (Dock buttons) - - Consistent with action button placement pattern - - Less obstruction of photo center -- **Cons:** - - May crowd the Dock button area - - Less discoverable (user might not look at corner) - - Asymmetrical with metadata overlay (which is bottom-left) +### ~~Q-009-02: Rating Smart Album Threshold Logic~~ ✅ RESOLVED -**Impact:** Medium - affects UX discoverability and visual balance, but either option is functional. +**Decision:** Option C - Hybrid (threshold for 3★+, exact for 1★-2★) +**Rationale:** Matches user's explicit statement that "3_stars album will contain all photos rated 3 stars or above." Low ratings (1★, 2★) use exact buckets so photos only appear in one album; high ratings (3★+) use threshold for cumulative view. +**Updated in spec:** FR-009-03 through FR-009-08, smart album filtering logic --- -### Q001-02: Auto-hide Timer Duration - -**Context:** The full-size photo rating overlay auto-hides after a period of inactivity to avoid obstructing the photo view. - -**Question:** What duration should the auto-hide timer be set to? +### ~~Q-009-03: Best Pictures Cutoff Behavior~~ ✅ RESOLVED -**Options (ordered by preference):** +**Decision:** Option B - Top N by rating, include ties +**Rationale:** Fair behavior that doesn't arbitrarily exclude photos with the same rating as the Nth photo. May show more than N photos if ties exist, but ensures no photo is unfairly excluded. +**Updated in spec:** FR-009-09, Best Pictures smart album logic -**Option A: 3 seconds (Recommended)** -- **Duration:** Overlay fades out after 3 seconds of no mouse movement -- **Pros:** - - Short enough to not be annoying - - Long enough for user to read and interact - - Common UX pattern for transient overlays -- **Cons:** - - May feel rushed for slower users - - Might hide before user finishes reading - -**Option B: 5 seconds** -- **Duration:** Overlay fades out after 5 seconds of no mouse movement -- **Pros:** - - More time for users to read and decide - - Less pressure to act quickly -- **Cons:** - - Longer obstruction of photo view - - May feel sluggish - -**Option C: Configurable (with 3s default)** -- **Duration:** User setting for auto-hide duration (1-10 seconds) -- **Pros:** - - User preference accommodated - - Accessible for users with different needs -- **Cons:** - - Added complexity (settings UI, store management) - - Deferred to post-MVP - -**Option D: No auto-hide (manual dismiss only)** -- **Duration:** Overlay persists until user moves mouse away from lower area -- **Pros:** - - No time pressure - - User controls when it disappears -- **Cons:** - - Overlay may linger and obstruct photo - - Less elegant UX - -**Impact:** Medium - affects user experience and perception of polish, but any reasonable duration works. - ---- - -### Q001-03: Rating Removal Button Placement - -**Context:** Users can remove their rating by selecting "0". The UI design needs to clarify how this is presented. - -**Question:** How should the "remove rating" (0) option be presented in the UI? +--- -**Options (ordered by preference):** +### ~~Q-009-04: Smart Album Sorting Default~~ ✅ RESOLVED -**Option A: Inline button [0] before stars (Recommended)** -- **Layout:** `[0] [1] [2] [3] [4] [5]` with 0 shown as "×" or "Remove" -- **Pros:** - - Consistent with the button pattern - - Clear that 0 is a special action (remove) - - Simple implementation (same component pattern) -- **Cons:** - - May be confused with a rating of zero - - Takes up space in compact overlays +**Decision:** Custom - Rating smart albums and Best Pictures sorted by rating DESC +**Rationale:** Shows highest-rated photos first, which is the natural expectation for rating-based albums. +**Updated in spec:** FR-009-10, NFR-009-03 -**Option B: Separate "Clear rating" button** -- **Layout:** `[1] [2] [3] [4] [5] [Clear ×]` -- **Pros:** - - Visually distinct from rating action - - Clearer intent (remove vs rate) - - Reduces accidental removal -- **Cons:** - - Additional UI element - - Less compact for overlays +--- -**Option C: Right-click or long-press to remove** -- **Interaction:** Click star to rate, right-click/long-press to remove -- **Pros:** - - No additional UI needed - - Clean visual design -- **Cons:** - - Not discoverable (hidden interaction) - - Accessibility concerns - - Mobile long-press may be awkward +### ~~Q-009-06: NULLS LAST Cross-Database Strategy~~ ✅ RESOLVED -**Impact:** Low - all options are functional, mainly affects visual design and user discovery. +**Decision:** Simple indexed ORDER BY with COALESCE pattern for fastest performance +**Rationale:** User specified "fastest ordering possible with indexing." Using `COALESCE(rating_avg, -1) DESC` allows the query to use the index on `rating_avg` efficiently across all databases. Since ratings are always positive (1-5), -1 as sentinel value is safe and pushes NULLs to the end. +**Updated in spec:** FR-009-02, sorting strategy, SortingDecorator implementation --- -### Q001-04: Overlay Visibility on Mobile Devices +### ~~Q-008-01: User Preference Storage Location~~ ✅ RESOLVED -**Context:** The current spec hides rating overlays on mobile (below md: breakpoint) because hover interactions don't work well on touch devices. Users can still rate via the details drawer. +**Decision:** Option A - New column in users table +**Rationale:** Follows existing Lychee pattern (user attributes in users table), simple implementation with single query, no new tables needed. +**Updated in spec:** FR-008-02, COL-008-01, migration strategy -**Question:** Should we provide any rating interaction on mobile beyond the details drawer? +--- -**Options (ordered by preference):** +### ~~Q-008-02: Smart Albums in Tabbed View~~ ✅ RESOLVED -**Option A: Details drawer only on mobile (Recommended)** -- **Behavior:** No overlays on mobile, rating only via PhotoDetails drawer -- **Pros:** - - Simple, consistent experience - - No awkward touch interaction patterns needed - - Cleaner thumbnail grid (no overlay clutter) - - Follows existing Lychee mobile pattern (overlays are desktop-only) -- **Cons:** - - Requires opening details drawer to rate - - Less convenient for quick ratings +**Decision:** Option D - Show above tabs (outside tab context) +**Rationale:** Smart albums span all content (photos from both owned and shared albums), so they should be displayed above the tab bar and remain always visible regardless of selected tab. +**Updated in spec:** UI mockups, FR-008-06, FR-008-07 -**Option B: Tap-to-show overlay on thumbnails** -- **Behavior:** Single tap shows overlay (without opening photo), tap star to rate, tap outside to dismiss -- **Pros:** - - Quick access to rating on mobile - - No need to open details drawer -- **Cons:** - - Conflicts with tap-to-open-photo gesture - - Requires double-tap or long-press (poor UX) - - Added complexity in touch event handling +--- -**Option C: Always-visible compact rating on thumbnails (mobile)** -- **Behavior:** Small rating display (stars or number) always visible on thumbnails on mobile -- **Pros:** - - Ratings always visible at a glance - - Tap star to rate directly -- **Cons:** - - Clutters thumbnail grid - - Inconsistent with desktop (hover-only) - - May obscure thumbnail image +### ~~Q-008-03: Tab Visibility When Empty~~ ✅ RESOLVED -**Impact:** Medium - affects mobile user experience, but details drawer provides full fallback. +**Decision:** Option A - Hide empty tabs +**Rationale:** Cleaner UX - if "Shared with Me" has no albums, don't show tab bar at all (behave like SHOW mode). Simpler for users with no shared albums. +**Updated in spec:** S-008-08, UI-008-02 --- -### Q001-07: Statistics Record Creation Strategy - -**Context:** When a user rates a photo for the first time, the `photo_statistics` record may not exist yet. The implementation must handle this gracefully. - -**Question:** How should we ensure the statistics record exists when creating the first rating? +--- -**Options (ordered by preference):** +### ~~Q-007-01: Pagination Strategy (Offset vs Cursor) and Page Size Configuration~~ ✅ RESOLVED -**Option A: firstOrCreate in transaction (Recommended)** -- **Approach:** Use `PhotoStatistics::firstOrCreate(['photo_id' => $photo_id], [...defaults])` within the transaction -- **Pros:** - - Atomic operation, no race condition - - Laravel handles duplicate creation attempts - - Simple implementation -- **Cons:** - - May create statistics record even if rating fails validation - - Extra query overhead +**Decision:** Option A - Offset-based pagination with config table page size +**Rationale:** Simple Laravel pagination pattern with standard LIMIT/OFFSET, easy navigation to specific pages, admin-configurable page sizes via config table. Performance acceptable for expected album sizes. +**Updated in spec:** FR-007-01 through FR-007-06, NFR-007-01, NFR-007-05, DO-007-01 -**Option B: Check existence before rating** -- **Approach:** Check if statistics exists, create if missing before rating transaction -- **Pros:** - - Explicit control flow - - Clear error handling -- **Cons:** - - Two separate operations (not atomic) - - Race condition if two users rate simultaneously - - More complex code +--- -**Option C: Database trigger** -- **Approach:** Create database trigger to auto-create statistics record on photo insert -- **Pros:** - - Guarantees statistics always exists - - No application logic needed -- **Cons:** - - Adds database complexity - - Migration complexity for existing photos - - Not Lychee's pattern (application-level logic preferred) +### ~~Q-007-02: API Endpoint Design (New Endpoints vs Modify Existing)~~ ✅ RESOLVED -**Impact:** High - affects data integrity and implementation complexity +**Decision:** Option B - New paginated endpoints (`/Album/{id}/head`, `/Album/{id}/albums`, `/Album/{id}/photos`) +**Rationale:** Clear separation of concerns, existing `/Album` endpoint unchanged for backward compatibility (avoiding test changes), consistent response structure per endpoint. Code duplication acceptable to minimize refactoring risk. +**Updated in spec:** FR-007-01, FR-007-02, FR-007-03, FR-007-12, NFR-007-04, NFR-007-06, API-007-01 through API-007-05 --- -### Q001-08: Transaction Rollback Error Handling +### ~~Q-007-03: Frontend Loading Strategy (Load-More vs Page Navigation)~~ ✅ RESOLVED -**Context:** When a database transaction fails (e.g., deadlock, constraint violation), the spec doesn't clarify what error should be returned to the user. +**Decision:** Configurable with infinite scroll as default +**Rationale:** User specified configurable UI modes: "infinite_scroll" (default), "load_more_button", "page_navigation". Infinite scroll provides smoothest UX for photo galleries. First page always loaded automatically, subsequent pages on demand based on UI mode. +**Updated in spec:** FR-007-07, FR-007-08, FR-007-09, FR-007-10, DO-007-02, UI mockups -**Question:** How should we handle transaction failures in the rating endpoint? +--- -**Options (ordered by preference):** +### ~~Q-007-04: Config Key Naming and Default Values~~ ✅ RESOLVED -**Option A: 500 Internal Server Error with generic message (Recommended)** -- **Response:** HTTP 500, `{"message": "Unable to save rating. Please try again."}` -- **Pros:** - - Doesn't expose database implementation details - - Standard error handling pattern - - User-friendly message -- **Cons:** - - Less specific for debugging - - May retry without fixing underlying issue +**Decision:** Option C - Multiple granular configs +**Rationale:** User specified: `albums_per_page` (default 30), `photos_per_page` (default 100), Flexible tuning for different resource types with appropriate defaults based on typical usage patterns. +**Updated in spec:** FR-007-06, NFR-007-05, DO-007-01 -**Option B: 409 Conflict for transaction errors** -- **Response:** HTTP 409, `{"message": "Rating conflict. Please refresh and try again."}` -- **Pros:** - - More semantic (conflict suggests retry) - - Indicates temporary issue -- **Cons:** - - 409 typically used for optimistic locking conflicts - - May confuse frontend logic +--- -**Option C: Log error, retry transaction automatically** -- **Approach:** Catch deadlock exceptions, retry transaction 2-3 times before failing -- **Pros:** - - Transparent to user - - Handles temporary deadlocks gracefully -- **Cons:** - - Added complexity - - May mask underlying database issues - - Increased latency +### ~~Q-007-05: Refactoring Scope (Extract Album/Photo Fetching Logic)~~ ✅ RESOLVED -**Impact:** High - affects error handling strategy and user experience +**Decision:** Option B - Repository pattern methods, code duplication acceptable +**Rationale:** User directive to avoid extensive refactoring, prioritize backward compatibility and minimal test changes. New endpoints can duplicate logic from existing implementation. Repository pattern methods for data access without extracting to separate service classes. +**Updated in spec:** NFR-007-06, Goals section, Non-Goals section --- -### Q001-09: N+1 Query Performance for user_rating - -**Context:** PhotoResource includes `user_rating` field by querying `$this->ratings()->where('user_id', auth()->id())->value('rating')`. When loading many photos (album grid), this creates N+1 query problem. - -**Question:** How should we optimize user_rating loading for photo collections? +### ~~Q-007-06: Backward Compatibility Strategy for Existing Clients~~ ✅ RESOLVED -**Options (ordered by preference):** +**Decision:** New endpoints default page=1, existing `/Album` endpoint unchanged +**Rationale:** User specified creating new endpoints only. Legacy `/Album?album_id=X` endpoint remains unchanged returning full data. New endpoints (`/Album/{id}/albums`, `/Album/{id}/photos`) default to page 1 if `?page=` parameter absent (not "return all"). +**Updated in spec:** FR-007-11, FR-007-12, API-007-02, API-007-03, API-007-04 -**Option A: Eager load with closure in controller (Recommended)** -- **Implementation:** - ```php - $photos->load(['ratings' => fn($q) => $q->where('user_id', auth()->id())]); - ``` -- **Pros:** - - Single additional query for all photos - - Standard Laravel pattern - - No PhotoResource changes needed -- **Cons:** - - Must remember to eager load in every controller method - - Easy to forget and create N+1 - -**Option B: Global scope on Photo model** -- **Implementation:** Add global scope to always eager load current user's rating -- **Pros:** - - Automatic, no controller changes needed - - Consistent across all queries -- **Cons:** - - Always loads ratings even when not needed - - Performance overhead for unauthenticated users - - Global scopes can have unexpected side effects - -**Option C: Separate endpoint for ratings** -- **Implementation:** Load photos without ratings, fetch ratings separately via `/api/photos/{ids}/ratings` -- **Pros:** - - Decoupled data loading - - Can defer ratings until needed -- **Cons:** - - Two API calls required - - More complex frontend logic - - Increased latency - -**Impact:** High - affects performance for album views with many photos - ---- - -### Q001-10: Concurrent Update Debouncing (Rapid Clicks) - -**Context:** If a user rapidly clicks different star values, multiple concurrent API requests may be sent. This could cause race conditions or display inconsistencies. - -**Question:** Should we debounce or throttle rapid rating changes in the UI? +--- -**Options (ordered by preference):** +### ~~Q-006-01: Filter UI Control Design and Interaction Pattern~~ ✅ RESOLVED -**Option A: Disable stars during API call (Recommended)** -- **Behavior:** Set `loading = true`, disable all star buttons until API returns -- **Pros:** - - Simple implementation - - Prevents concurrent requests - - Clear visual feedback (loading state) -- **Cons:** - - User must wait for each rating to complete - - Slower if user wants to correct mistake +**Decision:** Option D - Hover star list with minimum threshold filtering and toggle-off +**Rationale:** User specified custom interaction: Display 5 hoverable stars. Empty stars = no filtering. Click on star N = show photos with rating ≥ N (minimum threshold). Click same star again = remove filtering. Combines visual clarity of inline stars with flexible threshold filtering. +**Updated in spec:** FR-006-01, FR-006-02, FR-006-03, UI mockup section -**Option B: Debounce rating submissions (300ms)** -- **Behavior:** Wait 300ms after last click before sending API request, cancel pending requests -- **Pros:** - - Allows user to change mind quickly - - Reduces API calls for rapid clicks -- **Cons:** - - Delayed feedback - - More complex implementation (cancel logic) - - May feel sluggish +--- -**Option C: Queue requests, send last value only** -- **Behavior:** Queue rating changes, send only most recent value when previous request completes -- **Pros:** - - Always saves final user choice - - No wasted API calls -- **Cons:** - - Complex state management - - User may see intermediate states that don't persist +### ~~Q-006-02: Filter Behavior for Unrated Photos~~ ✅ RESOLVED -**Impact:** High - affects UX responsiveness and data consistency +**Decision:** Addressed by Q-006-01 decision +**Rationale:** Minimum threshold filtering (≥ N stars) inherently excludes unrated photos (which have no rating value). Empty stars (no filter) shows all photos including unrated. +**Updated in spec:** FR-006-02, filtering logic section --- -### Q001-11: Metrics Disabled Behavior (Can Still Rate?) +### ~~Q-006-03: Filter State Persistence Strategy~~ ✅ RESOLVED -**Context:** The spec says rating data is hidden when `metrics_enabled` config is false, but doesn't clarify if users can still submit ratings when metrics are disabled. +**Decision:** Custom - State store persistence (like NSFW visibility) +**Rationale:** User specified to keep selection in state store, similar to existing NSFW visibility pattern. State persists during session but managed by Pinia store, not localStorage (follows existing Lychee patterns for view state). +**Updated in spec:** FR-006-04, NFR-006-01 -**Question:** When metrics are disabled, should users still be able to rate photos? +--- -**Options (ordered by preference):** +### ~~Q-006-04: Multi-Rating Filter Support (AND vs OR)~~ ✅ RESOLVED -**Option A: Yes, rating functionality always available (Recommended)** -- **Behavior:** Users can rate, but aggregates/counts are hidden in UI. Data is still stored. -- **Pros:** - - Consistent user experience - - Data collection continues even if display is disabled - - Easy to re-enable metrics later with existing data -- **Cons:** - - May confuse users (why can I rate if I can't see ratings?) - - Data stored but not shown +**Decision:** Option C - Range filter (minimum threshold) as explained in Q-006-01 +**Rationale:** User clarified in Q-006-01 that clicking star N shows photos with rating ≥ N (3+ stars shows 3, 4, 5 star photos). Simple single-selection UI with flexible filtering capability. +**Updated in spec:** FR-006-01, FR-006-02, filtering algorithm section -**Option B: No, disable rating when metrics disabled** -- **Behavior:** Hide all rating UI and disable `/Photo::rate` endpoint when metrics disabled -- **Pros:** - - Consistent (if metrics off, ratings off) - - Respects privacy/metrics setting fully -- **Cons:** - - Loss of data collection - - Hard to re-enable later (no historical data) - - Inconsistent with favorites (favorites work when metrics disabled) +--- -**Option C: Admin setting controls independently** -- **Behavior:** Separate `ratings_enabled` config independent of `metrics_enabled` -- **Pros:** - - Granular control - - Can enable rating without showing aggregates -- **Cons:** - - More configuration complexity - - May confuse admins +### ~~Q-005-01: List View Layout Structure and Information Display~~ ✅ RESOLVED -**Impact:** High - affects feature scope and user experience +**Decision:** Option A - Windows Details View Pattern +**Rationale:** Familiar file manager pattern with horizontal row layout: `[Thumb 64px] [Album Name - Full] [X photos] [Y sub-albums]`. Scannable, information-dense, shows full untruncated album names. +**Updated in spec:** FR-005-01, FR-005-02, UI mockup section --- -### Q001-12: Rating Display When Metrics Disabled +### ~~Q-005-02: Toggle Control Placement and Styling~~ ✅ RESOLVED -**Context:** FR-001-04 says rating data is shown "when metrics are enabled," but spec doesn't clarify if user's own rating is shown when metrics are disabled. +**Decision:** Custom - AlbumHero.vue icon row (same line as statistics/download toggles) +**Rationale:** User specified placement on the same line as the statistics and download toggle buttons in AlbumHero.vue (line 33, flex-row-reverse container). Follows existing icon pattern with px-3 spacing and hover animations. +**Updated in spec:** FR-005-03, UI implementation section -**Question:** When metrics are disabled, should the UI show the user's own rating (even if aggregates are hidden)? +--- -**Options (ordered by preference):** +### ~~Q-005-03: View Preference Persistence Strategy~~ ✅ RESOLVED + +**Decision:** Option B - LocalStorage/session-only (no backend) +**Rationale:** Simple implementation, no backend changes needed, fast toggle response. User preference stored in browser localStorage per-device. +**Updated in spec:** FR-005-04, NFR-005-01 -**Option A: Show user's own rating regardless of metrics setting (Recommended)** -- **Behavior:** User sees their own rating stars highlighted, but no aggregate average/count -- **Pros:** - - User feedback on their own action - - Doesn't expose community metrics (privacy preserved) - - Consistent with user-centric data (my data vs community data) -- **Cons:** - - Slightly inconsistent with "metrics disabled" (rating is a metric) +--- -**Option B: Hide all rating data when metrics disabled** -- **Behavior:** No rating display at all, including user's own -- **Pros:** - - Fully consistent with metrics disabled - - Simplest implementation -- **Cons:** - - Poor UX (user can't see what they rated) - - Feels broken ("I clicked 4 stars, where did it go?") +### ~~Q-004-01: Recomputation Trigger Strategy for Size Statistics~~ ✅ RESOLVED -**Impact:** Medium - affects UX when metrics are disabled +**Decision:** Option B - Separate `RecomputeAlbumSizeJob` triggered independently, using Skip middleware with cache-based job tracking (same pattern as Feature 003's `RecomputeAlbumStatsJob`) +**Rationale:** Decoupled from Feature 003, can optimize independently, reuses proven Skip middleware pattern from [RecomputeAlbumStatsJob.php](app/Jobs/RecomputeAlbumStatsJob.php:76-93) with cache key `album_size_latest_job:{album_id}` and unique job IDs for deduplication. +**Updated in spec:** FR-004-02, JOB-004-01, middleware implementation details --- -### Q001-13: Half-Star Display for Fractional Averages +### ~~Q-004-02: Migration/Backfill Strategy for Existing Albums~~ ✅ RESOLVED -**Context:** Spec stores rating_avg as decimal(3,2), allowing fractional values like 4.33. UI mockups show full/empty stars only (no half-stars). +**Decision:** Option A - Separate artisan command, manual execution, PLUS maintenance UI button for operators +**Rationale:** Operator controls timing during maintenance window, fast migration (schema only), progress monitoring. Admin UI button provides convenient trigger for backfill without CLI access. +**Updated in spec:** FR-004-04, CLI-004-01, maintenance UI addition -**Question:** Should we display half-stars for fractional average ratings? +--- -**Options (ordered by preference):** +### ~~Q-004-03: Job Deduplication Approach for Concurrent Updates~~ ✅ RESOLVED -**Option A: Full stars only, round to nearest integer (Recommended)** -- **Display:** 4.33 avg → ★★★★☆ (4 stars), show "4.33" as text next to stars -- **Pros:** - - Simpler UI implementation - - Clear visual (full or empty) - - Numeric value still shows precision -- **Cons:** - - Visual representation less precise +**Decision:** Option D (Custom) - Use Skip middleware with cache-based job tracking (same pattern as Feature 003) +**Rationale:** Reuses proven pattern from [RecomputeAlbumStatsJob.php](app/Jobs/RecomputeAlbumStatsJob.php): Each job gets unique ID, latest job ID stored in cache with key `album_size_latest_job:{album_id}`, `Skip::when()` middleware checks if newer job queued. Simpler than `WithoutOverlapping`, guarantees most recent update eventually processes. +**Updated in spec:** FR-004-02, JOB-004-01 -**Option B: Half-star display for .25-.74 range** -- **Display:** 4.33 avg → ★★★★⯨ (4.5 stars visually), show "4.33" as text -- **Pros:** - - More precise visual representation - - Common rating pattern (Amazon, IMDb) -- **Cons:** - - More complex implementation (half-star icon, rounding logic) - - May not match user's mental model (users rate 1-5, not 1-10) +--- -**Option C: Gradient fill for precise fractional display** -- **Display:** 4.33 avg → ★★★★⯨ (4th star 33% filled) -- **Pros:** - - Exact visual representation - - Visually interesting -- **Cons:** - - Complex implementation (SVG/CSS gradients) - - May be hard to read at small sizes - - Uncommon pattern (users may not understand) +## How to Use This Document -**Impact:** Medium - affects UI polish and clarity +1. **Log new questions:** Add a row to the Active Questions table with a unique ID (format: `Q###-##`), feature reference, priority (High/Medium), and brief summary. +2. **Add details:** Create a corresponding section under Question Details with: + - Full question context + - Options (A, B, C...) ordered by preference + - Pros/cons for each option + - Impact analysis +3. **Present to user:** Once logged, present the question inline in chat referencing the question ID. +4. **Resolve and remove:** When answered, update the relevant spec sections (and create ADR if high-impact), then delete both the table row and Question Details entry. --- -### Q001-14: Overlay Persistence on Active Interaction +*Last updated: 2026-03-15* + +### ~~Q-003-01: Recomputation Job Queue Priority~~ ✅ RESOLVED -**Context:** PhotoRatingOverlay (full photo) auto-hides after 3 seconds of inactivity. Spec says "persists if mouse over overlay itself," but doesn't clarify behavior when user is actively clicking/interacting. +**Decision:** Option A - Use default queue, rely on worker scaling +**Rationale:** Simpler configuration, standard Laravel pattern, natural backpressure signaling. Operators scale worker count to meet 30-second consistency target. +**Updated in spec:** FR-003-02, JOB-003-01 -**Question:** Should the overlay stay visible while the user is actively interacting with the rating stars, even if they briefly move the mouse outside the overlay? +--- -**Options (ordered by preference):** +### ~~Q-003-02: Backfill Execution Strategy During Migration~~ ✅ RESOLVED -**Option A: Persist while loading, then restart auto-hide timer (Recommended)** -- **Behavior:** After user clicks a star, overlay stays visible during API call (loading state), then restarts 3s auto-hide timer on success -- **Pros:** - - User sees confirmation (success toast + updated rating) - - Natural flow (interact → see result → overlay fades) -- **Cons:** - - May stay visible longer than expected +**Decision:** Option A - Manual trigger after migration (with `lychee:` prefix requirement) +**Rationale:** Operator controls timing during maintenance window, migration completes quickly, aligns with dual-read fallback pattern. All Lychee commands use `lychee:` namespace. +**Updated in spec:** FR-003-06, CLI-003-01, Migration Strategy appendix +**ADR:** ADR-0003-album-computed-fields-precomputation.md -**Option B: Auto-hide immediately after successful rating** -- **Behavior:** After rating succeeds, overlay fades out immediately (no 3s delay) -- **Pros:** - - Faster cleanup after action - - User sees toast notification for confirmation -- **Cons:** - - Abrupt (overlay disappears right after click) - - User may not see updated average +--- -**Option C: Persist until mouse leaves lower area entirely** -- **Behavior:** Overlay stays visible as long as mouse is in lower 20-30% zone, regardless of timer -- **Pros:** - - User has full control - - Overlay available for multiple rating changes -- **Cons:** - - May linger too long - - Obstructs photo view longer +### ~~Q-003-03: Concurrent Album Mutation Deduplication~~ ✅ RESOLVED -**Impact:** Medium - affects UX polish and expected behavior +**Decision:** Option A - Laravel WithoutOverlapping middleware +**Rationale:** Built-in Laravel feature (same as Feature 002 Q-002-03), prevents wasted work, automatic lock release, simple implementation. +**Updated in spec:** FR-003-02, JOB-003-01 +**ADR:** ADR-0003-album-computed-fields-precomputation.md --- -### Q001-15: Rating Tooltip/Label Clarity (What Are Stars?) +### ~~Q-003-04: Cover Selection Race Condition Handling~~ ✅ RESOLVED -**Context:** UI mockups don't show tooltips or ARIA labels explaining what the star rating means (1 = lowest, 5 = highest). +**Decision:** Option A - Foreign key ON DELETE SET NULL (already in spec) +**Rationale:** Database handles automatically, simple, eventual consistency. Photo deletion events trigger recomputation for parent albums. +**Updated in spec:** FR-003-02 (added photo deletion event trigger), Migration Strategy appendix (FK constraint confirmed) -**Question:** Should we add tooltips/labels to explain the star rating scale? +--- -**Options (ordered by preference):** +### ~~Q-003-05: Propagation Chain Failure Handling~~ ✅ RESOLVED -**Option A: Hover tooltips on star buttons (Recommended)** -- **Implementation:** Each star button shows tooltip: "1 star", "2 stars", ... "5 stars" -- **Pros:** - - Self-explanatory on hover - - Accessible (screen reader friendly with aria-label) - - Doesn't clutter UI -- **Cons:** - - Requires tooltip implementation - - May be obvious to most users +**Decision:** Option A - Stop propagation, log error, manual recovery +**Rationale:** Prevents cascading errors, clear failure boundary, operator can investigate root cause before retrying via `lychee:recompute-album-stats`. +**Updated in spec:** FR-003-02, CLI-003-02 +**ADR:** ADR-0003-album-computed-fields-precomputation.md -**Option B: Label text: "Rate 1-5 stars"** -- **Implementation:** Static text label above star buttons -- **Pros:** - - Always visible, no hover needed - - Clear scale indication -- **Cons:** - - Takes up space in compact overlays - - May be redundant (stars are intuitive) +--- -**Option C: No labels/tooltips (stars are self-evident)** -- **Implementation:** No additional labels, star icons only -- **Pros:** - - Cleanest UI - - Stars are universal rating symbol -- **Cons:** - - Accessibility concerns (screen reader users) - - New users may not understand scale +### ~~Q-003-06: Soft-Deleted Photo Exclusion from Computations~~ ✅ RESOLVED -**Impact:** Medium - affects accessibility and UX clarity +**Decision:** N/A - Lychee does not use soft deletes +**Rationale:** Per user clarification, Lychee does not implement soft delete pattern for photos. Hard deletes only. +**Updated in spec:** FR-003-02 (removed soft-delete references) --- -### Q001-16: Accessibility (Keyboard Navigation, ARIA) +### ~~Q-003-07: NULL taken_at Handling in Min/Max Calculations~~ ✅ RESOLVED -**Context:** Spec doesn't specify keyboard navigation or ARIA attributes for rating components. +**Decision:** Option A - Ignore NULL taken_at, use SQL MIN/MAX directly +**Rationale:** Mirrors existing AlbumBuilder.php behavior (lines 111, 125). SQL MIN/MAX ignores NULLs by default. Semantically correct (taken_at unknown = exclude from range). +**Updated in spec:** FR-003-02 validation path -**Question:** What accessibility features should be implemented for the rating UI? +--- -**Options (ordered by preference):** +### ~~Q-003-08: Migration Rollback Strategy for Multi-Phase Deployment~~ ✅ RESOLVED -**Option A: Full WCAG 2.1 AA compliance (Recommended)** -- **Implementation:** - - Keyboard navigation: Tab to focus rating, Arrow keys to select star, Enter/Space to rate - - ARIA attributes: `role="radiogroup"`, `aria-label="Rate this photo"`, `aria-checked` on selected star - - Focus indicators: Visible outline on focused star - - Screen reader announcements: "4 stars selected, 15 total votes, average 4.2" -- **Pros:** - - Fully accessible to all users - - Meets legal/compliance requirements - - Better UX for keyboard users -- **Cons:** - - More implementation effort - - Testing complexity - -**Option B: Basic accessibility (tab focus, ARIA labels only)** -- **Implementation:** Tab to rating widget, click to rate, basic aria-labels -- **Pros:** - - Simpler implementation - - Covers most accessibility needs -- **Cons:** - - Not fully keyboard navigable - - May not meet WCAG AA - -**Option C: Defer to post-MVP** -- **Decision:** Launch with basic implementation, enhance accessibility later -- **Pros:** - - Faster to ship - - Can gather user feedback first -- **Cons:** - - Excludes users with disabilities - - Harder to retrofit later - - Potential compliance issues - -**Impact:** Medium - affects accessibility and inclusivity - ---- - -### Q001-17: Optimistic UI Updates vs Server Confirmation - -**Context:** Spec doesn't clarify whether UI should update optimistically (immediately on click) or wait for server confirmation. - -**Question:** Should the rating UI update optimistically or wait for API response? - -**Options (ordered by preference):** - -**Option A: Wait for server confirmation (Recommended)** -- **Behavior:** Show loading state on click, update UI only after API success -- **Pros:** - - Always shows accurate server state - - Clear error handling (revert on failure) - - No phantom updates -- **Cons:** - - Slower perceived responsiveness - - Requires loading state UI - -**Option B: Optimistic update, revert on error** -- **Behavior:** Update UI immediately on click, show error and revert if API fails -- **Pros:** - - Instant feedback, feels faster - - Better perceived performance -- **Cons:** - - Complex state management (revert logic) - - User may see incorrect state briefly - - Confusing if network is slow and revert happens seconds later - -**Option C: Hybrid (optimistic for user rating, wait for aggregate)** -- **Behavior:** Update user's star selection immediately, but wait for server to update average/count -- **Pros:** - - Fast feedback for user action - - Accurate aggregate display -- **Cons:** - - Split state management - - May show inconsistent state (user rating updated, aggregate unchanged) - -**Impact:** Medium - affects perceived performance and UX - ---- - -### Q001-18: Rating Count Threshold for Display - -**Context:** Spec doesn't specify if ratings should be hidden when count is very low (e.g., 1-2 ratings may not be statistically meaningful). - -**Question:** Should we hide average rating display until a minimum number of ratings exist? - -**Options (ordered by preference):** - -**Option A: Always show rating, regardless of count (Recommended)** -- **Display:** Show "★★★★★ 5.0 (1)" even for single rating -- **Pros:** - - Transparent, shows all data - - Simpler logic (no threshold) - - Users can judge significance from count -- **Cons:** - - Single ratings may be misleading (not representative) - - May encourage rating manipulation - -**Option B: Hide average until N >= 3 ratings** -- **Display:** Show "(3 ratings)" text only until 3+ ratings, then show average -- **Pros:** - - More statistically meaningful average - - Reduces impact of single outlier ratings -- **Cons:** - - Hides data from users - - Arbitrary threshold (why 3?) - - Users may be confused why they can't see average after rating - -**Option C: Show with disclaimer for low counts** -- **Display:** "★★★★★ 5.0 (1 rating)" with styling/tooltip: "Based on limited ratings" -- **Pros:** - - Shows data with context - - Users can make informed judgment -- **Cons:** - - More UI complexity - - May clutter compact overlays - -**Impact:** Medium - affects data presentation and perceived trustworthiness - ---- - -### Q001-19: Telemetry Event Granularity - -**Context:** Spec defines three telemetry events (photo.rated, photo.rating_updated, photo.rating_removed). These events overlap (updating is also rating). - -**Question:** Should we emit separate events for create vs update, or combine into one event? - -**Options (ordered by preference):** - -**Option A: Three separate events (as spec defines) (Recommended)** -- **Events:** `photo.rated` (new), `photo.rating_updated` (change), `photo.rating_removed` (delete) -- **Pros:** - - Granular analytics (can track rating changes separately from new ratings) - - Easier to query specific actions -- **Cons:** - - More event types to maintain - - Logic to determine which event to emit - -**Option B: Single event with action field** -- **Event:** `photo.rating_changed` with field `action: "created"|"updated"|"removed"` -- **Pros:** - - Simpler event schema - - Single event handler -- **Cons:** - - Less semantic - - Requires filtering by action field in analytics - -**Option C: Two events (rated/removed only)** -- **Events:** `photo.rated` (create or update), `photo.rating_removed` -- **Pros:** - - Simpler (updates are just "rated again") - - Matches user mental model (user doesn't distinguish create vs update) -- **Cons:** - - Can't track rating changes separately from new ratings - -**Impact:** Low - affects telemetry analytics, doesn't affect user experience +**Decision:** Option B - Full rollback with down() migration +**Rationale:** Clean schema restoration, simple one-command rollback. Trade-off: data loss if backfill ran, but values can be regenerated. Critical constraint: do NOT rollback after Phase 4 cleanup. +**Updated in spec:** FR-003-06, Migration Strategy appendix (new Rollback Strategy section) +**ADR:** ADR-0003-album-computed-fields-precomputation.md --- -### Q001-20: Rating Analytics/Trending Features - -**Context:** Spec explicitly excludes "advanced rating analytics or trends" from scope, but this may be a desirable future feature. - -**Question:** Should we design the schema and telemetry to support future analytics features (trending photos, rating distributions)? - -**Options (ordered by preference):** - -**Option A: Yes, design for extensibility (Recommended)** -- **Approach:** Include timestamps, consider adding indexes for common queries (ORDER BY rating_avg), design telemetry for time-series analysis -- **Pros:** - - Easier to add features later - - Better query performance from day 1 - - Minimal overhead now -- **Cons:** - - May add complexity that's never used - - YAGNI (You Aren't Gonna Need It) principle violation - -**Option B: No, implement minimally for current scope** -- **Approach:** Bare minimum schema/indexes for current requirements, add analytics support later if needed -- **Pros:** - - Simpler initial implementation - - Follows YAGNI principle - - Faster to ship -- **Cons:** - - May require schema changes later - - Migration complexity for existing data +### ~~Q-003-09: Multi-user Cover Selection Strategy for computed_cover_id~~ ✅ RESOLVED -**Impact:** Low - affects future extensibility, not current functionality +**Decision:** Option D - Store dual cover IDs with privilege-based selection (`auto_cover_id_max_privilege` and `auto_cover_id_least_privilege`) +**Rationale:** Balances performance (pre-computation) with security (no photo leakage). Two cover IDs stored per album: one for admin/owner view (max privilege), one for public view (least privilege). Display logic selects appropriate cover based on user permissions at query time (simple column read, no subquery). Simple schema (2 columns vs. per-user table), guaranteed safe (least-privilege cover never leaks private photos), good UX (admin/owner sees best possible cover). +**Updated in spec:** FR-003-01, FR-003-02, FR-003-04, FR-003-07, NFR-003-05, DO-003-03, DO-003-04, Migration Strategy, Cover Selection Logic appendix +**ADR:** ADR-0003-album-computed-fields-precomputation.md (to be updated with Q-003-09 resolution) --- -### Q001-21: Album Aggregate Rating Display - -**Context:** Spec excludes "album-level aggregate ratings" from scope, but users may expect to see album ratings in album grid view. - -**Question:** Should we display aggregate album ratings (average of all photo ratings in album)? - -**Options (ordered by preference):** - -**Option A: Defer to future feature (Recommended)** -- **Decision:** Not in scope for Feature 001, track as separate future feature (Feature 00X) -- **Pros:** - - Keeps current feature focused - - Can design properly later with user feedback on photo ratings -- **Cons:** - - Users may expect this feature - - More work to add later - -**Option B: Add to current feature scope** -- **Implementation:** Calculate album average from photo ratings, display in album grid -- **Pros:** - - Complete feature (photos + albums) - - More useful to users -- **Cons:** - - Increases scope significantly - - More complex queries (aggregate of aggregates) - - Unclear UX (what does album rating mean? average of photos? weighted by photo quality?) +### ~~Q-002-01: Worker Auto-Restart Queue Priority~~ ✅ RESOLVED -**Impact:** Low - out of current scope, but may be user expectation +**Decision:** Option A - Support multiple queue workers with priority via QUEUE_NAMES environment variable +**Rationale:** Allows time-sensitive jobs to be prioritized, standard Laravel pattern, operator flexibility. +**Updated in spec:** FR-002-02, DO-002-02, CLI-002-01, Spec DSL, Queue Connection Configuration appendix --- -### Q001-22: Rating Export in Photo Backup - -**Context:** Lychee supports photo export/backup functionality. Spec doesn't clarify if rating data should be included in exports. - -**Question:** Should photo export/backup include rating data (user's own rating and/or aggregates)? - -**Options (ordered by preference):** - -**Option A: Include in export (CSV/JSON format) (Recommended)** -- **Export fields:** photo_id, user's rating, average rating, rating count -- **Pros:** - - Complete data portability - - Users can back up their ratings - - Useful for data analysis outside Lychee -- **Cons:** - - Larger export files - - Privacy concerns if export is shared (includes others' aggregate data) - -**Option B: Export user's ratings only (not aggregates)** -- **Export fields:** photo_id, user's rating -- **Pros:** - - User data portability - - No privacy concerns (only user's own data) -- **Cons:** - - Incomplete export (aggregates lost) - -**Option C: No export (ratings are ephemeral/server-side only)** -- **Decision:** Ratings not included in photo exports -- **Pros:** - - Simpler export logic - - Smaller export files -- **Cons:** - - Data loss risk if server fails - - No migration path to other platforms +### ~~Q-002-02: Worker Max-Time Configurability~~ ✅ RESOLVED -**Impact:** Low - affects data portability, not core functionality +**Decision:** Option A - Configurable with sensible default via WORKER_MAX_TIME environment variable +**Rationale:** Operators can tune for their workload, no code changes needed to adjust restart interval. +**Updated in spec:** FR-002-02, DO-002-03, CLI-002-01, Spec DSL, Queue Connection Configuration appendix --- -### Q001-23: Rating Notification to Photo Owner - -**Context:** When other users rate a photo, the photo owner may want to be notified (similar to comment notifications). - -**Question:** Should photo owners receive notifications when their photos are rated? - -**Options (ordered by preference):** - -**Option A: Defer to future feature (notifications system) (Recommended)** -- **Decision:** Not in scope for Feature 001, add when notifications framework is implemented -- **Pros:** - - Keeps feature scope focused - - Requires notifications infrastructure (may not exist yet) - - Can be added non-intrusively later -- **Cons:** - - Photo owners won't know when photos are rated - - Lower engagement - -**Option B: Simple email notification** -- **Implementation:** Send email to photo owner when photo is rated (with throttling: max 1 email per photo per day) -- **Pros:** - - Engagement boost - - Photo owners stay informed -- **Cons:** - - Email fatigue (could get many emails) - - Requires email configuration - - Increases scope - -**Option C: In-app notification only (no email)** -- **Implementation:** Show notification bell/count in Lychee UI when photos are rated -- **Pros:** - - Less intrusive than email - - Real-time feedback when user is active -- **Cons:** - - Requires notification UI infrastructure - - User may miss notifications if not logged in +### ~~Q-002-03: Job Deduplication for Concurrent Mutations~~ ✅ RESOLVED -**Impact:** Low - nice-to-have feature, not core rating functionality +**Decision:** Option A - Laravel job middleware with deduplication using WithoutOverlapping +**Rationale:** Built-in Laravel feature, prevents wasted work, automatic lock release. +**Updated in spec:** NFR-002-05, Documentation Deliverables --- -### Q001-24: Statistics Recalculation Artisan Command - -**Context:** Implementation notes mention "artisan command to recalculate all statistics from photo_ratings table for data integrity audits." - -**Question:** Should we implement an artisan command to recalculate rating statistics, and if so, when should it be used? - -**Options (ordered by preference):** - -**Option A: Yes, implement `php artisan photos:recalculate-ratings` command (Recommended)** -- **Usage:** Run manually after data migration, database corruption, or as periodic audit -- **Behavior:** Iterate all photos, sum ratings from photo_ratings table, update photo_statistics -- **Pros:** - - Data integrity safety net - - Useful for debugging/auditing - - Can fix inconsistencies from bugs or manual DB edits -- **Cons:** - - Extra code to maintain - - May be slow on large databases - - Risk of overwriting correct data if command is buggy - -**Option B: No command, rely on transaction integrity** -- **Decision:** Trust atomic transactions to maintain consistency, no recalculation needed -- **Pros:** - - Simpler (less code) - - Transactions should guarantee consistency -- **Cons:** - - No recovery if bug causes inconsistency - - No way to audit/verify correctness - -**Option C: Automated periodic recalculation (cron job)** -- **Implementation:** Run recalculation command daily/weekly via scheduler -- **Pros:** - - Automatic data integrity maintenance - - Catches and fixes issues proactively -- **Cons:** - - Resource intensive (extra DB load) - - May mask underlying bugs instead of fixing them - - Overkill if transactions are working correctly +### ~~Q-002-04: Worker Healthcheck Failure Behavior~~ ✅ RESOLVED -**Impact:** Low - data integrity safety feature, not core functionality +**Decision:** Option B - Healthcheck tracks restart count, fail after 10 restarts in 5 minutes +**Rationale:** Orchestrator can restart container if worker is fundamentally broken, prevents infinite crash loops. +**Updated in spec:** FR-002-05 --- -### Q001-25: Migration Strategy for Existing Installations - -**Context:** When existing Lychee installations upgrade to this feature, they'll have photos but no rating data. Migration behavior isn't specified. - -**Question:** How should the migration handle existing photos with no rating data? - -**Options (ordered by preference):** - -**Option A: Migration adds columns with defaults, no backfill (Recommended)** -- **Behavior:** Migration adds rating_sum/rating_count columns with default 0, existing photos have no ratings -- **Pros:** - - Clean state (accurate: no ratings yet) - - Fast migration (no data processing) - - No assumptions about historical data -- **Cons:** - - Existing photos start with no ratings (expected behavior) - -**Option B: Backfill with random/seeded ratings (dev/test only)** -- **Behavior:** For development, optionally seed some random ratings for testing -- **Pros:** - - Easier to test rating display with real-looking data -- **Cons:** - - Fake data, not suitable for production - - Could confuse users if accidentally run in production - -**Option C: Import from external source (if available)** -- **Behavior:** If migrating from another system with ratings, provide import script -- **Pros:** - - Preserves historical rating data -- **Cons:** - - Complex, requires external data source - - Not applicable to most installations - - Out of scope for Feature 001 +### ~~Q001-01: Full-size Photo Overlay Positioning~~ ✅ RESOLVED -**Impact:** Low - affects upgrade experience, but default behavior (no ratings) is expected +**Decision:** Option A - Bottom-center +**Rationale:** Centered position is more discoverable and doesn't compete with Dock buttons. Symmetrical with metadata overlay below. +**Updated in spec:** FR-001-10, UI mockup section 2, implementation plan I9c/I9d --- -### ~~Q-004-01: Recomputation Trigger Strategy for Size Statistics~~ ✅ RESOLVED +### ~~Q001-02: Auto-hide Timer Duration~~ ✅ RESOLVED -**Decision:** Option B - Separate `RecomputeAlbumSizeJob` triggered independently, using Skip middleware with cache-based job tracking (same pattern as Feature 003's `RecomputeAlbumStatsJob`) -**Rationale:** Decoupled from Feature 003, can optimize independently, reuses proven Skip middleware pattern from [RecomputeAlbumStatsJob.php](app/Jobs/RecomputeAlbumStatsJob.php:76-93) with cache key `album_size_latest_job:{album_id}` and unique job IDs for deduplication. -**Updated in spec:** FR-004-02, JOB-004-01, middleware implementation details +**Decision:** Option A - 3 seconds +**Rationale:** Standard UX pattern, balanced duration (not too fast, not too slow). +**Updated in spec:** FR-001-10, UI mockup section 2, implementation plan I9c --- -### ~~Q-004-02: Migration/Backfill Strategy for Existing Albums~~ ✅ RESOLVED +### ~~Q001-03: Rating Removal Button Placement~~ ✅ RESOLVED -**Decision:** Option A - Separate artisan command, manual execution, PLUS maintenance UI button for operators -**Rationale:** Operator controls timing during maintenance window, fast migration (schema only), progress monitoring. Admin UI button provides convenient trigger for backfill without CLI access. -**Updated in spec:** FR-004-04, CLI-004-01, maintenance UI addition +**Decision:** Option A - Inline [0] button +**Rationale:** Consistent button pattern, simple implementation, shown as "×" or "Remove" for clarity. +**Updated in spec:** FR-001-09, UI mockup section 1, implementation plan I9a --- -### ~~Q-004-03: Job Deduplication Approach for Concurrent Updates~~ ✅ RESOLVED +### ~~Q001-04: Overlay Visibility on Mobile Devices~~ ✅ RESOLVED -**Decision:** Option D (Custom) - Use Skip middleware with cache-based job tracking (same pattern as Feature 003) -**Rationale:** Reuses proven pattern from [RecomputeAlbumStatsJob.php](app/Jobs/RecomputeAlbumStatsJob.php): Each job gets unique ID, latest job ID stored in cache with key `album_size_latest_job:{album_id}`, `Skip::when()` middleware checks if newer job queued. Simpler than `WithoutOverlapping`, guarantees most recent update eventually processes. -**Updated in spec:** FR-004-02, JOB-004-01 +**Decision:** Option A - Details drawer only on mobile +**Rationale:** Follows existing Lychee pattern (overlays are desktop-only), simple and consistent experience. +**Updated in spec:** FR-001-09, FR-001-10, UI mockup sections 1-2, implementation plan I9a/I9c --- -## How to Use This Document +### ~~Q001-05: Authorization Model for Rating~~ ✅ RESOLVED -1. **Log new questions:** Add a row to the Active Questions table with a unique ID (format: `Q###-##`), feature reference, priority (High/Medium), and brief summary. -2. **Add details:** Create a corresponding section under Question Details with: - - Full question context - - Options (A, B, C...) ordered by preference - - Pros/cons for each option - - Impact analysis -3. **Present to user:** Once logged, present the question inline in chat referencing the question ID. -4. **Resolve and remove:** When answered, update the relevant spec sections (and create ADR if high-impact), then delete both the table row and Question Details entry. +**Decision:** Option B - Read access (anyone who can view can rate) +**Rationale:** Follows standard rating system patterns. Rating is a lightweight engagement action similar to favoriting, not a privileged edit operation. Makes ratings more accessible and useful. +**Updated in spec:** FR-001-01, NFR-001-04 --- -*Last updated: 2026-03-15* - -### ~~Q-023-01: Remember-me Cookie Duration and Admin Configurability~~ ✅ RESOLVED +### ~~Q001-06: Rating Removal HTTP Status Code~~ ✅ RESOLVED -**Decision:** Option C — Use a shorter default (4 weeks) with env override -**Rationale:** A 4-week (40320 minutes) default is more security-conscious than Laravel's ~5-year default while still being practical for home/personal instances. The duration is configurable via `REMEMBER_LIFETIME` env variable, loaded by `config/auth.php` in the lychee guard config (`'remember' => (int) env('REMEMBER_LIFETIME', 40320)`). The existing `SessionOrTokenGuard::createGuard()` already reads this key via `setRememberDuration()`. No admin UI control — env/config only. -**Updated in spec:** Non-Goals (clarified no admin UI for duration), NFR-023-01 (cookie duration = 4 weeks default) +**Decision:** 200 OK (idempotent behavior) +**Rationale:** Removing a non-existent rating is a no-op and should return success (200 OK) rather than 404 error. This makes the endpoint idempotent and simpler to use. +**Updated in spec:** FR-001-02 --- -### ~~Q-030-01: Communication Protocol Between Python Face-Recognition Service and Lychee~~ ✅ RESOLVED - -**Feature:** 030 – Facial Recognition -**Priority:** High -**Status:** Resolved -**Opened:** 2026-03-15 - -**Resolution:** **Option A** — REST API with webhook callbacks. Lychee sends scan requests to the Python service's REST API; the Python service calls back to Lychee's `/api/v2/FaceDetection/results` endpoint when results are ready. - -**Rationale:** Simplest architecture, stateless, easy to debug, works with existing HTTP infrastructure. No additional broker dependencies. - -**Spec Impact:** FR-030-07, FR-030-08 confirmed with REST+callback pattern. Inter-service contract in spec appendix is authoritative. +### ~~Q001-07: Statistics Record Creation Strategy~~ ✅ RESOLVED -**Resolved:** 2026-03-15 +**Decision:** Option A - firstOrCreate in transaction +**Rationale:** Atomic operation with no race conditions, Laravel handles duplicate creation attempts automatically, simple implementation. +**Updated in spec:** Implementation plan I5 --- -### ~~Q-030-02: Face Detection Trigger Mechanism~~ ✅ RESOLVED - -**Feature:** 030 – Facial Recognition -**Priority:** High -**Status:** Resolved -**Opened:** 2026-03-15 - -**Resolution:** **Option A** — Multiple triggers: automatic on upload (via queue job), manual scan (photo/album), and admin bulk-scan command. - -**Rationale:** Covers all use cases. New photos auto-processed; existing libraries backfilled via bulk scan; manual scan for on-demand needs. - -**Spec Impact:** FR-030-08 (manual scan), FR-030-09 (bulk scan) confirmed. Auto-on-upload trigger added to plan as I7 sub-task. +### ~~Q001-08: Transaction Rollback Error Handling~~ ✅ RESOLVED -**Resolved:** 2026-03-15 +**Decision:** Option B - 409 Conflict for transaction errors +**Rationale:** More semantic HTTP status, indicates temporary issue that suggests retry, clearer to frontend. +**Updated in spec:** Implementation plan I5, I10 --- -### ~~Q-030-03: Face Clustering and Assignment Workflow~~ ✅ RESOLVED - -**Feature:** 030 – Facial Recognition -**Priority:** High -**Status:** Resolved -**Opened:** 2026-03-15 - -**Resolution:** **Option A** — Auto-cluster with manual confirmation. Python service clusters face embeddings and suggests groupings. Users review, name clusters (creating Person records), and can merge/split. Unknown faces grouped as "Unknown" until assigned. - -**Rationale:** Best balance of automation and user control. Leverages ML capability while keeping human in the loop. - -**Spec Impact:** Clustering result ingestion added to inter-service contract. UI for cluster review added to frontend increments. +### ~~Q001-09: N+1 Query Performance for user_rating~~ ✅ RESOLVED -**Resolved:** 2026-03-15 +**Decision:** Option A - Eager load with closure in controller +**Rationale:** Standard Laravel pattern, single additional query for all photos, no global scope side effects. +**Updated in spec:** Implementation plan I6 --- -### ~~Q-030-04: Face Embedding Storage Location~~ ✅ RESOLVED - -**Feature:** 030 – Facial Recognition -**Priority:** Medium -**Status:** Resolved -**Opened:** 2026-03-15 - -**Resolution:** **Option A** — Python service owns embeddings in its own storage. Lychee's `faces` table stores only bounding box, confidence, person_id, photo_id. No raw embedding data in Lychee DB. - -**Rationale:** Keeps Lychee DB lean; vector similarity search belongs in the Python service; clean separation of concerns. - -**Spec Impact:** DO-030-02 (Face) confirmed without embedding column. NFR-030-05 (versioned contract) covers embedding_id reference. +### ~~Q001-10: Concurrent Update Debouncing (Rapid Clicks)~~ ✅ RESOLVED -**Resolved:** 2026-03-15 +**Decision:** Option A - Disable stars during API call +**Rationale:** Simple implementation, prevents concurrent requests, clear visual feedback with loading state. +**Updated in spec:** Implementation plan I8, I9a, I9c --- -### ~~Q-030-05: "Non-Searchable" Person Semantics~~ ✅ RESOLVED - -**Feature:** 030 – Facial Recognition -**Priority:** Medium -**Status:** Resolved -**Opened:** 2026-03-15 - -**Resolution:** **Option A** — Non-searchable Person hidden from search results AND People browsing page for all users except the Person's linked User and admins. Faces still detected and stored internally. - -**Rationale:** Privacy-respecting; person can opt out of being discoverable; data remains available for the linked user and administrators. - -**Spec Impact:** FR-030-06 updated with full visibility rules. NFR-030-04 confirmed. S-030-05, S-030-15 test scenarios confirmed. +### ~~Q001-11: Metrics Disabled Behavior (Can Still Rate?)~~ ✅ RESOLVED -**Resolved:** 2026-03-15 +**Decision:** Option C - Admin setting controls independently +**Rationale:** Granular control allows enabling rating without showing aggregates, future-proof configuration. +**Updated in spec:** New config setting needed (separate `ratings_enabled` from `metrics_enabled`) --- -### ~~Q-030-06: Person-User Tie Purpose and Semantics~~ ✅ RESOLVED - -**Feature:** 030 – Facial Recognition -**Priority:** Medium -**Status:** Resolved -**Opened:** 2026-03-15 - -**Resolution:** **Option A (extended)** — Self-identification ("this Person is me") with two additions: -1. **Admin override:** Admins can link/unlink any Person-User pair, overriding user claims. -2. **Selfie-upload claim:** Users can upload a photo of themselves; the Python service matches the selfie against existing face embeddings to find and assign the matching Person record. - -**Rationale:** Self-identification enables privacy self-service and "find photos of me". Admin override provides governance. Selfie-upload leverages the face recognition service for convenient self-assignment without manual browsing. - -**Spec Impact:** FR-030-05 updated with admin override. New FR-030-12 added for selfie-upload claim flow. New API endpoint (API-030-13) and UI state (UI-030-07) added. Plan increment I5 extended with selfie-upload sub-tasks. +### ~~Q001-12: Rating Display When Metrics Disabled~~ ✅ RESOLVED -**Resolved:** 2026-03-15 +**Decision:** Option B - Hide all rating data when metrics disabled +**Rationale:** Fully consistent with metrics disabled setting, simplest implementation, respects admin preference. +**Updated in spec:** UI components conditional rendering --- -### ~~Q-030-07: How Does the Python Service Access Photo Files?~~ ✅ RESOLVED - -**Feature:** 030 – Facial Recognition -**Priority:** High -**Status:** Resolved -**Opened:** 2026-03-15 - -**Resolution:** **Option A** — Shared Docker volume. Both containers mount the same storage volume. The scan request includes a `photo_path` (filesystem path) instead of a URL. Python service reads directly from disk. - -**Rationale:** Fastest access; no auth complexity; works with private photos; no network overhead. Deployment requires both containers to share the photos volume. - -**Spec Impact:** Inter-service contract updated: `photo_url` replaced with `photo_path` in scan request. Deployment docs must specify shared volume configuration. NFR added for S3/remote storage documentation (FUSE mount or alternative). +### ~~Q001-13: Half-Star Display for Fractional Averages~~ ✅ RESOLVED -**Resolved:** 2026-03-15 +**Decision:** Option B - Half-star display using PrimeVue icons +**Rationale:** PrimeVue provides pi-star, pi-star-fill, pi-star-half, pi-star-half-fill icons. More precise visual representation, common rating pattern. +**Updated in spec:** UI mockups, component implementation uses PrimeVue star icons --- -### ~~Q-030-08: Permission Model for People/Face Operations~~ ✅ RESOLVED - -**Feature:** 030 – Facial Recognition -**Priority:** High -**Status:** Resolved -**Opened:** 2026-03-15 - -**Resolution:** **Option C** — Configurable via admin setting (`face_recognition_permission_mode`). Two modes: -- **"open"** (default): Any authenticated user can perform all CRUD/assign/merge operations. Only bulk scan restricted to admin. -- **"restricted"**: Photo-owner-centric with admin escalation: - - Create Person: Any authenticated user. - - Update/Delete Person: Linked User, creator, or admin. - - Assign Face: Photo owner or admin. - - Trigger scan: Photo/album owner or admin. - - Bulk scan: Admin only. - - Merge Persons: Admin only. - - Claim Person: Any authenticated user. - -**Rationale:** Accommodates both single-user/family instances (open mode) and multi-user deployments (restricted mode). Default is "open" since most Lychee instances are single-user. - -**Spec Impact:** New config entry `face_recognition_permission_mode` (enum: open, restricted). FR-030-05/08/10/11 updated with conditional authorization. New NFR for permission mode testing (both modes covered by feature tests). +### ~~Q001-14: Overlay Persistence on Active Interaction~~ ✅ RESOLVED -**Resolved:** 2026-03-15 +**Decision:** Option A - Persist while loading, then restart auto-hide timer +**Rationale:** User sees confirmation (success toast + updated rating), natural interaction flow. +**Updated in spec:** Implementation plan I9c, PhotoRatingOverlay behavior --- -### ~~Q-030-09: Face Crop Thumbnail Generation~~ ✅ RESOLVED - -**Feature:** 030 – Facial Recognition -**Priority:** High -**Status:** Resolved -**Opened:** 2026-03-15 - -**Resolution:** **Option B** — Server-side crop stored as a new asset. The Python service generates a cropped face thumbnail (150x150px) during face detection and includes it in the scan result callback. The crop is stored alongside size variants. The Face record includes a `crop_path` field. - -**Rationale:** Crisp thumbnails optimized for People page grid; fast rendering from small pre-generated files; Python service already has the image loaded during detection so the crop is essentially free. - -**Spec Impact:** DO-030-02 (Face) gains `crop_path` field. Inter-service contract updated: scan result includes `crop` (base64 JPEG) per face. New migration adds `crop_path` to faces table. I16 Python service includes crop generation. +### ~~Q001-15: Rating Tooltip/Label Clarity~~ ✅ RESOLVED -**Resolved:** 2026-03-15 +**Decision:** Option C - No labels/tooltips (stars are self-evident) +**Rationale:** Cleanest UI, stars are universal rating symbol, keeps overlays compact. +**Updated in spec:** UI components (no tooltip implementation needed) --- -### ~~Q-030-10: Non-Searchable Person Face Overlay Behavior~~ ✅ RESOLVED - -**Feature:** 030 – Facial Recognition -**Priority:** Medium -**Status:** Resolved -**Opened:** 2026-03-15 - -**Resolution:** **Option B (extended)** — Hide the overlay entirely for non-searchable persons, but include a summary indicator: "N faces detected but hidden for privacy reasons" displayed below the photo or in the faces info bar. The count does not reveal which specific persons are hidden. - -**Rationale:** Maximum privacy — no hint about which specific face was identified. The summary count maintains transparency about face detection having occurred without leaking person-specific data. - -**Spec Impact:** FR-030-04 updated: photo detail response excludes Face records for non-searchable persons (for unauthorized viewers), but includes `hidden_face_count` (integer). Frontend displays "{N} face(s) hidden for privacy" when count > 0. NFR-030-04 test cases updated. +### ~~Q001-16: Accessibility (Keyboard Navigation, ARIA)~~ ✅ RESOLVED -**Resolved:** 2026-03-15 +**Decision:** Option C - Defer to post-MVP +**Rationale:** Ship faster with basic implementation, gather user feedback first, can enhance accessibility later. +**Updated in spec:** Out of scope (deferred enhancement) --- -### ~~Q-030-11: Selfie Image Lifecycle~~ ✅ RESOLVED - -**Feature:** 030 – Facial Recognition -**Priority:** Medium -**Status:** Resolved -**Opened:** 2026-03-15 - -**Resolution:** **Option A** — Discard immediately after match. The selfie is held in memory/temp storage only during the matching request. Once the Python service returns its result, the image is deleted. No permanent record. - -**Rationale:** Privacy-friendly; no unnecessary data retention; simpler storage. Users can re-upload if they want to retry. - -**Spec Impact:** FR-030-12 confirmed: selfie is transient. No storage schema changes needed for selfie retention. Implementation uses temp file or in-memory buffer. +### ~~Q001-17: Optimistic UI Updates vs Server Confirmation~~ ✅ RESOLVED -**Resolved:** 2026-03-15 +**Decision:** Option A - Wait for server confirmation +**Rationale:** Always shows accurate server state, clear error handling, no phantom updates. +**Updated in spec:** Implementation plan I8, I9a, I9c (loading state pattern) --- -### ~~Q-030-12: Selfie Match Inter-Service Contract~~ ✅ RESOLVED - -**Feature:** 030 – Facial Recognition -**Priority:** Medium -**Status:** Resolved -**Opened:** 2026-03-15 - -**Resolution:** **Option A** — Dedicated match endpoint on Python service. `POST /match` accepts an image file (multipart) and returns top-N matching embedding references with confidence scores. - -Contract: -```json -// Request: POST /match (multipart form with "image" file field) -// Response: -{ - "matches": [ - { "embedding_id": "emb_001", "person_suggestion": "cluster_42", "confidence": 0.963 }, - { "embedding_id": "emb_002", "person_suggestion": "cluster_17", "confidence": 0.412 } - ] -} -``` - -Lychee maps `embedding_id` back to Face records (which have person_id) to identify the matching Person. The `person_suggestion` field is advisory (from clustering) and may be null. - -**Rationale:** Clean separation; Python service owns matching logic; single round trip; Lychee just consumes results. - -**Spec Impact:** Inter-service contract appendix updated with `/match` endpoint. I17 Python service implements the endpoint. I5 Lychee SelfieClaimController consumes it. +### ~~Q001-18: Rating Count Threshold for Display~~ ✅ RESOLVED -**Resolved:** 2026-03-15 +**Decision:** Option A - Always show rating, regardless of count +**Rationale:** Transparent, simpler logic, users can judge significance from count displayed. +**Updated in spec:** UI components (no threshold logic needed) --- -### ~~Q-033-01: Monitor Trust Level Behaviour~~ ✅ RESOLVED - -**Feature:** 033 – Upload Trust Level -**Priority:** High -**Status:** Resolved -**Opened:** 2026-04-09 - -**Resolution:** **Option A** — Photos from `monitor`-level users are immediately validated (public), but flagged for periodic admin review. A separate "monitoring queue" shows recently uploaded photos from `monitor` users for the admin to spot-check. No photos are hidden; this is a soft-audit mechanism. - -**Spec Impact:** Updated FR-033-03 to clarify that `monitor` behaves as `trusted` (uploads immediately validated) in this iteration. The monitoring queue is deferred to a follow-up. Updated Non-Goals and Appendix Trust Level Decision Matrix. +### ~~Q001-19: Telemetry Event Granularity~~ ✅ RESOLVED -**Resolved:** 2026-04-09 +**Decision:** No telemetry events / analytics +**Rationale:** Feature does not include telemetry or analytics tracking. +**Updated in spec:** Remove telemetry events from FR-001-01, FR-001-02, FR-001-03 --- -### ~~Q-033-02: Retroactive Trust Level Changes~~ ✅ RESOLVED - -**Feature:** 033 – Upload Trust Level -**Priority:** Medium -**Status:** Resolved -**Opened:** 2026-04-09 - -**Resolution:** **Option A** — No retroactive changes. Only future uploads are affected by the new trust level. Existing photos retain their `is_validated` status. This is the simplest and safest approach. - -**Spec Impact:** Confirmed as a non-goal in spec.md. No additional follow-up tasks needed. +### ~~Q001-20: Rating Analytics/Trending Features~~ ✅ RESOLVED -**Resolved:** 2026-04-09 +**Decision:** Option B - Implement minimally for current scope +**Rationale:** Follows YAGNI principle, simpler initial implementation, faster to ship. +**Updated in spec:** Out of scope (no future analytics preparation) --- -### ~~Q-033-03: Admin Photo Uploads and Trust Level~~ ✅ RESOLVED - -**Feature:** 033 – Upload Trust Level -**Priority:** Medium -**Status:** Resolved -**Opened:** 2026-04-09 - -**Resolution:** **Option A** — Admin uploads are always immediately validated (`is_validated = true`) regardless of the admin's `upload_trust_level` setting. Admins are inherently trusted — they can approve their own photos anyway. The `SetUploadValidated` pipe checks `may_administrate` first and short-circuits to `true`. - -**Spec Impact:** Updated FR-033-03 to explicitly state that admin uploads bypass trust level checks. Updated Appendix Trust Level Decision Matrix. Updated task T-033-07 to include the admin short-circuit logic. +### ~~Q001-21: Album Aggregate Rating Display~~ ✅ RESOLVED -**Resolved:** 2026-04-09 +**Decision:** Option A - Defer to future feature +**Rationale:** Keeps current feature focused, can design properly later with user feedback on photo ratings. +**Updated in spec:** Out of scope, potential future Feature 00X --- -### ~~Q-034-01: TagAlbum Rows in Bulk Edit List~~ ✅ RESOLVED - -**Feature:** 034 – Bulk Album Edit -**Priority:** Medium -**Status:** Resolved -**Opened:** 2026-04-12 -**Resolved:** 2026-04-14 - -**Resolution:** **Option A** — Show only regular `Album` records (no TagAlbums). The list query joins only the `albums` table. A note on the page explains TagAlbums are excluded. +### ~~Q001-22: Rating Export in Photo Backup~~ ✅ RESOLVED -**Spec Impact:** FR-034-01 clarified; plan and tasks updated to confirm only `albums` table is queried. +**Decision:** Option C - No export (ratings are ephemeral/server-side only) +**Rationale:** Simpler export logic, smaller export files. +**Updated in spec:** Out of scope (no export functionality) --- -### ~~Q-034-02: Depth Indicator Computation Strategy~~ ✅ RESOLVED - -**Feature:** 034 – Bulk Album Edit -**Priority:** Medium -**Status:** Resolved -**Opened:** 2026-04-12 -**Resolved:** 2026-04-14 - -**Resolution:** **Option B** — Compute depth client-side, **linearly**, by scanning `_lft` values in descending order. The server returns `_lft` in each `BulkAlbumResource` row (already included). The frontend performs a single O(n) pass over the sorted-by-`_lft` result set: maintain a stack of ancestor `_rgt` values; pop the stack whenever the current row's `_lft` exceeds the stack top's `_rgt`; depth = stack length. This avoids an extra server-side `withDepth()` join and keeps computation in the client where the full page of records is already available. +### ~~Q001-23: Rating Notification to Photo Owner~~ ✅ RESOLVED -**Spec Impact:** `BulkAlbumResource` includes `_lft` and `_rgt` (not `depth`). FR-034-14 updated to specify client-side linear depth computation. T-034-03 and T-034-17 updated accordingly. +**Decision:** Option A - Defer to future feature (notifications system) +**Rationale:** Keeps feature scope focused, requires notifications infrastructure that may not exist yet. +**Updated in spec:** Out of scope (deferred to future notifications feature) --- -### ~~Q-034-03: Confirmation for Bulk Delete~~ ✅ RESOLVED - -**Feature:** 034 – Bulk Album Edit -**Priority:** High -**Status:** Resolved -**Opened:** 2026-04-12 -**Resolved:** 2026-04-14 - -**Resolution:** **Option B** — Bulk delete shows a minimal confirmation modal: a dialog displaying the count of selected albums and requiring the admin to click a second **"Confirm Delete"** button. No text input required. This is consistent with the spirit of the no-confirmation rule (field edits apply immediately) while protecting against accidental mass-delete. +### ~~Q001-24: Statistics Recalculation Artisan Command~~ ✅ RESOLVED -**Spec Impact:** FR-034-10 updated to require confirmation modal for delete. UI-034-05 (delete confirmation dialog state) added. T-034-20 updated. +**Decision:** Option B - No command, rely on transaction integrity +**Rationale:** Trust atomic transactions to maintain consistency, simpler implementation. +**Updated in spec:** Out of scope (no artisan command) --- -### ~~Q-034-04: Scope of "Select All Matching"~~ ✅ RESOLVED - -**Feature:** 034 – Bulk Album Edit -**Priority:** Low -**Status:** Resolved -**Opened:** 2026-04-12 -**Resolved:** 2026-04-14 - -**Resolution:** **Option A** — Return all albums in the gallery regardless of owner. Admin page; admin has authority over all albums. +### ~~Q001-25: Migration Strategy for Existing Installations~~ ✅ RESOLVED -**Spec Impact:** FR-034-12 confirmed: no owner filter applied on `GET /BulkAlbumEdit::ids`. +**Decision:** Option A - Migration adds columns with defaults, no backfill +**Rationale:** Clean state (accurate: no ratings yet), fast migration, no assumptions about historical data. +**Updated in spec:** Implementation plan I1 (migrations with default values) --- -### ~~Q-035-01: Behaviour of GET /Zip (no chunk param) when chunked mode is ON~~ ✅ RESOLVED - -**Feature:** 035 – Chunked Archive Download -**Priority:** Medium -**Status:** Resolved -**Opened:** 2026-04-12 - -**Context:** When `download_archive_chunked` is enabled, a client that calls `GET /Zip` without a `chunk` parameter may be a legacy client or an incorrect integration. We need a defined contract for this case. - -**Resolution:** **Option A** — Treat missing `chunk` as a regular single-archive download, regardless of the chunked-mode setting. This is backward-compatible: legacy frontends and direct URL downloads work without modification. - -**Spec Impact:** Encoded in FR-035-05 and FR-035-07. - -**Resolved:** 2026-04-12 diff --git a/docs/specs/4-architecture/roadmap.md b/docs/specs/4-architecture/roadmap.md index 2199164ffc1..2602f7ad3f8 100644 --- a/docs/specs/4-architecture/roadmap.md +++ b/docs/specs/4-architecture/roadmap.md @@ -6,6 +6,7 @@ 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. | ## Paused Features @@ -20,7 +21,6 @@ High-level planning document for Lychee features and architectural initiatives. | 037 | Admin Dashboard & `/admin/` URL Reorg | 2026-04-22 | Config migration (`use_admin_dashboard` toggle), `AdminStatsService` with 5-min cache, `GET /api/v2/Admin/Stats` endpoint, 9 admin views relocated to `views/admin/`, `AdminDashboard.vue` tile grid + stats panel + Refresh, left-menu collapse toggle, 22-locale i18n, 13 backend tests passing, TypeScript/PHPStan clean. | | 034 | Bulk Album Edit | 2026-04-12 | Spec, plan, tasks drafted. 25 tasks across 11 increments (I1 backend scaffold, I2-I6 REST endpoints, I7-I10 frontend, I11 quality gates). 4 open questions (Q-034-01 to Q-034-04; 1 high, 2 medium, 1 low). Ready to begin T-034-01 once Q-034-03 resolved. | | 032 | Security Advisories Check | 2026-04-06 | Spec, plan, tasks drafted. 18 tasks across 6 increments (I1 config/DTO, I2 fetch service, I3 diagnostic pipe, I4 REST endpoint, I5 frontend modal, I6 quality gates). All open questions resolved in spec. Ready to begin T-032-01. | -| 030 | AI Vision Service | 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. | | 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. | | 026 | Album Photo Tag Filter | 2026-03-09 | Spec, plan, tasks complete. 76 tasks across 9 increments (~32h estimated). All open questions resolved. Ready to begin Task 1.1. | diff --git a/lang/ar/dialogs.php b/lang/ar/dialogs.php index 7b3f0b09a3e..ac900943947 100644 --- a/lang/ar/dialogs.php +++ b/lang/ar/dialogs.php @@ -57,7 +57,8 @@ 'move' => 'نقل الصورة', 'delete' => 'حذف الصورة', 'edit' => 'تعديل المعلومات', - 'show_hide_meta' => 'إظهار المعلومات', + 'show_hide_meta' => 'Show information', + 'toggle_face_overlay' => 'Toggle face overlay', 'keep_hidden' => 'سنحافظ على إخفائها.', 'button_hidden' => 'سنخفي الزر في الرأس.', ], diff --git a/lang/ar/gallery.php b/lang/ar/gallery.php index eddbbc304d0..43c587895fa 100644 --- a/lang/ar/gallery.php +++ b/lang/ar/gallery.php @@ -317,6 +317,8 @@ 'merge_all' => 'دمج المحدد', 'apply_renamer' => 'Apply Renamer', 'apply_renamer_all' => 'Apply Renamer to Selected', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'approve' => 'Approve', 'approve_all' => 'Approve Selected', 'upload_photo' => 'رفع صورة', diff --git a/lang/ar/left-menu.php b/lang/ar/left-menu.php index 7f42f8b1ae8..d4d2771f619 100644 --- a/lang/ar/left-menu.php +++ b/lang/ar/left-menu.php @@ -24,5 +24,6 @@ 'support' => 'الدعم', 'contact' => 'Contact', 'messages' => 'Messages', + 'people' => 'People', 'webhooks' => 'Webhooks', ]; diff --git a/lang/ar/maintenance.php b/lang/ar/maintenance.php index 4f4f2e7fea2..03536d30a2f 100644 --- a/lang/ar/maintenance.php +++ b/lang/ar/maintenance.php @@ -100,4 +100,56 @@ 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:recompute-album-sizes', 'button' => 'Compute sizes', ], -]; + + 'face_quality' => [ + 'title' => 'Face Quality Review', + 'description' => 'Review face detections by quality score and dismiss low-quality or erroneous faces.', + 'sort_by' => 'Sort by:', + 'sort_confidence' => 'Confidence', + 'sort_blur' => 'Blur (Laplacian)', + 'no_faces' => 'No qualifying faces. Everything looks good!', + 'col_face' => 'Face', + 'col_person' => 'Person', + 'col_cluster' => 'Cluster', + 'col_confidence' => 'Confidence', + 'col_blur' => 'Blur Score', + 'col_actions' => 'Actions', + 'unassigned' => 'Unassigned', + 'dismiss' => 'Dismiss face', + 'load_error' => 'Failed to load faces.', + 'dismissed' => 'Face dismissed.', + 'dismiss_error' => 'Failed to dismiss face.', + 'batch_dismiss' => 'Dismiss selected', + 'batch_dismissed' => ':count face(s) dismissed.', + 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'selected_count' => ':count selected', + ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d 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.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], + + ]; diff --git a/lang/ar/people.php b/lang/ar/people.php new file mode 100644 index 00000000000..fc46a8dfa68 --- /dev/null +++ b/lang/ar/people.php @@ -0,0 +1,91 @@ + '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', + 'laplacian_variance' => 'Laplacian Variance', + '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.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'remove_from_person' => 'Remove from person', + 'remove_from_person_success' => 'Face removed successfully.', + 'batch_select' => 'Select faces', + 'batch_cancel' => 'Cancel selection', + 'batch_selected' => ':count selected', + 'batch_assign' => 'Assign selected', + 'batch_unassign' => 'Unassign selected', + 'assign_to_user' => 'Assign to user', + 'search_user' => 'Search user…', + 'cluster_detail_title' => 'Cluster (:count faces)', + 'assigned_faces_to' => 'Assigned :count face(s) to ":name"', + 'assigned_faces' => 'Assigned :count face(s)', + 'dismissed_faces' => 'Dismissed :count face(s)', + 'clustering_started' => 'Clustering started. Reload when complete.', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/lang/bg/dialogs.php b/lang/bg/dialogs.php index 972e2c5e988..b0c1e91ad06 100644 --- a/lang/bg/dialogs.php +++ b/lang/bg/dialogs.php @@ -57,7 +57,8 @@ 'move' => 'Премести снимката', 'delete' => 'Изтрий снимката', 'edit' => 'Редактирай информацията', - 'show_hide_meta' => 'Покажи информация', + 'show_hide_meta' => 'Show information', + 'toggle_face_overlay' => 'Toggle face overlay', 'keep_hidden' => 'Ще го запазим скрито.', 'button_hidden' => 'Бутонът ще бъде скрит от заглавната лента.', ], diff --git a/lang/bg/gallery.php b/lang/bg/gallery.php index 0271040c022..898b3459e28 100644 --- a/lang/bg/gallery.php +++ b/lang/bg/gallery.php @@ -317,6 +317,8 @@ 'merge_all' => 'Слей избраните', 'apply_renamer' => 'Apply Renamer', 'apply_renamer_all' => 'Apply Renamer to Selected', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'approve' => 'Approve', 'approve_all' => 'Approve Selected', 'upload_photo' => 'Качи снимка', diff --git a/lang/bg/left-menu.php b/lang/bg/left-menu.php index 5c686ddbd36..99354a7c114 100644 --- a/lang/bg/left-menu.php +++ b/lang/bg/left-menu.php @@ -24,5 +24,6 @@ 'support' => 'Поддръжка', 'contact' => 'Contact', 'messages' => 'Messages', + 'people' => 'People', 'webhooks' => 'Webhooks', ]; diff --git a/lang/bg/maintenance.php b/lang/bg/maintenance.php index 8fe36ef907d..868221c7833 100644 --- a/lang/bg/maintenance.php +++ b/lang/bg/maintenance.php @@ -100,4 +100,56 @@ 'description' => 'Открити са %d албума без статистика за размера.

Еквивалентно на изпълнението на: php artisan lychee:recompute-album-sizes', 'button' => 'Изчисли размерите', ], -]; + + 'face_quality' => [ + 'title' => 'Face Quality Review', + 'description' => 'Review face detections by quality score and dismiss low-quality or erroneous faces.', + 'sort_by' => 'Sort by:', + 'sort_confidence' => 'Confidence', + 'sort_blur' => 'Blur (Laplacian)', + 'no_faces' => 'No qualifying faces. Everything looks good!', + 'col_face' => 'Face', + 'col_person' => 'Person', + 'col_cluster' => 'Cluster', + 'col_confidence' => 'Confidence', + 'col_blur' => 'Blur Score', + 'col_actions' => 'Actions', + 'unassigned' => 'Unassigned', + 'dismiss' => 'Dismiss face', + 'load_error' => 'Failed to load faces.', + 'dismissed' => 'Face dismissed.', + 'dismiss_error' => 'Failed to dismiss face.', + 'batch_dismiss' => 'Dismiss selected', + 'batch_dismissed' => ':count face(s) dismissed.', + 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'selected_count' => ':count selected', + ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d 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.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], + + ]; diff --git a/lang/bg/people.php b/lang/bg/people.php new file mode 100644 index 00000000000..fc46a8dfa68 --- /dev/null +++ b/lang/bg/people.php @@ -0,0 +1,91 @@ + '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', + 'laplacian_variance' => 'Laplacian Variance', + '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.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'remove_from_person' => 'Remove from person', + 'remove_from_person_success' => 'Face removed successfully.', + 'batch_select' => 'Select faces', + 'batch_cancel' => 'Cancel selection', + 'batch_selected' => ':count selected', + 'batch_assign' => 'Assign selected', + 'batch_unassign' => 'Unassign selected', + 'assign_to_user' => 'Assign to user', + 'search_user' => 'Search user…', + 'cluster_detail_title' => 'Cluster (:count faces)', + 'assigned_faces_to' => 'Assigned :count face(s) to ":name"', + 'assigned_faces' => 'Assigned :count face(s)', + 'dismissed_faces' => 'Dismissed :count face(s)', + 'clustering_started' => 'Clustering started. Reload when complete.', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/lang/cz/dialogs.php b/lang/cz/dialogs.php index 4024bde89a1..d96bea4648e 100644 --- a/lang/cz/dialogs.php +++ b/lang/cz/dialogs.php @@ -57,7 +57,8 @@ 'move' => 'Přesunout foto', 'delete' => 'Smazat foto', 'edit' => 'Upravit informace', - 'show_hide_meta' => 'Zobrazit informace', + 'show_hide_meta' => 'Show information', + 'toggle_face_overlay' => 'Toggle face overlay', 'keep_hidden' => 'Zůstane to skryté.', 'button_hidden' => 'Skrýt tlačítko v hlavičče.', ], diff --git a/lang/cz/gallery.php b/lang/cz/gallery.php index 856f2f9d877..85eb9e29f24 100644 --- a/lang/cz/gallery.php +++ b/lang/cz/gallery.php @@ -319,6 +319,8 @@ 'approve_all' => 'Schválit vybrané', 'apply_renamer' => 'Použít přejmenování', 'apply_renamer_all' => 'Použít přejmenování na vybrané', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'upload_photo' => 'Nahrát fotografii', 'take_photo' => 'Vyfotit', 'import_link' => 'Importovat z odkazu', diff --git a/lang/cz/left-menu.php b/lang/cz/left-menu.php index 8c3de716a2f..3bd22710cb5 100644 --- a/lang/cz/left-menu.php +++ b/lang/cz/left-menu.php @@ -24,6 +24,6 @@ 'support' => 'Podpora', 'contact' => 'Kontakt', 'messages' => 'Zprávy', + 'people' => 'People', 'webhooks' => 'Webhooky', ]; - diff --git a/lang/cz/maintenance.php b/lang/cz/maintenance.php index 1a711023f2f..74a3c520596 100644 --- a/lang/cz/maintenance.php +++ b/lang/cz/maintenance.php @@ -101,4 +101,56 @@ 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:recompute-album-sizes', 'button' => 'Compute sizes', ], -]; + + 'face_quality' => [ + 'title' => 'Face Quality Review', + 'description' => 'Review face detections by quality score and dismiss low-quality or erroneous faces.', + 'sort_by' => 'Sort by:', + 'sort_confidence' => 'Confidence', + 'sort_blur' => 'Blur (Laplacian)', + 'no_faces' => 'No qualifying faces. Everything looks good!', + 'col_face' => 'Face', + 'col_person' => 'Person', + 'col_cluster' => 'Cluster', + 'col_confidence' => 'Confidence', + 'col_blur' => 'Blur Score', + 'col_actions' => 'Actions', + 'unassigned' => 'Unassigned', + 'dismiss' => 'Dismiss face', + 'load_error' => 'Failed to load faces.', + 'dismissed' => 'Face dismissed.', + 'dismiss_error' => 'Failed to dismiss face.', + 'batch_dismiss' => 'Dismiss selected', + 'batch_dismissed' => ':count face(s) dismissed.', + 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'selected_count' => ':count selected', + ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d 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.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], + + ]; diff --git a/lang/cz/people.php b/lang/cz/people.php new file mode 100644 index 00000000000..fc46a8dfa68 --- /dev/null +++ b/lang/cz/people.php @@ -0,0 +1,91 @@ + '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', + 'laplacian_variance' => 'Laplacian Variance', + '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.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'remove_from_person' => 'Remove from person', + 'remove_from_person_success' => 'Face removed successfully.', + 'batch_select' => 'Select faces', + 'batch_cancel' => 'Cancel selection', + 'batch_selected' => ':count selected', + 'batch_assign' => 'Assign selected', + 'batch_unassign' => 'Unassign selected', + 'assign_to_user' => 'Assign to user', + 'search_user' => 'Search user…', + 'cluster_detail_title' => 'Cluster (:count faces)', + 'assigned_faces_to' => 'Assigned :count face(s) to ":name"', + 'assigned_faces' => 'Assigned :count face(s)', + 'dismissed_faces' => 'Dismissed :count face(s)', + 'clustering_started' => 'Clustering started. Reload when complete.', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/lang/de/dialogs.php b/lang/de/dialogs.php index ecb35503b4c..4ae5cfbb009 100644 --- a/lang/de/dialogs.php +++ b/lang/de/dialogs.php @@ -57,7 +57,8 @@ 'move' => 'Foto verschieben', 'delete' => 'Foto löschen', 'edit' => 'Informationen bearbeiten', - 'show_hide_meta' => 'Informationen anzeigen', + 'show_hide_meta' => 'Show information', + 'toggle_face_overlay' => 'Toggle face overlay', 'keep_hidden' => 'Es wird verborgen bleiben.', 'button_hidden' => 'Schaltfläche im Header wird ausgeblendet.', ], diff --git a/lang/de/gallery.php b/lang/de/gallery.php index d4e8ca9c3cd..5edcb23dc4f 100644 --- a/lang/de/gallery.php +++ b/lang/de/gallery.php @@ -317,6 +317,8 @@ 'merge_all' => 'Auswahl zusammenführen', 'apply_renamer' => 'Umbenennungsregeln auf Auswahl anwenden', 'apply_renamer_all' => 'Apply Renamer to Selected', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'approve' => 'Approve', 'approve_all' => 'Approve Selected', 'upload_photo' => 'Foto hochladen', diff --git a/lang/de/left-menu.php b/lang/de/left-menu.php index cc9df460805..cbde6ed5611 100644 --- a/lang/de/left-menu.php +++ b/lang/de/left-menu.php @@ -24,5 +24,6 @@ 'support' => 'Support', 'contact' => 'Kontakt', 'messages' => 'Nachrichten', + 'people' => 'People', 'webhooks' => 'Webhooks', ]; diff --git a/lang/de/maintenance.php b/lang/de/maintenance.php index 72a24dfdfe9..da79b792d26 100644 --- a/lang/de/maintenance.php +++ b/lang/de/maintenance.php @@ -100,4 +100,56 @@ 'description' => 'Es wurden %d Alben ohne Größenstatistik gefunden.

Entspricht dem Befehl: php artisan lychee:recompute-album-sizes', 'button' => 'Größen berechnen', ], -]; + + 'face_quality' => [ + 'title' => 'Face Quality Review', + 'description' => 'Review face detections by quality score and dismiss low-quality or erroneous faces.', + 'sort_by' => 'Sort by:', + 'sort_confidence' => 'Confidence', + 'sort_blur' => 'Blur (Laplacian)', + 'no_faces' => 'No qualifying faces. Everything looks good!', + 'col_face' => 'Face', + 'col_person' => 'Person', + 'col_cluster' => 'Cluster', + 'col_confidence' => 'Confidence', + 'col_blur' => 'Blur Score', + 'col_actions' => 'Actions', + 'unassigned' => 'Unassigned', + 'dismiss' => 'Dismiss face', + 'load_error' => 'Failed to load faces.', + 'dismissed' => 'Face dismissed.', + 'dismiss_error' => 'Failed to dismiss face.', + 'batch_dismiss' => 'Dismiss selected', + 'batch_dismissed' => ':count face(s) dismissed.', + 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'selected_count' => ':count selected', + ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d 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.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], + + ]; diff --git a/lang/de/people.php b/lang/de/people.php new file mode 100644 index 00000000000..fc46a8dfa68 --- /dev/null +++ b/lang/de/people.php @@ -0,0 +1,91 @@ + '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', + 'laplacian_variance' => 'Laplacian Variance', + '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.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'remove_from_person' => 'Remove from person', + 'remove_from_person_success' => 'Face removed successfully.', + 'batch_select' => 'Select faces', + 'batch_cancel' => 'Cancel selection', + 'batch_selected' => ':count selected', + 'batch_assign' => 'Assign selected', + 'batch_unassign' => 'Unassign selected', + 'assign_to_user' => 'Assign to user', + 'search_user' => 'Search user…', + 'cluster_detail_title' => 'Cluster (:count faces)', + 'assigned_faces_to' => 'Assigned :count face(s) to ":name"', + 'assigned_faces' => 'Assigned :count face(s)', + 'dismissed_faces' => 'Dismissed :count face(s)', + 'clustering_started' => 'Clustering started. Reload when complete.', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/lang/el/dialogs.php b/lang/el/dialogs.php index f3b2709b2a4..b51e380890d 100644 --- a/lang/el/dialogs.php +++ b/lang/el/dialogs.php @@ -58,6 +58,7 @@ 'delete' => 'Delete the photo', 'edit' => 'Edit information', 'show_hide_meta' => 'Show information', + 'toggle_face_overlay' => 'Toggle face overlay', 'keep_hidden' => 'We will keep it hidden.', 'button_hidden' => 'We will hide the button in the header.', ], diff --git a/lang/el/gallery.php b/lang/el/gallery.php index d6fd852a9f6..dbb8463349f 100644 --- a/lang/el/gallery.php +++ b/lang/el/gallery.php @@ -317,6 +317,8 @@ 'merge_all' => 'Merge Selected', 'apply_renamer' => 'Apply Renamer', 'apply_renamer_all' => 'Apply Renamer to Selected', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'approve' => 'Approve', 'approve_all' => 'Approve Selected', 'upload_photo' => 'Upload Photo', diff --git a/lang/el/left-menu.php b/lang/el/left-menu.php index f35b798d7a5..3c785a55be2 100644 --- a/lang/el/left-menu.php +++ b/lang/el/left-menu.php @@ -24,5 +24,6 @@ 'support' => 'Support', 'contact' => 'Contact', 'messages' => 'Messages', + 'people' => 'People', 'webhooks' => 'Webhooks', ]; diff --git a/lang/el/maintenance.php b/lang/el/maintenance.php index 7a217ca787e..c038692d799 100644 --- a/lang/el/maintenance.php +++ b/lang/el/maintenance.php @@ -101,4 +101,56 @@ 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:recompute-album-sizes', 'button' => 'Compute sizes', ], -]; + + 'face_quality' => [ + 'title' => 'Face Quality Review', + 'description' => 'Review face detections by quality score and dismiss low-quality or erroneous faces.', + 'sort_by' => 'Sort by:', + 'sort_confidence' => 'Confidence', + 'sort_blur' => 'Blur (Laplacian)', + 'no_faces' => 'No qualifying faces. Everything looks good!', + 'col_face' => 'Face', + 'col_person' => 'Person', + 'col_cluster' => 'Cluster', + 'col_confidence' => 'Confidence', + 'col_blur' => 'Blur Score', + 'col_actions' => 'Actions', + 'unassigned' => 'Unassigned', + 'dismiss' => 'Dismiss face', + 'load_error' => 'Failed to load faces.', + 'dismissed' => 'Face dismissed.', + 'dismiss_error' => 'Failed to dismiss face.', + 'batch_dismiss' => 'Dismiss selected', + 'batch_dismissed' => ':count face(s) dismissed.', + 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'selected_count' => ':count selected', + ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d 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.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], + + ]; diff --git a/lang/el/people.php b/lang/el/people.php new file mode 100644 index 00000000000..fc46a8dfa68 --- /dev/null +++ b/lang/el/people.php @@ -0,0 +1,91 @@ + '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', + 'laplacian_variance' => 'Laplacian Variance', + '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.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'remove_from_person' => 'Remove from person', + 'remove_from_person_success' => 'Face removed successfully.', + 'batch_select' => 'Select faces', + 'batch_cancel' => 'Cancel selection', + 'batch_selected' => ':count selected', + 'batch_assign' => 'Assign selected', + 'batch_unassign' => 'Unassign selected', + 'assign_to_user' => 'Assign to user', + 'search_user' => 'Search user…', + 'cluster_detail_title' => 'Cluster (:count faces)', + 'assigned_faces_to' => 'Assigned :count face(s) to ":name"', + 'assigned_faces' => 'Assigned :count face(s)', + 'dismissed_faces' => 'Dismissed :count face(s)', + 'clustering_started' => 'Clustering started. Reload when complete.', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/lang/en/dialogs.php b/lang/en/dialogs.php index 2b7bb34a43e..8fbd66aea3b 100644 --- a/lang/en/dialogs.php +++ b/lang/en/dialogs.php @@ -58,6 +58,7 @@ 'delete' => 'Delete the photo', 'edit' => 'Edit information', 'show_hide_meta' => 'Show information', + 'toggle_face_overlay' => 'Toggle face overlay', 'keep_hidden' => 'We will keep it hidden.', 'button_hidden' => 'We will hide the button in the header.', ], diff --git a/lang/en/gallery.php b/lang/en/gallery.php index 1324a3de326..cebe9c127ad 100644 --- a/lang/en/gallery.php +++ b/lang/en/gallery.php @@ -316,6 +316,8 @@ 'merge_all' => 'Merge Selected', 'apply_renamer' => 'Apply Renamer', 'apply_renamer_all' => 'Apply Renamer to Selected', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'approve' => 'Approve', 'approve_all' => 'Approve Selected', 'upload_photo' => 'Upload Photo', diff --git a/lang/en/left-menu.php b/lang/en/left-menu.php index dc78742aa75..aab7be424c6 100644 --- a/lang/en/left-menu.php +++ b/lang/en/left-menu.php @@ -24,5 +24,6 @@ 'support' => 'Support', 'contact' => 'Contact', 'messages' => 'Messages', + 'people' => 'People', 'webhooks' => 'Webhooks', ]; diff --git a/lang/en/maintenance.php b/lang/en/maintenance.php index 9bd9dee1f2c..5277bf68352 100644 --- a/lang/en/maintenance.php +++ b/lang/en/maintenance.php @@ -100,4 +100,54 @@ 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:recompute-album-sizes', 'button' => 'Compute sizes', ], + 'face_quality' => [ + 'title' => 'Face Quality Review', + 'description' => 'Review face detections by quality score and dismiss low-quality or erroneous faces.', + 'sort_by' => 'Sort by:', + 'sort_confidence' => 'Confidence', + 'sort_blur' => 'Blur (Laplacian)', + 'no_faces' => 'No qualifying faces. Everything looks good!', + 'col_face' => 'Face', + 'col_person' => 'Person', + 'col_cluster' => 'Cluster', + 'col_confidence' => 'Confidence', + 'col_blur' => 'Blur Score', + 'col_actions' => 'Actions', + 'unassigned' => 'Unassigned', + 'dismiss' => 'Dismiss face', + 'load_error' => 'Failed to load faces.', + 'dismissed' => 'Face dismissed.', + 'dismiss_error' => 'Failed to dismiss face.', + 'batch_dismiss' => 'Dismiss selected', + 'batch_dismissed' => ':count face(s) dismissed.', + 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'selected_count' => ':count selected', + ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d 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.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], ]; diff --git a/lang/en/people.php b/lang/en/people.php new file mode 100644 index 00000000000..fc46a8dfa68 --- /dev/null +++ b/lang/en/people.php @@ -0,0 +1,91 @@ + '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', + 'laplacian_variance' => 'Laplacian Variance', + '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.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'remove_from_person' => 'Remove from person', + 'remove_from_person_success' => 'Face removed successfully.', + 'batch_select' => 'Select faces', + 'batch_cancel' => 'Cancel selection', + 'batch_selected' => ':count selected', + 'batch_assign' => 'Assign selected', + 'batch_unassign' => 'Unassign selected', + 'assign_to_user' => 'Assign to user', + 'search_user' => 'Search user…', + 'cluster_detail_title' => 'Cluster (:count faces)', + 'assigned_faces_to' => 'Assigned :count face(s) to ":name"', + 'assigned_faces' => 'Assigned :count face(s)', + 'dismissed_faces' => 'Dismissed :count face(s)', + 'clustering_started' => 'Clustering started. Reload when complete.', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/lang/es/dialogs.php b/lang/es/dialogs.php index bb6a7023ce7..ec6053a33cd 100644 --- a/lang/es/dialogs.php +++ b/lang/es/dialogs.php @@ -57,7 +57,8 @@ 'move' => 'Mover la foto', 'delete' => 'Borrar la foto', 'edit' => 'Editar información', - 'show_hide_meta' => 'Mostrar información', + 'show_hide_meta' => 'Show information', + 'toggle_face_overlay' => 'Toggle face overlay', 'keep_hidden' => 'Lo mantendremos oculto.', 'button_hidden' => 'Ocultaremos el botón en el encabezado.', ], diff --git a/lang/es/gallery.php b/lang/es/gallery.php index 9b8d698da9c..5bc91526fce 100644 --- a/lang/es/gallery.php +++ b/lang/es/gallery.php @@ -315,6 +315,8 @@ 'merge_all' => 'Fusionar seleccionados', 'apply_renamer' => 'Apply Renamer', 'apply_renamer_all' => 'Apply Renamer to Selected', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'approve' => 'Approve', 'approve_all' => 'Approve Selected', 'upload_photo' => 'Subir foto', diff --git a/lang/es/left-menu.php b/lang/es/left-menu.php index aaa17f40c2e..e373a5ffd79 100644 --- a/lang/es/left-menu.php +++ b/lang/es/left-menu.php @@ -24,5 +24,6 @@ 'support' => 'Asistencia', 'contact' => 'Contact', 'messages' => 'Messages', + 'people' => 'People', 'webhooks' => 'Webhooks', ]; diff --git a/lang/es/maintenance.php b/lang/es/maintenance.php index 214fde12ace..2a63b6ebd6c 100644 --- a/lang/es/maintenance.php +++ b/lang/es/maintenance.php @@ -100,4 +100,56 @@ 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:recompute-album-sizes', 'button' => 'Compute sizes', ], -]; + + 'face_quality' => [ + 'title' => 'Face Quality Review', + 'description' => 'Review face detections by quality score and dismiss low-quality or erroneous faces.', + 'sort_by' => 'Sort by:', + 'sort_confidence' => 'Confidence', + 'sort_blur' => 'Blur (Laplacian)', + 'no_faces' => 'No qualifying faces. Everything looks good!', + 'col_face' => 'Face', + 'col_person' => 'Person', + 'col_cluster' => 'Cluster', + 'col_confidence' => 'Confidence', + 'col_blur' => 'Blur Score', + 'col_actions' => 'Actions', + 'unassigned' => 'Unassigned', + 'dismiss' => 'Dismiss face', + 'load_error' => 'Failed to load faces.', + 'dismissed' => 'Face dismissed.', + 'dismiss_error' => 'Failed to dismiss face.', + 'batch_dismiss' => 'Dismiss selected', + 'batch_dismissed' => ':count face(s) dismissed.', + 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'selected_count' => ':count selected', + ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d 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.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], + + ]; diff --git a/lang/es/people.php b/lang/es/people.php new file mode 100644 index 00000000000..fc46a8dfa68 --- /dev/null +++ b/lang/es/people.php @@ -0,0 +1,91 @@ + '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', + 'laplacian_variance' => 'Laplacian Variance', + '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.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'remove_from_person' => 'Remove from person', + 'remove_from_person_success' => 'Face removed successfully.', + 'batch_select' => 'Select faces', + 'batch_cancel' => 'Cancel selection', + 'batch_selected' => ':count selected', + 'batch_assign' => 'Assign selected', + 'batch_unassign' => 'Unassign selected', + 'assign_to_user' => 'Assign to user', + 'search_user' => 'Search user…', + 'cluster_detail_title' => 'Cluster (:count faces)', + 'assigned_faces_to' => 'Assigned :count face(s) to ":name"', + 'assigned_faces' => 'Assigned :count face(s)', + 'dismissed_faces' => 'Dismissed :count face(s)', + 'clustering_started' => 'Clustering started. Reload when complete.', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/lang/fa/dialogs.php b/lang/fa/dialogs.php index deb9de4acc1..906c519ef8f 100644 --- a/lang/fa/dialogs.php +++ b/lang/fa/dialogs.php @@ -57,7 +57,8 @@ 'move' => 'انتقال عکس', 'delete' => 'حذف عکس', 'edit' => 'ویرایش اطلاعات', - 'show_hide_meta' => 'نمایش اطلاعات', + 'show_hide_meta' => 'Show information', + 'toggle_face_overlay' => 'Toggle face overlay', 'keep_hidden' => 'ما آن را مخفی نگه می‌داریم.', 'button_hidden' => 'ما دکمه را در هدر مخفی می‌کنیم.', ], diff --git a/lang/fa/gallery.php b/lang/fa/gallery.php index d25142504a2..b3b685ad16c 100644 --- a/lang/fa/gallery.php +++ b/lang/fa/gallery.php @@ -317,6 +317,8 @@ 'merge_all' => 'ادغام انتخاب شده‌ها', 'apply_renamer' => 'Apply Renamer', 'apply_renamer_all' => 'Apply Renamer to Selected', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'approve' => 'Approve', 'approve_all' => 'Approve Selected', 'upload_photo' => 'بارگذاری عکس', diff --git a/lang/fa/left-menu.php b/lang/fa/left-menu.php index b7e8c24e417..31f59924609 100644 --- a/lang/fa/left-menu.php +++ b/lang/fa/left-menu.php @@ -24,5 +24,6 @@ 'support' => 'پشتیبانی', 'contact' => 'Contact', 'messages' => 'Messages', + 'people' => 'People', 'webhooks' => 'Webhooks', ]; diff --git a/lang/fa/maintenance.php b/lang/fa/maintenance.php index b70ac3f8de8..4521cff7bb5 100644 --- a/lang/fa/maintenance.php +++ b/lang/fa/maintenance.php @@ -100,4 +100,56 @@ 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:recompute-album-sizes', 'button' => 'Compute sizes', ], -]; + + 'face_quality' => [ + 'title' => 'Face Quality Review', + 'description' => 'Review face detections by quality score and dismiss low-quality or erroneous faces.', + 'sort_by' => 'Sort by:', + 'sort_confidence' => 'Confidence', + 'sort_blur' => 'Blur (Laplacian)', + 'no_faces' => 'No qualifying faces. Everything looks good!', + 'col_face' => 'Face', + 'col_person' => 'Person', + 'col_cluster' => 'Cluster', + 'col_confidence' => 'Confidence', + 'col_blur' => 'Blur Score', + 'col_actions' => 'Actions', + 'unassigned' => 'Unassigned', + 'dismiss' => 'Dismiss face', + 'load_error' => 'Failed to load faces.', + 'dismissed' => 'Face dismissed.', + 'dismiss_error' => 'Failed to dismiss face.', + 'batch_dismiss' => 'Dismiss selected', + 'batch_dismissed' => ':count face(s) dismissed.', + 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'selected_count' => ':count selected', + ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d 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.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], + + ]; diff --git a/lang/fa/people.php b/lang/fa/people.php new file mode 100644 index 00000000000..fc46a8dfa68 --- /dev/null +++ b/lang/fa/people.php @@ -0,0 +1,91 @@ + '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', + 'laplacian_variance' => 'Laplacian Variance', + '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.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'remove_from_person' => 'Remove from person', + 'remove_from_person_success' => 'Face removed successfully.', + 'batch_select' => 'Select faces', + 'batch_cancel' => 'Cancel selection', + 'batch_selected' => ':count selected', + 'batch_assign' => 'Assign selected', + 'batch_unassign' => 'Unassign selected', + 'assign_to_user' => 'Assign to user', + 'search_user' => 'Search user…', + 'cluster_detail_title' => 'Cluster (:count faces)', + 'assigned_faces_to' => 'Assigned :count face(s) to ":name"', + 'assigned_faces' => 'Assigned :count face(s)', + 'dismissed_faces' => 'Dismissed :count face(s)', + 'clustering_started' => 'Clustering started. Reload when complete.', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/lang/fr/dialogs.php b/lang/fr/dialogs.php index f7d2a17c90d..2f20eba7a19 100644 --- a/lang/fr/dialogs.php +++ b/lang/fr/dialogs.php @@ -57,7 +57,8 @@ 'move' => 'Déplacer la photo', 'delete' => 'Supprimer la photo', 'edit' => 'Modifier les informations', - 'show_hide_meta' => 'Afficher les informations', + 'show_hide_meta' => 'Show information', + 'toggle_face_overlay' => 'Toggle face overlay', 'keep_hidden' => 'Nous la garderons cachée.', 'button_hidden' => 'Nous allons cacher ce bouton dans la barre de menu.', ], diff --git a/lang/fr/gallery.php b/lang/fr/gallery.php index d20d3a1f841..2a49c2ddee2 100644 --- a/lang/fr/gallery.php +++ b/lang/fr/gallery.php @@ -317,6 +317,8 @@ 'merge_all' => 'Fusionner la sélection', 'apply_renamer' => 'Apply Renamer', 'apply_renamer_all' => 'Apply Renamer to Selected', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'approve' => 'Approve', 'approve_all' => 'Approve Selected', 'upload_photo' => 'Téléverser une photo', diff --git a/lang/fr/left-menu.php b/lang/fr/left-menu.php index a5e065d1eb5..40d36ad3e1c 100644 --- a/lang/fr/left-menu.php +++ b/lang/fr/left-menu.php @@ -24,5 +24,6 @@ 'support' => 'Support', 'contact' => 'Contact', 'messages' => 'Messages', + 'people' => 'People', 'webhooks' => 'Webhooks', ]; diff --git a/lang/fr/maintenance.php b/lang/fr/maintenance.php index 26e77495ab4..d144ad931d9 100644 --- a/lang/fr/maintenance.php +++ b/lang/fr/maintenance.php @@ -100,4 +100,56 @@ 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:recompute-album-sizes', 'button' => 'Compute sizes', ], -]; + + 'face_quality' => [ + 'title' => 'Face Quality Review', + 'description' => 'Review face detections by quality score and dismiss low-quality or erroneous faces.', + 'sort_by' => 'Sort by:', + 'sort_confidence' => 'Confidence', + 'sort_blur' => 'Blur (Laplacian)', + 'no_faces' => 'No qualifying faces. Everything looks good!', + 'col_face' => 'Face', + 'col_person' => 'Person', + 'col_cluster' => 'Cluster', + 'col_confidence' => 'Confidence', + 'col_blur' => 'Blur Score', + 'col_actions' => 'Actions', + 'unassigned' => 'Unassigned', + 'dismiss' => 'Dismiss face', + 'load_error' => 'Failed to load faces.', + 'dismissed' => 'Face dismissed.', + 'dismiss_error' => 'Failed to dismiss face.', + 'batch_dismiss' => 'Dismiss selected', + 'batch_dismissed' => ':count face(s) dismissed.', + 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'selected_count' => ':count selected', + ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d 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.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], + + ]; diff --git a/lang/fr/people.php b/lang/fr/people.php new file mode 100644 index 00000000000..fc46a8dfa68 --- /dev/null +++ b/lang/fr/people.php @@ -0,0 +1,91 @@ + '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', + 'laplacian_variance' => 'Laplacian Variance', + '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.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'remove_from_person' => 'Remove from person', + 'remove_from_person_success' => 'Face removed successfully.', + 'batch_select' => 'Select faces', + 'batch_cancel' => 'Cancel selection', + 'batch_selected' => ':count selected', + 'batch_assign' => 'Assign selected', + 'batch_unassign' => 'Unassign selected', + 'assign_to_user' => 'Assign to user', + 'search_user' => 'Search user…', + 'cluster_detail_title' => 'Cluster (:count faces)', + 'assigned_faces_to' => 'Assigned :count face(s) to ":name"', + 'assigned_faces' => 'Assigned :count face(s)', + 'dismissed_faces' => 'Dismissed :count face(s)', + 'clustering_started' => 'Clustering started. Reload when complete.', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/lang/hu/dialogs.php b/lang/hu/dialogs.php index 235119a6206..e48d73de89b 100644 --- a/lang/hu/dialogs.php +++ b/lang/hu/dialogs.php @@ -58,6 +58,7 @@ 'delete' => 'Delete the photo', 'edit' => 'Edit information', 'show_hide_meta' => 'Show information', + 'toggle_face_overlay' => 'Toggle face overlay', 'keep_hidden' => 'We will keep it hidden.', 'button_hidden' => 'We will hide the button in the header.', ], diff --git a/lang/hu/gallery.php b/lang/hu/gallery.php index 5208cee3d7f..ab3faf42736 100644 --- a/lang/hu/gallery.php +++ b/lang/hu/gallery.php @@ -317,6 +317,8 @@ 'merge_all' => 'Merge Selected', 'apply_renamer' => 'Apply Renamer', 'apply_renamer_all' => 'Apply Renamer to Selected', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'approve' => 'Approve', 'approve_all' => 'Approve Selected', 'upload_photo' => 'Upload Photo', diff --git a/lang/hu/left-menu.php b/lang/hu/left-menu.php index 71c271cf399..14e927de121 100644 --- a/lang/hu/left-menu.php +++ b/lang/hu/left-menu.php @@ -24,5 +24,6 @@ 'support' => 'Support', 'contact' => 'Contact', 'messages' => 'Messages', + 'people' => 'People', 'webhooks' => 'Webhooks', ]; diff --git a/lang/hu/maintenance.php b/lang/hu/maintenance.php index 7a217ca787e..c038692d799 100644 --- a/lang/hu/maintenance.php +++ b/lang/hu/maintenance.php @@ -101,4 +101,56 @@ 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:recompute-album-sizes', 'button' => 'Compute sizes', ], -]; + + 'face_quality' => [ + 'title' => 'Face Quality Review', + 'description' => 'Review face detections by quality score and dismiss low-quality or erroneous faces.', + 'sort_by' => 'Sort by:', + 'sort_confidence' => 'Confidence', + 'sort_blur' => 'Blur (Laplacian)', + 'no_faces' => 'No qualifying faces. Everything looks good!', + 'col_face' => 'Face', + 'col_person' => 'Person', + 'col_cluster' => 'Cluster', + 'col_confidence' => 'Confidence', + 'col_blur' => 'Blur Score', + 'col_actions' => 'Actions', + 'unassigned' => 'Unassigned', + 'dismiss' => 'Dismiss face', + 'load_error' => 'Failed to load faces.', + 'dismissed' => 'Face dismissed.', + 'dismiss_error' => 'Failed to dismiss face.', + 'batch_dismiss' => 'Dismiss selected', + 'batch_dismissed' => ':count face(s) dismissed.', + 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'selected_count' => ':count selected', + ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d 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.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], + + ]; diff --git a/lang/hu/people.php b/lang/hu/people.php new file mode 100644 index 00000000000..fc46a8dfa68 --- /dev/null +++ b/lang/hu/people.php @@ -0,0 +1,91 @@ + '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', + 'laplacian_variance' => 'Laplacian Variance', + '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.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'remove_from_person' => 'Remove from person', + 'remove_from_person_success' => 'Face removed successfully.', + 'batch_select' => 'Select faces', + 'batch_cancel' => 'Cancel selection', + 'batch_selected' => ':count selected', + 'batch_assign' => 'Assign selected', + 'batch_unassign' => 'Unassign selected', + 'assign_to_user' => 'Assign to user', + 'search_user' => 'Search user…', + 'cluster_detail_title' => 'Cluster (:count faces)', + 'assigned_faces_to' => 'Assigned :count face(s) to ":name"', + 'assigned_faces' => 'Assigned :count face(s)', + 'dismissed_faces' => 'Dismissed :count face(s)', + 'clustering_started' => 'Clustering started. Reload when complete.', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/lang/it/dialogs.php b/lang/it/dialogs.php index 36704aae7f3..8eff5d3e37c 100644 --- a/lang/it/dialogs.php +++ b/lang/it/dialogs.php @@ -58,6 +58,7 @@ 'delete' => 'Delete the photo', 'edit' => 'Edit information', 'show_hide_meta' => 'Show information', + 'toggle_face_overlay' => 'Toggle face overlay', 'keep_hidden' => 'We will keep it hidden.', 'button_hidden' => 'We will hide the button in the header.', ], diff --git a/lang/it/gallery.php b/lang/it/gallery.php index 1f7865be7dc..1261434968f 100644 --- a/lang/it/gallery.php +++ b/lang/it/gallery.php @@ -315,6 +315,8 @@ 'merge_all' => 'Merge Selected', 'apply_renamer' => 'Apply Renamer', 'apply_renamer_all' => 'Apply Renamer to Selected', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'approve' => 'Approve', 'approve_all' => 'Approve Selected', 'upload_photo' => 'Upload Photo', diff --git a/lang/it/left-menu.php b/lang/it/left-menu.php index f5c7399fdee..dbdedf93dc0 100644 --- a/lang/it/left-menu.php +++ b/lang/it/left-menu.php @@ -24,5 +24,6 @@ 'support' => 'Support', 'contact' => 'Contact', 'messages' => 'Messages', + 'people' => 'People', 'webhooks' => 'Webhooks', ]; diff --git a/lang/it/maintenance.php b/lang/it/maintenance.php index 7a217ca787e..c038692d799 100644 --- a/lang/it/maintenance.php +++ b/lang/it/maintenance.php @@ -101,4 +101,56 @@ 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:recompute-album-sizes', 'button' => 'Compute sizes', ], -]; + + 'face_quality' => [ + 'title' => 'Face Quality Review', + 'description' => 'Review face detections by quality score and dismiss low-quality or erroneous faces.', + 'sort_by' => 'Sort by:', + 'sort_confidence' => 'Confidence', + 'sort_blur' => 'Blur (Laplacian)', + 'no_faces' => 'No qualifying faces. Everything looks good!', + 'col_face' => 'Face', + 'col_person' => 'Person', + 'col_cluster' => 'Cluster', + 'col_confidence' => 'Confidence', + 'col_blur' => 'Blur Score', + 'col_actions' => 'Actions', + 'unassigned' => 'Unassigned', + 'dismiss' => 'Dismiss face', + 'load_error' => 'Failed to load faces.', + 'dismissed' => 'Face dismissed.', + 'dismiss_error' => 'Failed to dismiss face.', + 'batch_dismiss' => 'Dismiss selected', + 'batch_dismissed' => ':count face(s) dismissed.', + 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'selected_count' => ':count selected', + ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d 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.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], + + ]; diff --git a/lang/it/people.php b/lang/it/people.php new file mode 100644 index 00000000000..fc46a8dfa68 --- /dev/null +++ b/lang/it/people.php @@ -0,0 +1,91 @@ + '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', + 'laplacian_variance' => 'Laplacian Variance', + '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.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'remove_from_person' => 'Remove from person', + 'remove_from_person_success' => 'Face removed successfully.', + 'batch_select' => 'Select faces', + 'batch_cancel' => 'Cancel selection', + 'batch_selected' => ':count selected', + 'batch_assign' => 'Assign selected', + 'batch_unassign' => 'Unassign selected', + 'assign_to_user' => 'Assign to user', + 'search_user' => 'Search user…', + 'cluster_detail_title' => 'Cluster (:count faces)', + 'assigned_faces_to' => 'Assigned :count face(s) to ":name"', + 'assigned_faces' => 'Assigned :count face(s)', + 'dismissed_faces' => 'Dismissed :count face(s)', + 'clustering_started' => 'Clustering started. Reload when complete.', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/lang/ja/dialogs.php b/lang/ja/dialogs.php index 09f1afb22ba..5abc7cd5330 100644 --- a/lang/ja/dialogs.php +++ b/lang/ja/dialogs.php @@ -58,6 +58,7 @@ 'delete' => 'Delete the photo', 'edit' => 'Edit information', 'show_hide_meta' => 'Show information', + 'toggle_face_overlay' => 'Toggle face overlay', 'keep_hidden' => 'We will keep it hidden.', 'button_hidden' => 'We will hide the button in the header.', ], diff --git a/lang/ja/gallery.php b/lang/ja/gallery.php index 7a2476c349f..3cc4df5c1f4 100644 --- a/lang/ja/gallery.php +++ b/lang/ja/gallery.php @@ -317,6 +317,8 @@ 'merge_all' => 'Merge Selected', 'apply_renamer' => 'Apply Renamer', 'apply_renamer_all' => 'Apply Renamer to Selected', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'approve' => 'Approve', 'approve_all' => 'Approve Selected', 'upload_photo' => 'Upload Photo', diff --git a/lang/ja/left-menu.php b/lang/ja/left-menu.php index 949fa8f98ee..b6ead058fa1 100644 --- a/lang/ja/left-menu.php +++ b/lang/ja/left-menu.php @@ -24,5 +24,6 @@ 'support' => 'Support', 'contact' => 'Contact', 'messages' => 'Messages', + 'people' => 'People', 'webhooks' => 'Webhooks', ]; diff --git a/lang/ja/maintenance.php b/lang/ja/maintenance.php index 5fd635a7ad9..e0a5a63d327 100644 --- a/lang/ja/maintenance.php +++ b/lang/ja/maintenance.php @@ -100,4 +100,56 @@ 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:recompute-album-sizes', 'button' => 'Compute sizes', ], -]; + + 'face_quality' => [ + 'title' => 'Face Quality Review', + 'description' => 'Review face detections by quality score and dismiss low-quality or erroneous faces.', + 'sort_by' => 'Sort by:', + 'sort_confidence' => 'Confidence', + 'sort_blur' => 'Blur (Laplacian)', + 'no_faces' => 'No qualifying faces. Everything looks good!', + 'col_face' => 'Face', + 'col_person' => 'Person', + 'col_cluster' => 'Cluster', + 'col_confidence' => 'Confidence', + 'col_blur' => 'Blur Score', + 'col_actions' => 'Actions', + 'unassigned' => 'Unassigned', + 'dismiss' => 'Dismiss face', + 'load_error' => 'Failed to load faces.', + 'dismissed' => 'Face dismissed.', + 'dismiss_error' => 'Failed to dismiss face.', + 'batch_dismiss' => 'Dismiss selected', + 'batch_dismissed' => ':count face(s) dismissed.', + 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'selected_count' => ':count selected', + ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d 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.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], + + ]; diff --git a/lang/ja/people.php b/lang/ja/people.php new file mode 100644 index 00000000000..fc46a8dfa68 --- /dev/null +++ b/lang/ja/people.php @@ -0,0 +1,91 @@ + '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', + 'laplacian_variance' => 'Laplacian Variance', + '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.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'remove_from_person' => 'Remove from person', + 'remove_from_person_success' => 'Face removed successfully.', + 'batch_select' => 'Select faces', + 'batch_cancel' => 'Cancel selection', + 'batch_selected' => ':count selected', + 'batch_assign' => 'Assign selected', + 'batch_unassign' => 'Unassign selected', + 'assign_to_user' => 'Assign to user', + 'search_user' => 'Search user…', + 'cluster_detail_title' => 'Cluster (:count faces)', + 'assigned_faces_to' => 'Assigned :count face(s) to ":name"', + 'assigned_faces' => 'Assigned :count face(s)', + 'dismissed_faces' => 'Dismissed :count face(s)', + 'clustering_started' => 'Clustering started. Reload when complete.', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/lang/nl/dialogs.php b/lang/nl/dialogs.php index 95679d3a738..daa6ce4588d 100644 --- a/lang/nl/dialogs.php +++ b/lang/nl/dialogs.php @@ -57,7 +57,8 @@ 'move' => 'Foto verplaatsen', 'delete' => 'Foto verwijderen', 'edit' => 'Informatie bewerken', - 'show_hide_meta' => 'Informatie tonen', + 'show_hide_meta' => 'Show information', + 'toggle_face_overlay' => 'Toggle face overlay', 'keep_hidden' => 'We houden het verborgen.', 'button_hidden' => 'We verbergen de knop in de koptekst.', ], diff --git a/lang/nl/gallery.php b/lang/nl/gallery.php index 7f7860750df..233848a4b4b 100644 --- a/lang/nl/gallery.php +++ b/lang/nl/gallery.php @@ -317,6 +317,8 @@ 'merge_all' => 'Alles samenvoegen', 'apply_renamer' => 'Apply Renamer', 'apply_renamer_all' => 'Apply Renamer to Selected', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'approve' => 'Approve', 'approve_all' => 'Approve Selected', 'upload_photo' => 'Foto uploaden', diff --git a/lang/nl/left-menu.php b/lang/nl/left-menu.php index 95286fc9d79..6cc877c9cbd 100644 --- a/lang/nl/left-menu.php +++ b/lang/nl/left-menu.php @@ -24,5 +24,6 @@ 'support' => 'Ondersteuning', 'contact' => 'Contact', 'messages' => 'Messages', + 'people' => 'People', 'webhooks' => 'Webhooks', ]; diff --git a/lang/nl/maintenance.php b/lang/nl/maintenance.php index 6907b8c7feb..16d6bac386d 100644 --- a/lang/nl/maintenance.php +++ b/lang/nl/maintenance.php @@ -100,4 +100,56 @@ 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:recompute-album-sizes', 'button' => 'Compute sizes', ], -]; + + 'face_quality' => [ + 'title' => 'Face Quality Review', + 'description' => 'Review face detections by quality score and dismiss low-quality or erroneous faces.', + 'sort_by' => 'Sort by:', + 'sort_confidence' => 'Confidence', + 'sort_blur' => 'Blur (Laplacian)', + 'no_faces' => 'No qualifying faces. Everything looks good!', + 'col_face' => 'Face', + 'col_person' => 'Person', + 'col_cluster' => 'Cluster', + 'col_confidence' => 'Confidence', + 'col_blur' => 'Blur Score', + 'col_actions' => 'Actions', + 'unassigned' => 'Unassigned', + 'dismiss' => 'Dismiss face', + 'load_error' => 'Failed to load faces.', + 'dismissed' => 'Face dismissed.', + 'dismiss_error' => 'Failed to dismiss face.', + 'batch_dismiss' => 'Dismiss selected', + 'batch_dismissed' => ':count face(s) dismissed.', + 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'selected_count' => ':count selected', + ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d 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.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], + + ]; diff --git a/lang/nl/people.php b/lang/nl/people.php new file mode 100644 index 00000000000..fc46a8dfa68 --- /dev/null +++ b/lang/nl/people.php @@ -0,0 +1,91 @@ + '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', + 'laplacian_variance' => 'Laplacian Variance', + '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.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'remove_from_person' => 'Remove from person', + 'remove_from_person_success' => 'Face removed successfully.', + 'batch_select' => 'Select faces', + 'batch_cancel' => 'Cancel selection', + 'batch_selected' => ':count selected', + 'batch_assign' => 'Assign selected', + 'batch_unassign' => 'Unassign selected', + 'assign_to_user' => 'Assign to user', + 'search_user' => 'Search user…', + 'cluster_detail_title' => 'Cluster (:count faces)', + 'assigned_faces_to' => 'Assigned :count face(s) to ":name"', + 'assigned_faces' => 'Assigned :count face(s)', + 'dismissed_faces' => 'Dismissed :count face(s)', + 'clustering_started' => 'Clustering started. Reload when complete.', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/lang/no/dialogs.php b/lang/no/dialogs.php index c000699af3f..c61592a7641 100644 --- a/lang/no/dialogs.php +++ b/lang/no/dialogs.php @@ -57,7 +57,8 @@ 'move' => 'Flytt bildet', 'delete' => 'Slett bildet', 'edit' => 'Editer informasjonen', - 'show_hide_meta' => 'Vis informasjonen', + 'show_hide_meta' => 'Show information', + 'toggle_face_overlay' => 'Toggle face overlay', 'keep_hidden' => 'Vi vil holde det skjult.', 'button_hidden' => 'Vi gjemmer knappen i øverste felt.', ], diff --git a/lang/no/gallery.php b/lang/no/gallery.php index 85dbfc07376..29b0f40d38b 100644 --- a/lang/no/gallery.php +++ b/lang/no/gallery.php @@ -316,6 +316,8 @@ 'merge_all' => 'Merge Selected', 'apply_renamer' => 'Apply Renamer', 'apply_renamer_all' => 'Apply Renamer to Selected', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'approve' => 'Approve', 'approve_all' => 'Approve Selected', 'upload_photo' => 'Upload Photo', diff --git a/lang/no/left-menu.php b/lang/no/left-menu.php index cdbef6ad894..af7489b5ddd 100644 --- a/lang/no/left-menu.php +++ b/lang/no/left-menu.php @@ -24,5 +24,6 @@ 'support' => 'Brukerstøtte', 'contact' => 'Contact', 'messages' => 'Messages', + 'people' => 'People', 'webhooks' => 'Webhooks', ]; diff --git a/lang/no/maintenance.php b/lang/no/maintenance.php index a36f8b7ed36..bfa4aa04e54 100644 --- a/lang/no/maintenance.php +++ b/lang/no/maintenance.php @@ -100,4 +100,56 @@ 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:recompute-album-sizes', 'button' => 'Compute sizes', ], -]; + + 'face_quality' => [ + 'title' => 'Face Quality Review', + 'description' => 'Review face detections by quality score and dismiss low-quality or erroneous faces.', + 'sort_by' => 'Sort by:', + 'sort_confidence' => 'Confidence', + 'sort_blur' => 'Blur (Laplacian)', + 'no_faces' => 'No qualifying faces. Everything looks good!', + 'col_face' => 'Face', + 'col_person' => 'Person', + 'col_cluster' => 'Cluster', + 'col_confidence' => 'Confidence', + 'col_blur' => 'Blur Score', + 'col_actions' => 'Actions', + 'unassigned' => 'Unassigned', + 'dismiss' => 'Dismiss face', + 'load_error' => 'Failed to load faces.', + 'dismissed' => 'Face dismissed.', + 'dismiss_error' => 'Failed to dismiss face.', + 'batch_dismiss' => 'Dismiss selected', + 'batch_dismissed' => ':count face(s) dismissed.', + 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'selected_count' => ':count selected', + ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d 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.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], + + ]; diff --git a/lang/no/people.php b/lang/no/people.php new file mode 100644 index 00000000000..fc46a8dfa68 --- /dev/null +++ b/lang/no/people.php @@ -0,0 +1,91 @@ + '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', + 'laplacian_variance' => 'Laplacian Variance', + '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.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'remove_from_person' => 'Remove from person', + 'remove_from_person_success' => 'Face removed successfully.', + 'batch_select' => 'Select faces', + 'batch_cancel' => 'Cancel selection', + 'batch_selected' => ':count selected', + 'batch_assign' => 'Assign selected', + 'batch_unassign' => 'Unassign selected', + 'assign_to_user' => 'Assign to user', + 'search_user' => 'Search user…', + 'cluster_detail_title' => 'Cluster (:count faces)', + 'assigned_faces_to' => 'Assigned :count face(s) to ":name"', + 'assigned_faces' => 'Assigned :count face(s)', + 'dismissed_faces' => 'Dismissed :count face(s)', + 'clustering_started' => 'Clustering started. Reload when complete.', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/lang/pl/dialogs.php b/lang/pl/dialogs.php index ba31504e8c8..d632da6313d 100644 --- a/lang/pl/dialogs.php +++ b/lang/pl/dialogs.php @@ -57,7 +57,8 @@ 'move' => 'Przenieś zdjęcie', 'delete' => 'Usuń zdjęcie', 'edit' => 'Edytuj informacje', - 'show_hide_meta' => 'Pokaż informacje', + 'show_hide_meta' => 'Show information', + 'toggle_face_overlay' => 'Toggle face overlay', 'keep_hidden' => 'Będziemy to ukrywać.', 'button_hidden' => 'We will hide the button in the header.', ], diff --git a/lang/pl/gallery.php b/lang/pl/gallery.php index aca2c6f26b4..b094513212d 100644 --- a/lang/pl/gallery.php +++ b/lang/pl/gallery.php @@ -317,6 +317,8 @@ 'merge_all' => 'Scal wybrane', 'apply_renamer' => 'Apply Renamer', 'apply_renamer_all' => 'Apply Renamer to Selected', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'approve' => 'Approve', 'approve_all' => 'Approve Selected', 'upload_photo' => 'Prześlij zdjęcie', diff --git a/lang/pl/left-menu.php b/lang/pl/left-menu.php index b7b4c5a00b3..7eba8e60e8a 100644 --- a/lang/pl/left-menu.php +++ b/lang/pl/left-menu.php @@ -24,5 +24,6 @@ 'support' => 'Wsparcie', 'contact' => 'Contact', 'messages' => 'Messages', + 'people' => 'People', 'webhooks' => 'Webhooks', ]; diff --git a/lang/pl/maintenance.php b/lang/pl/maintenance.php index a7d7fcf3ba8..22447e03bde 100644 --- a/lang/pl/maintenance.php +++ b/lang/pl/maintenance.php @@ -101,4 +101,56 @@ 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:recompute-album-sizes', 'button' => 'Compute sizes', ], -]; + + 'face_quality' => [ + 'title' => 'Face Quality Review', + 'description' => 'Review face detections by quality score and dismiss low-quality or erroneous faces.', + 'sort_by' => 'Sort by:', + 'sort_confidence' => 'Confidence', + 'sort_blur' => 'Blur (Laplacian)', + 'no_faces' => 'No qualifying faces. Everything looks good!', + 'col_face' => 'Face', + 'col_person' => 'Person', + 'col_cluster' => 'Cluster', + 'col_confidence' => 'Confidence', + 'col_blur' => 'Blur Score', + 'col_actions' => 'Actions', + 'unassigned' => 'Unassigned', + 'dismiss' => 'Dismiss face', + 'load_error' => 'Failed to load faces.', + 'dismissed' => 'Face dismissed.', + 'dismiss_error' => 'Failed to dismiss face.', + 'batch_dismiss' => 'Dismiss selected', + 'batch_dismissed' => ':count face(s) dismissed.', + 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'selected_count' => ':count selected', + ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d 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.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], + + ]; diff --git a/lang/pl/people.php b/lang/pl/people.php new file mode 100644 index 00000000000..fc46a8dfa68 --- /dev/null +++ b/lang/pl/people.php @@ -0,0 +1,91 @@ + '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', + 'laplacian_variance' => 'Laplacian Variance', + '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.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'remove_from_person' => 'Remove from person', + 'remove_from_person_success' => 'Face removed successfully.', + 'batch_select' => 'Select faces', + 'batch_cancel' => 'Cancel selection', + 'batch_selected' => ':count selected', + 'batch_assign' => 'Assign selected', + 'batch_unassign' => 'Unassign selected', + 'assign_to_user' => 'Assign to user', + 'search_user' => 'Search user…', + 'cluster_detail_title' => 'Cluster (:count faces)', + 'assigned_faces_to' => 'Assigned :count face(s) to ":name"', + 'assigned_faces' => 'Assigned :count face(s)', + 'dismissed_faces' => 'Dismissed :count face(s)', + 'clustering_started' => 'Clustering started. Reload when complete.', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/lang/pt/dialogs.php b/lang/pt/dialogs.php index 67529aa4f69..48e88f92718 100644 --- a/lang/pt/dialogs.php +++ b/lang/pt/dialogs.php @@ -58,6 +58,7 @@ 'delete' => 'Delete the photo', 'edit' => 'Edit information', 'show_hide_meta' => 'Show information', + 'toggle_face_overlay' => 'Toggle face overlay', 'keep_hidden' => 'We will keep it hidden.', 'button_hidden' => 'We will hide the button in the header.', ], diff --git a/lang/pt/gallery.php b/lang/pt/gallery.php index 7868fb39e36..01d8cb8dc54 100644 --- a/lang/pt/gallery.php +++ b/lang/pt/gallery.php @@ -317,6 +317,8 @@ 'merge_all' => 'Merge Selected', 'apply_renamer' => 'Apply Renamer', 'apply_renamer_all' => 'Apply Renamer to Selected', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'approve' => 'Approve', 'approve_all' => 'Approve Selected', 'upload_photo' => 'Upload Photo', diff --git a/lang/pt/left-menu.php b/lang/pt/left-menu.php index 9647d3bc4f7..13e7efeaeeb 100644 --- a/lang/pt/left-menu.php +++ b/lang/pt/left-menu.php @@ -24,5 +24,6 @@ 'support' => 'Support', 'contact' => 'Contact', 'messages' => 'Messages', + 'people' => 'People', 'webhooks' => 'Webhooks', ]; diff --git a/lang/pt/maintenance.php b/lang/pt/maintenance.php index 7a217ca787e..c038692d799 100644 --- a/lang/pt/maintenance.php +++ b/lang/pt/maintenance.php @@ -101,4 +101,56 @@ 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:recompute-album-sizes', 'button' => 'Compute sizes', ], -]; + + 'face_quality' => [ + 'title' => 'Face Quality Review', + 'description' => 'Review face detections by quality score and dismiss low-quality or erroneous faces.', + 'sort_by' => 'Sort by:', + 'sort_confidence' => 'Confidence', + 'sort_blur' => 'Blur (Laplacian)', + 'no_faces' => 'No qualifying faces. Everything looks good!', + 'col_face' => 'Face', + 'col_person' => 'Person', + 'col_cluster' => 'Cluster', + 'col_confidence' => 'Confidence', + 'col_blur' => 'Blur Score', + 'col_actions' => 'Actions', + 'unassigned' => 'Unassigned', + 'dismiss' => 'Dismiss face', + 'load_error' => 'Failed to load faces.', + 'dismissed' => 'Face dismissed.', + 'dismiss_error' => 'Failed to dismiss face.', + 'batch_dismiss' => 'Dismiss selected', + 'batch_dismissed' => ':count face(s) dismissed.', + 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'selected_count' => ':count selected', + ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d 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.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], + + ]; diff --git a/lang/pt/people.php b/lang/pt/people.php new file mode 100644 index 00000000000..fc46a8dfa68 --- /dev/null +++ b/lang/pt/people.php @@ -0,0 +1,91 @@ + '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', + 'laplacian_variance' => 'Laplacian Variance', + '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.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'remove_from_person' => 'Remove from person', + 'remove_from_person_success' => 'Face removed successfully.', + 'batch_select' => 'Select faces', + 'batch_cancel' => 'Cancel selection', + 'batch_selected' => ':count selected', + 'batch_assign' => 'Assign selected', + 'batch_unassign' => 'Unassign selected', + 'assign_to_user' => 'Assign to user', + 'search_user' => 'Search user…', + 'cluster_detail_title' => 'Cluster (:count faces)', + 'assigned_faces_to' => 'Assigned :count face(s) to ":name"', + 'assigned_faces' => 'Assigned :count face(s)', + 'dismissed_faces' => 'Dismissed :count face(s)', + 'clustering_started' => 'Clustering started. Reload when complete.', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/lang/ru/dialogs.php b/lang/ru/dialogs.php index 20243460a11..712ef7c9d7a 100644 --- a/lang/ru/dialogs.php +++ b/lang/ru/dialogs.php @@ -57,7 +57,8 @@ 'move' => 'Переместить фотографию', 'delete' => 'Удалить фотографию', 'edit' => 'Редактировать информацию', - 'show_hide_meta' => 'Показать информацию', + 'show_hide_meta' => 'Show information', + 'toggle_face_overlay' => 'Toggle face overlay', 'keep_hidden' => 'Мы оставим это скрытым.', 'button_hidden' => 'Мы скроем кнопку в шапке.', ], diff --git a/lang/ru/gallery.php b/lang/ru/gallery.php index fb74d262206..398e9bd5f39 100644 --- a/lang/ru/gallery.php +++ b/lang/ru/gallery.php @@ -317,6 +317,8 @@ 'merge_all' => 'Объединить выбранные', 'apply_renamer' => 'Apply Renamer', 'apply_renamer_all' => 'Apply Renamer to Selected', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'approve' => 'Approve', 'approve_all' => 'Approve Selected', 'upload_photo' => 'Загрузить фото', diff --git a/lang/ru/left-menu.php b/lang/ru/left-menu.php index a84eca23517..47bf042988c 100644 --- a/lang/ru/left-menu.php +++ b/lang/ru/left-menu.php @@ -24,5 +24,6 @@ 'support' => 'Поддержка', 'contact' => 'Contact', 'messages' => 'Messages', + 'people' => 'People', 'webhooks' => 'Webhooks', ]; diff --git a/lang/ru/maintenance.php b/lang/ru/maintenance.php index a3de5665ccd..f57cd25379d 100644 --- a/lang/ru/maintenance.php +++ b/lang/ru/maintenance.php @@ -100,4 +100,56 @@ 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:recompute-album-sizes', 'button' => 'Compute sizes', ], -]; + + 'face_quality' => [ + 'title' => 'Face Quality Review', + 'description' => 'Review face detections by quality score and dismiss low-quality or erroneous faces.', + 'sort_by' => 'Sort by:', + 'sort_confidence' => 'Confidence', + 'sort_blur' => 'Blur (Laplacian)', + 'no_faces' => 'No qualifying faces. Everything looks good!', + 'col_face' => 'Face', + 'col_person' => 'Person', + 'col_cluster' => 'Cluster', + 'col_confidence' => 'Confidence', + 'col_blur' => 'Blur Score', + 'col_actions' => 'Actions', + 'unassigned' => 'Unassigned', + 'dismiss' => 'Dismiss face', + 'load_error' => 'Failed to load faces.', + 'dismissed' => 'Face dismissed.', + 'dismiss_error' => 'Failed to dismiss face.', + 'batch_dismiss' => 'Dismiss selected', + 'batch_dismissed' => ':count face(s) dismissed.', + 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'selected_count' => ':count selected', + ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d 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.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], + + ]; diff --git a/lang/ru/people.php b/lang/ru/people.php new file mode 100644 index 00000000000..fc46a8dfa68 --- /dev/null +++ b/lang/ru/people.php @@ -0,0 +1,91 @@ + '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', + 'laplacian_variance' => 'Laplacian Variance', + '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.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'remove_from_person' => 'Remove from person', + 'remove_from_person_success' => 'Face removed successfully.', + 'batch_select' => 'Select faces', + 'batch_cancel' => 'Cancel selection', + 'batch_selected' => ':count selected', + 'batch_assign' => 'Assign selected', + 'batch_unassign' => 'Unassign selected', + 'assign_to_user' => 'Assign to user', + 'search_user' => 'Search user…', + 'cluster_detail_title' => 'Cluster (:count faces)', + 'assigned_faces_to' => 'Assigned :count face(s) to ":name"', + 'assigned_faces' => 'Assigned :count face(s)', + 'dismissed_faces' => 'Dismissed :count face(s)', + 'clustering_started' => 'Clustering started. Reload when complete.', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/lang/sk/dialogs.php b/lang/sk/dialogs.php index a247febf4f5..7b85e274312 100644 --- a/lang/sk/dialogs.php +++ b/lang/sk/dialogs.php @@ -58,6 +58,7 @@ 'delete' => 'Delete the photo', 'edit' => 'Edit information', 'show_hide_meta' => 'Show information', + 'toggle_face_overlay' => 'Toggle face overlay', 'keep_hidden' => 'We will keep it hidden.', 'button_hidden' => 'We will hide the button in the header.', ], diff --git a/lang/sk/gallery.php b/lang/sk/gallery.php index 2bbf9fc1b25..af647d0e034 100644 --- a/lang/sk/gallery.php +++ b/lang/sk/gallery.php @@ -317,6 +317,8 @@ 'merge_all' => 'Merge Selected', 'apply_renamer' => 'Apply Renamer', 'apply_renamer_all' => 'Apply Renamer to Selected', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'approve' => 'Approve', 'approve_all' => 'Approve Selected', 'upload_photo' => 'Upload Photo', diff --git a/lang/sk/left-menu.php b/lang/sk/left-menu.php index c251960a0d4..a0a42ab7854 100644 --- a/lang/sk/left-menu.php +++ b/lang/sk/left-menu.php @@ -24,5 +24,6 @@ 'support' => 'Support', 'contact' => 'Contact', 'messages' => 'Messages', + 'people' => 'People', 'webhooks' => 'Webhooks', ]; diff --git a/lang/sk/maintenance.php b/lang/sk/maintenance.php index 7a217ca787e..c038692d799 100644 --- a/lang/sk/maintenance.php +++ b/lang/sk/maintenance.php @@ -101,4 +101,56 @@ 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:recompute-album-sizes', 'button' => 'Compute sizes', ], -]; + + 'face_quality' => [ + 'title' => 'Face Quality Review', + 'description' => 'Review face detections by quality score and dismiss low-quality or erroneous faces.', + 'sort_by' => 'Sort by:', + 'sort_confidence' => 'Confidence', + 'sort_blur' => 'Blur (Laplacian)', + 'no_faces' => 'No qualifying faces. Everything looks good!', + 'col_face' => 'Face', + 'col_person' => 'Person', + 'col_cluster' => 'Cluster', + 'col_confidence' => 'Confidence', + 'col_blur' => 'Blur Score', + 'col_actions' => 'Actions', + 'unassigned' => 'Unassigned', + 'dismiss' => 'Dismiss face', + 'load_error' => 'Failed to load faces.', + 'dismissed' => 'Face dismissed.', + 'dismiss_error' => 'Failed to dismiss face.', + 'batch_dismiss' => 'Dismiss selected', + 'batch_dismissed' => ':count face(s) dismissed.', + 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'selected_count' => ':count selected', + ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d 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.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], + + ]; diff --git a/lang/sk/people.php b/lang/sk/people.php new file mode 100644 index 00000000000..fc46a8dfa68 --- /dev/null +++ b/lang/sk/people.php @@ -0,0 +1,91 @@ + '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', + 'laplacian_variance' => 'Laplacian Variance', + '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.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'remove_from_person' => 'Remove from person', + 'remove_from_person_success' => 'Face removed successfully.', + 'batch_select' => 'Select faces', + 'batch_cancel' => 'Cancel selection', + 'batch_selected' => ':count selected', + 'batch_assign' => 'Assign selected', + 'batch_unassign' => 'Unassign selected', + 'assign_to_user' => 'Assign to user', + 'search_user' => 'Search user…', + 'cluster_detail_title' => 'Cluster (:count faces)', + 'assigned_faces_to' => 'Assigned :count face(s) to ":name"', + 'assigned_faces' => 'Assigned :count face(s)', + 'dismissed_faces' => 'Dismissed :count face(s)', + 'clustering_started' => 'Clustering started. Reload when complete.', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/lang/sv/dialogs.php b/lang/sv/dialogs.php index 9560e48db42..18ae3d9061e 100644 --- a/lang/sv/dialogs.php +++ b/lang/sv/dialogs.php @@ -58,6 +58,7 @@ 'delete' => 'Delete the photo', 'edit' => 'Edit information', 'show_hide_meta' => 'Show information', + 'toggle_face_overlay' => 'Toggle face overlay', 'keep_hidden' => 'We will keep it hidden.', 'button_hidden' => 'We will hide the button in the header.', ], diff --git a/lang/sv/gallery.php b/lang/sv/gallery.php index 8c21468a330..7b7f3304873 100644 --- a/lang/sv/gallery.php +++ b/lang/sv/gallery.php @@ -317,6 +317,8 @@ 'merge_all' => 'Merge Selected', 'apply_renamer' => 'Apply Renamer', 'apply_renamer_all' => 'Apply Renamer to Selected', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'approve' => 'Approve', 'approve_all' => 'Approve Selected', 'upload_photo' => 'Upload Photo', diff --git a/lang/sv/left-menu.php b/lang/sv/left-menu.php index 3e54b1d5c16..b9332245ffa 100644 --- a/lang/sv/left-menu.php +++ b/lang/sv/left-menu.php @@ -24,5 +24,6 @@ 'support' => 'Support', 'contact' => 'Contact', 'messages' => 'Messages', + 'people' => 'People', 'webhooks' => 'Webhooks', ]; diff --git a/lang/sv/maintenance.php b/lang/sv/maintenance.php index 7a217ca787e..c038692d799 100644 --- a/lang/sv/maintenance.php +++ b/lang/sv/maintenance.php @@ -101,4 +101,56 @@ 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:recompute-album-sizes', 'button' => 'Compute sizes', ], -]; + + 'face_quality' => [ + 'title' => 'Face Quality Review', + 'description' => 'Review face detections by quality score and dismiss low-quality or erroneous faces.', + 'sort_by' => 'Sort by:', + 'sort_confidence' => 'Confidence', + 'sort_blur' => 'Blur (Laplacian)', + 'no_faces' => 'No qualifying faces. Everything looks good!', + 'col_face' => 'Face', + 'col_person' => 'Person', + 'col_cluster' => 'Cluster', + 'col_confidence' => 'Confidence', + 'col_blur' => 'Blur Score', + 'col_actions' => 'Actions', + 'unassigned' => 'Unassigned', + 'dismiss' => 'Dismiss face', + 'load_error' => 'Failed to load faces.', + 'dismissed' => 'Face dismissed.', + 'dismiss_error' => 'Failed to dismiss face.', + 'batch_dismiss' => 'Dismiss selected', + 'batch_dismissed' => ':count face(s) dismissed.', + 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'selected_count' => ':count selected', + ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d 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.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], + + ]; diff --git a/lang/sv/people.php b/lang/sv/people.php new file mode 100644 index 00000000000..fc46a8dfa68 --- /dev/null +++ b/lang/sv/people.php @@ -0,0 +1,91 @@ + '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', + 'laplacian_variance' => 'Laplacian Variance', + '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.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'remove_from_person' => 'Remove from person', + 'remove_from_person_success' => 'Face removed successfully.', + 'batch_select' => 'Select faces', + 'batch_cancel' => 'Cancel selection', + 'batch_selected' => ':count selected', + 'batch_assign' => 'Assign selected', + 'batch_unassign' => 'Unassign selected', + 'assign_to_user' => 'Assign to user', + 'search_user' => 'Search user…', + 'cluster_detail_title' => 'Cluster (:count faces)', + 'assigned_faces_to' => 'Assigned :count face(s) to ":name"', + 'assigned_faces' => 'Assigned :count face(s)', + 'dismissed_faces' => 'Dismissed :count face(s)', + 'clustering_started' => 'Clustering started. Reload when complete.', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/lang/tr/dialogs.php b/lang/tr/dialogs.php index 2b7bb34a43e..8fbd66aea3b 100644 --- a/lang/tr/dialogs.php +++ b/lang/tr/dialogs.php @@ -58,6 +58,7 @@ 'delete' => 'Delete the photo', 'edit' => 'Edit information', 'show_hide_meta' => 'Show information', + 'toggle_face_overlay' => 'Toggle face overlay', 'keep_hidden' => 'We will keep it hidden.', 'button_hidden' => 'We will hide the button in the header.', ], diff --git a/lang/tr/gallery.php b/lang/tr/gallery.php index 2b169a0c35e..22ea6afa21f 100644 --- a/lang/tr/gallery.php +++ b/lang/tr/gallery.php @@ -317,6 +317,8 @@ 'merge_all' => 'Merge Selected', 'apply_renamer' => 'Apply Renamer', 'apply_renamer_all' => 'Apply Renamer to Selected', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'approve' => 'Approve', 'approve_all' => 'Approve Selected', 'upload_photo' => 'Upload Photo', diff --git a/lang/tr/left-menu.php b/lang/tr/left-menu.php index dc78742aa75..aab7be424c6 100644 --- a/lang/tr/left-menu.php +++ b/lang/tr/left-menu.php @@ -24,5 +24,6 @@ 'support' => 'Support', 'contact' => 'Contact', 'messages' => 'Messages', + 'people' => 'People', 'webhooks' => 'Webhooks', ]; diff --git a/lang/tr/maintenance.php b/lang/tr/maintenance.php index 9bd9dee1f2c..13697f9e68e 100644 --- a/lang/tr/maintenance.php +++ b/lang/tr/maintenance.php @@ -100,4 +100,56 @@ 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:recompute-album-sizes', 'button' => 'Compute sizes', ], -]; + + 'face_quality' => [ + 'title' => 'Face Quality Review', + 'description' => 'Review face detections by quality score and dismiss low-quality or erroneous faces.', + 'sort_by' => 'Sort by:', + 'sort_confidence' => 'Confidence', + 'sort_blur' => 'Blur (Laplacian)', + 'no_faces' => 'No qualifying faces. Everything looks good!', + 'col_face' => 'Face', + 'col_person' => 'Person', + 'col_cluster' => 'Cluster', + 'col_confidence' => 'Confidence', + 'col_blur' => 'Blur Score', + 'col_actions' => 'Actions', + 'unassigned' => 'Unassigned', + 'dismiss' => 'Dismiss face', + 'load_error' => 'Failed to load faces.', + 'dismissed' => 'Face dismissed.', + 'dismiss_error' => 'Failed to dismiss face.', + 'batch_dismiss' => 'Dismiss selected', + 'batch_dismissed' => ':count face(s) dismissed.', + 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'selected_count' => ':count selected', + ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d 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.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], + + ]; diff --git a/lang/tr/people.php b/lang/tr/people.php new file mode 100644 index 00000000000..fc46a8dfa68 --- /dev/null +++ b/lang/tr/people.php @@ -0,0 +1,91 @@ + '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', + 'laplacian_variance' => 'Laplacian Variance', + '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.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'remove_from_person' => 'Remove from person', + 'remove_from_person_success' => 'Face removed successfully.', + 'batch_select' => 'Select faces', + 'batch_cancel' => 'Cancel selection', + 'batch_selected' => ':count selected', + 'batch_assign' => 'Assign selected', + 'batch_unassign' => 'Unassign selected', + 'assign_to_user' => 'Assign to user', + 'search_user' => 'Search user…', + 'cluster_detail_title' => 'Cluster (:count faces)', + 'assigned_faces_to' => 'Assigned :count face(s) to ":name"', + 'assigned_faces' => 'Assigned :count face(s)', + 'dismissed_faces' => 'Dismissed :count face(s)', + 'clustering_started' => 'Clustering started. Reload when complete.', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/lang/vi/dialogs.php b/lang/vi/dialogs.php index e91d14be63e..9ca2cacebfa 100644 --- a/lang/vi/dialogs.php +++ b/lang/vi/dialogs.php @@ -58,6 +58,7 @@ 'delete' => 'Delete the photo', 'edit' => 'Edit information', 'show_hide_meta' => 'Show information', + 'toggle_face_overlay' => 'Toggle face overlay', 'keep_hidden' => 'We will keep it hidden.', 'button_hidden' => 'We will hide the button in the header.', ], diff --git a/lang/vi/gallery.php b/lang/vi/gallery.php index 5ea7b5b549c..f09dae56205 100644 --- a/lang/vi/gallery.php +++ b/lang/vi/gallery.php @@ -317,6 +317,8 @@ 'merge_all' => 'Merge Selected', 'apply_renamer' => 'Apply Renamer', 'apply_renamer_all' => 'Apply Renamer to Selected', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'approve' => 'Approve', 'approve_all' => 'Approve Selected', 'upload_photo' => 'Upload Photo', diff --git a/lang/vi/left-menu.php b/lang/vi/left-menu.php index f34c90388bc..10d486dd1af 100644 --- a/lang/vi/left-menu.php +++ b/lang/vi/left-menu.php @@ -24,5 +24,6 @@ 'support' => 'Support', 'contact' => 'Contact', 'messages' => 'Messages', + 'people' => 'People', 'webhooks' => 'Webhooks', ]; diff --git a/lang/vi/maintenance.php b/lang/vi/maintenance.php index 7a217ca787e..c038692d799 100644 --- a/lang/vi/maintenance.php +++ b/lang/vi/maintenance.php @@ -101,4 +101,56 @@ 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:recompute-album-sizes', 'button' => 'Compute sizes', ], -]; + + 'face_quality' => [ + 'title' => 'Face Quality Review', + 'description' => 'Review face detections by quality score and dismiss low-quality or erroneous faces.', + 'sort_by' => 'Sort by:', + 'sort_confidence' => 'Confidence', + 'sort_blur' => 'Blur (Laplacian)', + 'no_faces' => 'No qualifying faces. Everything looks good!', + 'col_face' => 'Face', + 'col_person' => 'Person', + 'col_cluster' => 'Cluster', + 'col_confidence' => 'Confidence', + 'col_blur' => 'Blur Score', + 'col_actions' => 'Actions', + 'unassigned' => 'Unassigned', + 'dismiss' => 'Dismiss face', + 'load_error' => 'Failed to load faces.', + 'dismissed' => 'Face dismissed.', + 'dismiss_error' => 'Failed to dismiss face.', + 'batch_dismiss' => 'Dismiss selected', + 'batch_dismissed' => ':count face(s) dismissed.', + 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'selected_count' => ':count selected', + ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d 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.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], + + ]; diff --git a/lang/vi/people.php b/lang/vi/people.php new file mode 100644 index 00000000000..fc46a8dfa68 --- /dev/null +++ b/lang/vi/people.php @@ -0,0 +1,91 @@ + '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', + 'laplacian_variance' => 'Laplacian Variance', + '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.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'remove_from_person' => 'Remove from person', + 'remove_from_person_success' => 'Face removed successfully.', + 'batch_select' => 'Select faces', + 'batch_cancel' => 'Cancel selection', + 'batch_selected' => ':count selected', + 'batch_assign' => 'Assign selected', + 'batch_unassign' => 'Unassign selected', + 'assign_to_user' => 'Assign to user', + 'search_user' => 'Search user…', + 'cluster_detail_title' => 'Cluster (:count faces)', + 'assigned_faces_to' => 'Assigned :count face(s) to ":name"', + 'assigned_faces' => 'Assigned :count face(s)', + 'dismissed_faces' => 'Dismissed :count face(s)', + 'clustering_started' => 'Clustering started. Reload when complete.', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/lang/zh_CN/dialogs.php b/lang/zh_CN/dialogs.php index 0a379043458..d4abab174e6 100644 --- a/lang/zh_CN/dialogs.php +++ b/lang/zh_CN/dialogs.php @@ -57,7 +57,8 @@ 'move' => '移动照片', 'delete' => '删除照片', 'edit' => '编辑信息', - 'show_hide_meta' => '显示信息', + 'show_hide_meta' => 'Show information', + 'toggle_face_overlay' => 'Toggle face overlay', 'keep_hidden' => '我们会保持隐藏。', 'button_hidden' => '我们将隐藏顶栏中的按钮。', ], diff --git a/lang/zh_CN/gallery.php b/lang/zh_CN/gallery.php index 6cc702ed31d..3e964a67fc7 100644 --- a/lang/zh_CN/gallery.php +++ b/lang/zh_CN/gallery.php @@ -317,6 +317,8 @@ 'merge_all' => '合并所选', 'apply_renamer' => 'Apply Renamer', 'apply_renamer_all' => 'Apply Renamer to Selected', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'approve' => 'Approve', 'approve_all' => 'Approve Selected', 'upload_photo' => '上传照片', diff --git a/lang/zh_CN/left-menu.php b/lang/zh_CN/left-menu.php index 6718c6f4ea3..e923eb406b4 100644 --- a/lang/zh_CN/left-menu.php +++ b/lang/zh_CN/left-menu.php @@ -24,5 +24,6 @@ 'support' => '支持', 'contact' => 'Contact', 'messages' => 'Messages', + 'people' => 'People', 'webhooks' => 'Webhooks', ]; diff --git a/lang/zh_CN/maintenance.php b/lang/zh_CN/maintenance.php index 78f3ac7c942..487986c33de 100644 --- a/lang/zh_CN/maintenance.php +++ b/lang/zh_CN/maintenance.php @@ -100,4 +100,56 @@ 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:recompute-album-sizes', 'button' => 'Compute sizes', ], -]; + + 'face_quality' => [ + 'title' => 'Face Quality Review', + 'description' => 'Review face detections by quality score and dismiss low-quality or erroneous faces.', + 'sort_by' => 'Sort by:', + 'sort_confidence' => 'Confidence', + 'sort_blur' => 'Blur (Laplacian)', + 'no_faces' => 'No qualifying faces. Everything looks good!', + 'col_face' => 'Face', + 'col_person' => 'Person', + 'col_cluster' => 'Cluster', + 'col_confidence' => 'Confidence', + 'col_blur' => 'Blur Score', + 'col_actions' => 'Actions', + 'unassigned' => 'Unassigned', + 'dismiss' => 'Dismiss face', + 'load_error' => 'Failed to load faces.', + 'dismissed' => 'Face dismissed.', + 'dismiss_error' => 'Failed to dismiss face.', + 'batch_dismiss' => 'Dismiss selected', + 'batch_dismissed' => ':count face(s) dismissed.', + 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'selected_count' => ':count selected', + ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d 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.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], + + ]; diff --git a/lang/zh_CN/people.php b/lang/zh_CN/people.php new file mode 100644 index 00000000000..fc46a8dfa68 --- /dev/null +++ b/lang/zh_CN/people.php @@ -0,0 +1,91 @@ + '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', + 'laplacian_variance' => 'Laplacian Variance', + '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.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'remove_from_person' => 'Remove from person', + 'remove_from_person_success' => 'Face removed successfully.', + 'batch_select' => 'Select faces', + 'batch_cancel' => 'Cancel selection', + 'batch_selected' => ':count selected', + 'batch_assign' => 'Assign selected', + 'batch_unassign' => 'Unassign selected', + 'assign_to_user' => 'Assign to user', + 'search_user' => 'Search user…', + 'cluster_detail_title' => 'Cluster (:count faces)', + 'assigned_faces_to' => 'Assigned :count face(s) to ":name"', + 'assigned_faces' => 'Assigned :count face(s)', + 'dismissed_faces' => 'Dismissed :count face(s)', + 'clustering_started' => 'Clustering started. Reload when complete.', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/lang/zh_TW/dialogs.php b/lang/zh_TW/dialogs.php index f844e8c1c7a..b4527feb86b 100644 --- a/lang/zh_TW/dialogs.php +++ b/lang/zh_TW/dialogs.php @@ -57,7 +57,8 @@ 'move' => '移動相片', 'delete' => '刪除相片', 'edit' => '編輯資訊', - 'show_hide_meta' => '顯示資訊', + 'show_hide_meta' => 'Show information', + 'toggle_face_overlay' => 'Toggle face overlay', 'keep_hidden' => '我們將使其隱藏。', 'button_hidden' => '我們將在標題中隱藏按鈕。', ], diff --git a/lang/zh_TW/gallery.php b/lang/zh_TW/gallery.php index 3cd8b8a8c98..3fb5a5b151a 100644 --- a/lang/zh_TW/gallery.php +++ b/lang/zh_TW/gallery.php @@ -317,6 +317,8 @@ 'merge_all' => 'Merge Selected', 'apply_renamer' => 'Apply Renamer', 'apply_renamer_all' => 'Apply Renamer to Selected', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'approve' => 'Approve', 'approve_all' => 'Approve Selected', 'upload_photo' => 'Upload Photo', diff --git a/lang/zh_TW/left-menu.php b/lang/zh_TW/left-menu.php index dc250c74e3b..3ed4c08f589 100644 --- a/lang/zh_TW/left-menu.php +++ b/lang/zh_TW/left-menu.php @@ -24,5 +24,6 @@ 'support' => 'Support', 'contact' => 'Contact', 'messages' => 'Messages', + 'people' => 'People', 'webhooks' => 'Webhooks', ]; diff --git a/lang/zh_TW/maintenance.php b/lang/zh_TW/maintenance.php index 6af426623c7..7f41e143e69 100644 --- a/lang/zh_TW/maintenance.php +++ b/lang/zh_TW/maintenance.php @@ -101,4 +101,56 @@ 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:recompute-album-sizes', 'button' => 'Compute sizes', ], -]; + + 'face_quality' => [ + 'title' => 'Face Quality Review', + 'description' => 'Review face detections by quality score and dismiss low-quality or erroneous faces.', + 'sort_by' => 'Sort by:', + 'sort_confidence' => 'Confidence', + 'sort_blur' => 'Blur (Laplacian)', + 'no_faces' => 'No qualifying faces. Everything looks good!', + 'col_face' => 'Face', + 'col_person' => 'Person', + 'col_cluster' => 'Cluster', + 'col_confidence' => 'Confidence', + 'col_blur' => 'Blur Score', + 'col_actions' => 'Actions', + 'unassigned' => 'Unassigned', + 'dismiss' => 'Dismiss face', + 'load_error' => 'Failed to load faces.', + 'dismissed' => 'Face dismissed.', + 'dismiss_error' => 'Failed to dismiss face.', + 'batch_dismiss' => 'Dismiss selected', + 'batch_dismissed' => ':count face(s) dismissed.', + 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'select_all' => 'Select all', + 'deselect_all' => 'Deselect all', + 'selected_count' => ':count selected', + ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d 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.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], + + ]; diff --git a/lang/zh_TW/people.php b/lang/zh_TW/people.php new file mode 100644 index 00000000000..fc46a8dfa68 --- /dev/null +++ b/lang/zh_TW/people.php @@ -0,0 +1,91 @@ + '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', + 'laplacian_variance' => 'Laplacian Variance', + '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.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'remove_from_person' => 'Remove from person', + 'remove_from_person_success' => 'Face removed successfully.', + 'batch_select' => 'Select faces', + 'batch_cancel' => 'Cancel selection', + 'batch_selected' => ':count selected', + 'batch_assign' => 'Assign selected', + 'batch_unassign' => 'Unassign selected', + 'assign_to_user' => 'Assign to user', + 'search_user' => 'Search user…', + 'cluster_detail_title' => 'Cluster (:count faces)', + 'assigned_faces_to' => 'Assigned :count face(s) to ":name"', + 'assigned_faces' => 'Assigned :count face(s)', + 'dismissed_faces' => 'Dismissed :count face(s)', + 'clustering_started' => 'Clustering started. Reload when complete.', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/resources/js/components/drawers/PhotoDetails.vue b/resources/js/components/drawers/PhotoDetails.vue index 9231adac2e8..bb3ac6dd8d5 100644 --- a/resources/js/components/drawers/PhotoDetails.vue +++ b/resources/js/components/drawers/PhotoDetails.vue @@ -221,6 +221,57 @@ :key="`rating-${photoStore.photo.id}`" /> + + +

@@ -228,7 +279,7 @@ diff --git a/resources/js/components/gallery/PersonCard.vue b/resources/js/components/gallery/PersonCard.vue new file mode 100644 index 00000000000..ad60bb29c2c --- /dev/null +++ b/resources/js/components/gallery/PersonCard.vue @@ -0,0 +1,31 @@ + + + diff --git a/resources/js/components/gallery/albumModule/AlbumHero.vue b/resources/js/components/gallery/albumModule/AlbumHero.vue index df595e1393d..2540dba1170 100644 --- a/resources/js/components/gallery/albumModule/AlbumHero.vue +++ b/resources/js/components/gallery/albumModule/AlbumHero.vue @@ -108,6 +108,14 @@ > + + +
+ diff --git a/resources/js/views/admin/Maintenance.vue b/resources/js/views/admin/Maintenance.vue index 9c34708ae53..cacf8260c75 100644 --- a/resources/js/views/admin/Maintenance.vue +++ b/resources/js/views/admin/Maintenance.vue @@ -38,6 +38,11 @@ + + + + + diff --git a/resources/js/views/face-recog/FaceClusters.vue b/resources/js/views/face-recog/FaceClusters.vue new file mode 100644 index 00000000000..a4baf9e6155 --- /dev/null +++ b/resources/js/views/face-recog/FaceClusters.vue @@ -0,0 +1,544 @@ + + + diff --git a/resources/js/views/face-recog/FaceMaintenance.vue b/resources/js/views/face-recog/FaceMaintenance.vue new file mode 100644 index 00000000000..8de806e748a --- /dev/null +++ b/resources/js/views/face-recog/FaceMaintenance.vue @@ -0,0 +1,427 @@ + + diff --git a/resources/js/views/face-recog/People.vue b/resources/js/views/face-recog/People.vue new file mode 100644 index 00000000000..4f9575ac5a2 --- /dev/null +++ b/resources/js/views/face-recog/People.vue @@ -0,0 +1,209 @@ + + + diff --git a/resources/js/views/face-recog/PersonDetail.vue b/resources/js/views/face-recog/PersonDetail.vue new file mode 100644 index 00000000000..e2383682043 --- /dev/null +++ b/resources/js/views/face-recog/PersonDetail.vue @@ -0,0 +1,693 @@ + + + diff --git a/resources/js/views/gallery-panels/Albums.vue b/resources/js/views/gallery-panels/Albums.vue index 91880bb5cd1..7d0f8f35da5 100644 --- a/resources/js/views/gallery-panels/Albums.vue +++ b/resources/js/views/gallery-panels/Albums.vue @@ -338,6 +338,7 @@ const albumCallbacks = { is_download_album_visible.value = true; }, toggleApplyRenamer: () => {}, + toggleScanFaces: () => {}, }; const { diff --git a/resources/js/views/gallery-panels/Search.vue b/resources/js/views/gallery-panels/Search.vue index 01900bbc16c..5e6139d8d5d 100644 --- a/resources/js/views/gallery-panels/Search.vue +++ b/resources/js/views/gallery-panels/Search.vue @@ -366,6 +366,7 @@ const photoCallbacks = { is_download_photo_visible.value = true; }, toggleApplyRenamer: toggleApplyRenamer, + toggleScanFaces: () => {}, toggleApprove: () => { ModerationService.approve(selectedPhotosIds.value).then(() => { selectedPhotosIds.value.forEach((photoId) => { @@ -390,6 +391,7 @@ const albumCallbacks = { }, togglePin: () => {}, toggleApplyRenamer: toggleApplyRenamer, + toggleScanFaces: () => {}, }; const album = computed(() => albumStore.album); diff --git a/resources/js/views/gallery-panels/Timeline.vue b/resources/js/views/gallery-panels/Timeline.vue index 4da49146034..2e175b03dc6 100644 --- a/resources/js/views/gallery-panels/Timeline.vue +++ b/resources/js/views/gallery-panels/Timeline.vue @@ -368,6 +368,7 @@ const photoCallbacks = { is_download_photo_visible.value = true; }, toggleApplyRenamer: () => {}, + toggleScanFaces: () => {}, toggleApprove: () => { ModerationService.approve(selectedPhotosIds.value).then(() => { selectedPhotosIds.value.forEach((photoId) => { diff --git a/routes/api_v2.php b/routes/api_v2.php index 661466f8d93..49f26e6265c 100644 --- a/routes/api_v2.php +++ b/routes/api_v2.php @@ -380,3 +380,65 @@ Route::delete('/Renamer', [RenamerController::class, 'destroy'])->middleware(['support:se']); Route::post('/Renamer::test', [RenamerController::class, 'test'])->middleware(['support:se']); Route::post('/Renamer::preview', [RenamerController::class, 'preview'])->middleware(['support:se']); + +/** + * AI VISION — PEOPLE. + */ +Route::get('/People', [AiVision\PeopleController::class, 'index'])->middleware(['support:se']); +Route::get('/Person/{id}', [AiVision\PeopleController::class, 'show'])->middleware(['support:se']); +Route::post('/Person', [AiVision\PeopleController::class, 'store'])->middleware(['support:se']); +Route::patch('/Person/{id}', [AiVision\PeopleController::class, 'update'])->middleware(['support:se']); +Route::delete('/Person/{id}', [AiVision\PeopleController::class, 'destroy'])->middleware(['support:se']); +Route::post('/Person/{id}/claim', [AiVision\PersonClaimController::class, 'claim'])->middleware(['support:se']); +Route::delete('/Person/{id}/claim', [AiVision\PersonClaimController::class, 'unclaim'])->middleware(['support:se']); +Route::post('/Person/{id}/merge', [AiVision\PersonClaimController::class, 'merge'])->middleware(['support:se']); +Route::post('/Person/claim-by-selfie', [AiVision\SelfieClaimController::class, 'claimBySelfie']) + ->middleware(['support:se', 'throttle:5,1']) + ->withoutMiddleware(['content_type:json']); +Route::get('/Person/{id}/photos', [AiVision\PersonPhotosController::class, 'index'])->middleware(['support:se']); + +/** + * AI VISION — FACES. + */ +Route::post('/Face/{id}/assign', [AiVision\FaceController::class, 'assign'])->middleware(['support:se']); +Route::post('/Face/batch', [AiVision\FaceController::class, 'batch'])->middleware(['support:se']); +Route::patch('/Face/{id}', [AiVision\FaceController::class, 'toggleDismissed'])->middleware(['support:se']); +Route::delete('/Face/dismissed', [AiVision\FaceController::class, 'destroyDismissed'])->middleware(['support:se']); +Route::get('/Face/maintenance', [AiVision\FaceMaintenanceController::class, 'index'])->middleware(['support:se']); +Route::post('/Face/maintenance/batch-dismiss', [AiVision\FaceMaintenanceController::class, 'batchDismiss'])->middleware(['support:se']); + +/** + * AI VISION — FACE DETECTION. + */ +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::get('/FaceDetection/clusters/{label}/faces', [AiVision\FaceClusterController::class, 'faces'])->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']); +Route::post('/FaceDetection/clusters/{label}/uncluster', [AiVision\FaceClusterController::class, 'uncluster'])->middleware(['support:se']); + +/** + * AI VISION — MAINTENANCE. + */ +Route::get('/Maintenance::bulkScanFaces', [Admin\Maintenance\BulkScanFaces::class, 'check']); +Route::post('/Maintenance::bulkScanFaces', [Admin\Maintenance\BulkScanFaces::class, 'do']); +Route::get('/Maintenance::runFaceClustering', [Admin\Maintenance\RunFaceClustering::class, 'check']); +Route::post('/Maintenance::runFaceClustering', [Admin\Maintenance\RunFaceClustering::class, 'do']); +Route::get('/Maintenance::destroyDismissedFaces', [Admin\Maintenance\DestroyDismissedFaces::class, 'check']); +Route::post('/Maintenance::destroyDismissedFaces', [Admin\Maintenance\DestroyDismissedFaces::class, 'do']); +Route::get('/Maintenance::syncFaceEmbeddings', [Admin\Maintenance\SyncFaceEmbeddings::class, 'check']); +Route::post('/Maintenance::syncFaceEmbeddings', [Admin\Maintenance\SyncFaceEmbeddings::class, 'do']); +Route::get('/Maintenance::resetFaceScanStatus', [Admin\Maintenance\ResetFaceScanStatus::class, 'check']); +Route::post('/Maintenance::resetFaceScanStatus', [Admin\Maintenance\ResetFaceScanStatus::class, 'do']); + +/** + * AI VISION — ALBUM PEOPLE. + */ +Route::get('/Album/{album_id}/people', [AiVision\AlbumPeopleController::class, 'index'])->middleware(['support:se']); diff --git a/routes/web_v2.php b/routes/web_v2.php index 38d66f912dc..fd4cb47dfec 100644 --- a/routes/web_v2.php +++ b/routes/web_v2.php @@ -33,7 +33,8 @@ Event::dispatch(new DiagnosingHealth()); return view('health-up'); -}); +})->withoutMiddleware(['admin_user:set']); // We do not require an admin user fot the health check, as it may be used to check if the application is up before any users are created. + Route::get('/octane-health', function () { $status = [ 'status' => 'healthy', @@ -66,6 +67,7 @@ Route::get('/tag/{tagId}/{photoId?}', VueController::class)->middleware(['migration:complete']); Route::get('/diagnostics', VueController::class)->middleware(['migration:complete']); Route::get('/statistics', VueController::class)->middleware(['migration:complete', 'login_required:always']); +Route::get('/people/{cluster?}', VueController::class)->middleware(['migration:complete']); Route::get('/changelogs', VueController::class)->middleware(['migration:complete']); Route::get('/login', VueController::class)->middleware(['migration:complete']); Route::get('/register', VueController::class)->name('register')->middleware(['migration:complete']); @@ -79,7 +81,7 @@ Route::get('/admin/moderation', VueController::class)->middleware(['migration:complete', 'login_required:always']); Route::get('/admin/purchasables', VueController::class)->middleware(['migration:complete', 'login_required:always']); Route::get('/admin/jobs', VueController::class)->middleware(['migration:complete', 'login_required:always']); -Route::get('/admin/maintenance', VueController::class)->middleware(['migration:complete', 'login_required:always']); +Route::get('/admin/maintenance/{faces?}', 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']); Route::get('/duplicatesFinder', VueController::class)->middleware(['migration:complete', 'login_required:always']); diff --git a/tests/Feature_v2/AiVision/AlbumPeopleTest.php b/tests/Feature_v2/AiVision/AlbumPeopleTest.php new file mode 100644 index 00000000000..b35bfb631f8 --- /dev/null +++ b/tests/Feature_v2/AiVision/AlbumPeopleTest.php @@ -0,0 +1,219 @@ +requireSe(); + + Configs::set('ai_vision_enabled', '1'); + Configs::set('ai_vision_face_enabled', '1'); + Configs::set('ai_vision_face_permission_mode', 'public'); + + // Create albums + $this->testAlbum1 = Album::factory()->owned_by($this->userMayUpload1)->create(); + $this->testAlbum2 = Album::factory()->owned_by($this->userMayUpload1)->create(); + + // Create photos + $this->testPhoto1 = Photo::factory()->owned_by($this->userMayUpload1)->create(); + $this->testPhoto2 = Photo::factory()->owned_by($this->userMayUpload1)->create(); + $this->testPhoto3 = Photo::factory()->owned_by($this->userMayUpload1)->create(); + + // Add photos to albums + DB::table('photo_album')->insert([ + ['photo_id' => $this->testPhoto1->id, 'album_id' => $this->testAlbum1->id], + ['photo_id' => $this->testPhoto2->id, 'album_id' => $this->testAlbum1->id], + ['photo_id' => $this->testPhoto3->id, 'album_id' => $this->testAlbum2->id], + ]); + + // Create persons + $this->testPerson1 = Person::factory()->with_name('Alice')->create(['is_searchable' => true]); + $this->testPerson2 = Person::factory()->with_name('Bob')->create(['is_searchable' => true]); + $this->testPerson3 = Person::factory()->with_name('Charlie')->create(['is_searchable' => false]); + + // Create faces in album1 photos + Face::factory()->for_photo($this->testPhoto1)->without_crop()->create([ + 'person_id' => $this->testPerson1->id, + 'is_dismissed' => false, + ]); + Face::factory()->for_photo($this->testPhoto1)->without_crop()->create([ + 'person_id' => $this->testPerson2->id, + 'is_dismissed' => false, + ]); + Face::factory()->for_photo($this->testPhoto2)->without_crop()->create([ + 'person_id' => $this->testPerson1->id, + 'is_dismissed' => false, + ]); + + // Create faces in album2 photos + Face::factory()->for_photo($this->testPhoto3)->without_crop()->create([ + 'person_id' => $this->testPerson3->id, + 'is_dismissed' => false, + ]); + } + + public function tearDown(): void + { + DB::table('photo_album')->delete(); + DB::table('faces')->delete(); + DB::table('persons')->delete(); + $this->resetSe(); + parent::tearDown(); + } + + // ── GET ALBUM PEOPLE ────────────────────────────────────────── + + public function testGetAlbumPeopleReturnsDistinctPersons(): void + { + $response = $this->actingAs($this->userMayUpload1)->getJson('Album/' . $this->testAlbum1->id . '/people'); + $this->assertStatus($response, 200); + + $people = $response->json('data'); + self::assertCount(2, $people); // Alice and Bob, not Charlie (different album) + + $names = array_column($people, 'name'); + self::assertContains('Alice', $names); + self::assertContains('Bob', $names); + self::assertNotContains('Charlie', $names); + } + + public function testGetAlbumPeopleDeduplicates(): void + { + // Alice appears in two photos in album1, should appear only once + $response = $this->actingAs($this->userMayUpload1)->getJson('Album/' . $this->testAlbum1->id . '/people'); + $this->assertStatus($response, 200); + + $people = $response->json('data'); + $alice_count = count(array_filter($people, fn ($p) => $p['name'] === 'Alice')); + self::assertEquals(1, $alice_count); + } + + public function testGetAlbumPeopleExcludesDismissedFaces(): void + { + // Dismiss all faces of person2 in album1 + Face::where('photo_id', $this->testPhoto1->id) + ->where('person_id', $this->testPerson2->id) + ->update(['is_dismissed' => true]); + + $response = $this->actingAs($this->userMayUpload1)->getJson('Album/' . $this->testAlbum1->id . '/people'); + $this->assertStatus($response, 200); + + $people = $response->json('data'); + $names = array_column($people, 'name'); + self::assertContains('Alice', $names); + self::assertNotContains('Bob', $names); // dismissed + } + + public function testGetAlbumPeopleExcludesNonSearchableForGuests(): void + { + // Charlie is not searchable, should not appear for guest users + // Add Charlie to album1 + Face::factory()->for_photo($this->testPhoto1)->without_crop()->create([ + 'person_id' => $this->testPerson3->id, + 'is_dismissed' => false, + ]); + + // Make album public + $this->testAlbum1->public = true; + $this->testAlbum1->save(); + + $response = $this->getJson('Album/' . $this->testAlbum1->id . '/people'); + $this->assertStatus($response, 200); + + $people = $response->json('data'); + $names = array_column($people, 'name'); + self::assertContains('Alice', $names); + self::assertContains('Bob', $names); + self::assertNotContains('Charlie', $names); // not searchable + } + + public function testGetAlbumPeopleIncludesNonSearchableForAdmin(): void + { + // Charlie is not searchable, but admins should see them + // Add Charlie to album1 + Face::factory()->for_photo($this->testPhoto1)->without_crop()->create([ + 'person_id' => $this->testPerson3->id, + 'is_dismissed' => false, + ]); + + $response = $this->actingAs($this->admin)->getJson('Album/' . $this->testAlbum1->id . '/people'); + $this->assertStatus($response, 200); + + $people = $response->json('data'); + $names = array_column($people, 'name'); + self::assertContains('Alice', $names); + self::assertContains('Bob', $names); + self::assertContains('Charlie', $names); // admin sees all + } + + public function testGetAlbumPeopleRequiresAlbumAccess(): void + { + // userNoUpload2 has no access to album1 + $response = $this->actingAs($this->userNoUpload2)->getJson('Album/' . $this->testAlbum1->id . '/people'); + $this->assertStatus($response, [403, 404]); + } + + public function testGetAlbumPeopleReturnsEmptyForAlbumWithoutFaces(): void + { + // Create empty album + $empty_album = Album::factory()->owned_by($this->userMayUpload1)->create(); + + $response = $this->actingAs($this->userMayUpload1)->getJson('Album/' . $empty_album->id . '/people'); + $this->assertStatus($response, 200); + + $people = $response->json('data'); + self::assertCount(0, $people); + } + + public function testGetAlbumPeopleReturnsEmptyForAlbumWithOnlyUnassignedFaces(): void + { + // Create photo with unassigned face + $photo_unassigned = Photo::factory()->owned_by($this->userMayUpload1)->create(); + DB::table('photo_album')->insert([ + 'photo_id' => $photo_unassigned->id, + 'album_id' => $this->testAlbum1->id, + ]); + Face::factory()->for_photo($photo_unassigned)->without_crop()->create([ + 'person_id' => null, + 'is_dismissed' => false, + ]); + + $response = $this->actingAs($this->userMayUpload1)->getJson('Album/' . $this->testAlbum1->id . '/people'); + $this->assertStatus($response, 200); + + // Should still return Alice and Bob, not the unassigned face + $people = $response->json('data'); + self::assertCount(2, $people); + } +} diff --git a/tests/Feature_v2/AiVision/AlbumPolicyFaceTest.php b/tests/Feature_v2/AiVision/AlbumPolicyFaceTest.php new file mode 100644 index 00000000000..f7ed2bb043e --- /dev/null +++ b/tests/Feature_v2/AiVision/AlbumPolicyFaceTest.php @@ -0,0 +1,285 @@ +requireSe(); + + Configs::set('ai_vision_enabled', '1'); + Configs::set('ai_vision_face_enabled', '1'); + } + + public function tearDown(): void + { + DB::table('face_suggestions')->delete(); + DB::table('faces')->delete(); + DB::table('persons')->delete(); + $this->resetSe(); + parent::tearDown(); + } + + // \u2500\u2500 canViewAlbumPeople \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + + public function testCanViewAlbumPeoplePublicModeOwnerCanView(): void + { + Configs::set('ai_vision_face_permission_mode', 'public'); + $this->actingAs($this->userMayUpload1); + $this->assertTrue(Gate::check(AlbumPolicy::CAN_VIEW_ALBUM_PEOPLE, [AbstractAlbum::class, $this->album1])); + } + + public function testCanViewAlbumPeoplePublicModeNonOwnerWithAlbumAccessCanView(): void + { + Configs::set('ai_vision_face_permission_mode', 'public'); + // userMayUpload2 has perm1 on album1 + $this->actingAs($this->userMayUpload2); + $this->assertTrue(Gate::check(AlbumPolicy::CAN_VIEW_ALBUM_PEOPLE, [AbstractAlbum::class, $this->album1])); + } + + public function testCanViewAlbumPeoplePublicModeGuestNoAccessDenied(): void + { + Configs::set('ai_vision_face_permission_mode', 'public'); + // album1 is not public -> guest cannot access + Auth::logout(); + $this->assertFalse(Gate::check(AlbumPolicy::CAN_VIEW_ALBUM_PEOPLE, [AbstractAlbum::class, $this->album1])); + } + + public function testCanViewAlbumPeoplePrivateModeOwnerCanView(): void + { + Configs::set('ai_vision_face_permission_mode', 'private'); + $this->actingAs($this->userMayUpload1); + $this->assertTrue(Gate::check(AlbumPolicy::CAN_VIEW_ALBUM_PEOPLE, [AbstractAlbum::class, $this->album1])); + } + + public function testCanViewAlbumPeoplePrivateModeNonOwnerCanView(): void + { + Configs::set('ai_vision_face_permission_mode', 'private'); + $this->actingAs($this->userMayUpload2); + $this->assertTrue(Gate::check(AlbumPolicy::CAN_VIEW_ALBUM_PEOPLE, [AbstractAlbum::class, $this->album1])); + } + + public function testCanViewAlbumPeoplePrivateModeGuestDenied(): void + { + Configs::set('ai_vision_face_permission_mode', 'private'); + Auth::logout(); + $this->assertFalse(Gate::check(AlbumPolicy::CAN_VIEW_ALBUM_PEOPLE, [AbstractAlbum::class, $this->album1])); + } + + public function testCanViewAlbumPeoplePrivacyPreservingOwnerOnly(): void + { + Configs::set('ai_vision_face_permission_mode', 'privacy-preserving'); + $this->actingAs($this->userMayUpload1); + $this->assertTrue(Gate::check(AlbumPolicy::CAN_VIEW_ALBUM_PEOPLE, [AbstractAlbum::class, $this->album1])); + $this->actingAs($this->userMayUpload2); + $this->assertFalse(Gate::check(AlbumPolicy::CAN_VIEW_ALBUM_PEOPLE, [AbstractAlbum::class, $this->album1])); + } + + public function testCanViewAlbumPeopleRestrictedOwnerOnly(): void + { + Configs::set('ai_vision_face_permission_mode', 'restricted'); + $this->actingAs($this->userMayUpload1); + $this->assertTrue(Gate::check(AlbumPolicy::CAN_VIEW_ALBUM_PEOPLE, [AbstractAlbum::class, $this->album1])); + $this->actingAs($this->userMayUpload2); + $this->assertFalse(Gate::check(AlbumPolicy::CAN_VIEW_ALBUM_PEOPLE, [AbstractAlbum::class, $this->album1])); + } + + public function testCanViewAlbumPeopleAdminAlwaysTrue(): void + { + $this->actingAs($this->admin); + foreach (['public', 'private', 'privacy-preserving', 'restricted'] as $mode) { + Configs::set('ai_vision_face_permission_mode', $mode); + $this->assertTrue(Gate::check(AlbumPolicy::CAN_VIEW_ALBUM_PEOPLE, [AbstractAlbum::class, $this->album1]), "Admin failed for mode: {$mode}"); + } + } + + public function testCanViewAlbumPeopleAiVisionDisabledDeniesNonAdmin(): void + { + Configs::set('ai_vision_enabled', '0'); + $this->actingAs($this->userMayUpload1); + $this->assertFalse(Gate::check(AlbumPolicy::CAN_VIEW_ALBUM_PEOPLE, [AbstractAlbum::class, $this->album1])); + } + + // \u2500\u2500 canTriggerScanOnAlbum \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + + public function testCanTriggerScanOnAlbumPublicModeOwnerCanTrigger(): void + { + Configs::set('ai_vision_face_permission_mode', 'public'); + $this->actingAs($this->userMayUpload1); + $this->assertTrue(Gate::check(AlbumPolicy::CAN_TRIGGER_SCAN_ON_ALBUM, [AbstractAlbum::class, $this->album1])); + } + + public function testCanTriggerScanOnAlbumPublicModeNonOwnerLoggedCanTrigger(): void + { + Configs::set('ai_vision_face_permission_mode', 'public'); + $this->actingAs($this->userMayUpload2); + $this->assertTrue(Gate::check(AlbumPolicy::CAN_TRIGGER_SCAN_ON_ALBUM, [AbstractAlbum::class, $this->album1])); + } + + public function testCanTriggerScanOnAlbumPublicModeGuestDenied(): void + { + Configs::set('ai_vision_face_permission_mode', 'public'); + Auth::logout(); + $this->assertFalse(Gate::check(AlbumPolicy::CAN_TRIGGER_SCAN_ON_ALBUM, [AbstractAlbum::class, $this->album1])); + } + + public function testCanTriggerScanOnAlbumPrivacyPreservingAndRestrictedOwnerOnly(): void + { + foreach (['privacy-preserving', 'restricted'] as $mode) { + Configs::set('ai_vision_face_permission_mode', $mode); + $this->actingAs($this->userMayUpload1); + $this->assertTrue(Gate::check(AlbumPolicy::CAN_TRIGGER_SCAN_ON_ALBUM, [AbstractAlbum::class, $this->album1]), "Owner failed for mode: {$mode}"); + $this->actingAs($this->userMayUpload2); + $this->assertFalse(Gate::check(AlbumPolicy::CAN_TRIGGER_SCAN_ON_ALBUM, [AbstractAlbum::class, $this->album1]), "Non-owner passed for mode: {$mode}"); + } + } + + public function testCanTriggerScanOnAlbumAdminAlwaysTrue(): void + { + $this->actingAs($this->admin); + foreach (['public', 'private', 'privacy-preserving', 'restricted'] as $mode) { + Configs::set('ai_vision_face_permission_mode', $mode); + $this->assertTrue(Gate::check(AlbumPolicy::CAN_TRIGGER_SCAN_ON_ALBUM, [AbstractAlbum::class, $this->album1]), "Admin failed for mode: {$mode}"); + } + } + + // \u2500\u2500 canAssignFaceInAlbum \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + + public function testCanAssignFaceInAlbumPublicAndPrivateModeLoggedUser(): void + { + foreach (['public', 'private'] as $mode) { + Configs::set('ai_vision_face_permission_mode', $mode); + $this->actingAs($this->userMayUpload1); + $this->assertTrue(Gate::check(AlbumPolicy::CAN_ASSIGN_FACE_IN_ALBUM, [AbstractAlbum::class, $this->album1]), "Owner failed for mode: {$mode}"); + $this->actingAs($this->userMayUpload2); + $this->assertTrue(Gate::check(AlbumPolicy::CAN_ASSIGN_FACE_IN_ALBUM, [AbstractAlbum::class, $this->album1]), "Non-owner failed for mode: {$mode}"); + } + } + + public function testCanAssignFaceInAlbumPublicModeGuestDenied(): void + { + Configs::set('ai_vision_face_permission_mode', 'public'); + Auth::logout(); + $this->assertFalse(Gate::check(AlbumPolicy::CAN_ASSIGN_FACE_IN_ALBUM, [AbstractAlbum::class, $this->album1])); + } + + public function testCanAssignFaceInAlbumPrivacyPreservingOwnerOnly(): void + { + Configs::set('ai_vision_face_permission_mode', 'privacy-preserving'); + $this->actingAs($this->userMayUpload1); + $this->assertTrue(Gate::check(AlbumPolicy::CAN_ASSIGN_FACE_IN_ALBUM, [AbstractAlbum::class, $this->album1])); + $this->actingAs($this->userMayUpload2); + $this->assertFalse(Gate::check(AlbumPolicy::CAN_ASSIGN_FACE_IN_ALBUM, [AbstractAlbum::class, $this->album1])); + } + + public function testCanAssignFaceInAlbumRestrictedDeniesEveryone(): void + { + Configs::set('ai_vision_face_permission_mode', 'restricted'); + $this->actingAs($this->userMayUpload1); + $this->assertFalse(Gate::check(AlbumPolicy::CAN_ASSIGN_FACE_IN_ALBUM, [AbstractAlbum::class, $this->album1])); + $this->actingAs($this->userMayUpload2); + $this->assertFalse(Gate::check(AlbumPolicy::CAN_ASSIGN_FACE_IN_ALBUM, [AbstractAlbum::class, $this->album1])); + } + + public function testCanAssignFaceInAlbumAdminAlwaysTrue(): void + { + $this->actingAs($this->admin); + foreach (['public', 'private', 'privacy-preserving', 'restricted'] as $mode) { + Configs::set('ai_vision_face_permission_mode', $mode); + $this->assertTrue(Gate::check(AlbumPolicy::CAN_ASSIGN_FACE_IN_ALBUM, [AbstractAlbum::class, $this->album1]), "Admin failed for mode: {$mode}"); + } + } + + // \u2500\u2500 canBatchFaceOps \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + + public function testCanBatchFaceOpsPublicAndPrivateModeLoggedUser(): void + { + foreach (['public', 'private'] as $mode) { + Configs::set('ai_vision_face_permission_mode', $mode); + $this->actingAs($this->userMayUpload1); + $this->assertTrue(Gate::check(AlbumPolicy::CAN_BATCH_FACE_OPS, [AbstractAlbum::class, $this->album1]), "Owner failed for mode: {$mode}"); + $this->actingAs($this->userMayUpload2); + $this->assertTrue(Gate::check(AlbumPolicy::CAN_BATCH_FACE_OPS, [AbstractAlbum::class, $this->album1]), "Non-owner failed for mode: {$mode}"); + } + } + + public function testCanBatchFaceOpsPublicModeGuestDenied(): void + { + Configs::set('ai_vision_face_permission_mode', 'public'); + Auth::logout(); + $this->assertFalse(Gate::check(AlbumPolicy::CAN_BATCH_FACE_OPS, [AbstractAlbum::class, $this->album1])); + } + + public function testCanBatchFaceOpsPrivacyPreservingOwnerOnly(): void + { + Configs::set('ai_vision_face_permission_mode', 'privacy-preserving'); + $this->actingAs($this->userMayUpload1); + $this->assertTrue(Gate::check(AlbumPolicy::CAN_BATCH_FACE_OPS, [AbstractAlbum::class, $this->album1])); + $this->actingAs($this->userMayUpload2); + $this->assertFalse(Gate::check(AlbumPolicy::CAN_BATCH_FACE_OPS, [AbstractAlbum::class, $this->album1])); + } + + public function testCanBatchFaceOpsRestrictedDeniesEveryone(): void + { + Configs::set('ai_vision_face_permission_mode', 'restricted'); + $this->actingAs($this->userMayUpload1); + $this->assertFalse(Gate::check(AlbumPolicy::CAN_BATCH_FACE_OPS, [AbstractAlbum::class, $this->album1])); + $this->actingAs($this->userMayUpload2); + $this->assertFalse(Gate::check(AlbumPolicy::CAN_BATCH_FACE_OPS, [AbstractAlbum::class, $this->album1])); + } + + public function testCanBatchFaceOpsNullAlbumDenies(): void + { + foreach (['public', 'private'] as $mode) { + Configs::set('ai_vision_face_permission_mode', $mode); + $this->actingAs($this->userMayUpload1); + $this->assertFalse(Gate::check(AlbumPolicy::CAN_BATCH_FACE_OPS, [AbstractAlbum::class, null]), "Null album should always deny for mode: {$mode}"); + } + } + + public function testCanBatchFaceOpsAdminAlwaysTrueWithAlbum(): void + { + $this->actingAs($this->admin); + foreach (['public', 'private', 'privacy-preserving', 'restricted'] as $mode) { + Configs::set('ai_vision_face_permission_mode', $mode); + $this->assertTrue(Gate::check(AlbumPolicy::CAN_BATCH_FACE_OPS, [AbstractAlbum::class, $this->album1]), "Admin failed for mode: {$mode}"); + } + } + + public function testCanBatchFaceOpsAiVisionDisabledDeniesNonAdmin(): void + { + Configs::set('ai_vision_enabled', '0'); + $this->actingAs($this->userMayUpload1); + $this->assertFalse(Gate::check(AlbumPolicy::CAN_BATCH_FACE_OPS, [AbstractAlbum::class, $this->album1])); + } +} 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/FaceBatchTest.php b/tests/Feature_v2/AiVision/FaceBatchTest.php new file mode 100644 index 00000000000..d980e77dc24 --- /dev/null +++ b/tests/Feature_v2/AiVision/FaceBatchTest.php @@ -0,0 +1,255 @@ +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->face1 = Face::factory()->for_photo($this->photo1)->without_crop()->create(); + $this->face2 = Face::factory()->for_photo($this->photo1)->without_crop()->create(); + $this->face3 = Face::factory()->for_photo($this->photo2)->without_crop()->create(); + } + + public function tearDown(): void + { + DB::table('faces')->delete(); + DB::table('persons')->delete(); + $this->resetSe(); + parent::tearDown(); + } + + // ── BATCH ASSIGN TO EXISTING PERSON ────────────────────────── + + public function testBatchAssignToExistingPerson(): void + { + $response = $this->actingAs($this->userMayUpload1)->postJson('Face/batch', [ + 'face_ids' => [$this->face1->id, $this->face2->id], + 'action' => 'assign', + 'person_id' => $this->person1->id, + ]); + $this->assertStatus($response, 200); + self::assertEquals(2, $response->json('affected_count')); + self::assertEquals($this->person1->id, $response->json('person_id')); + + // Verify faces were assigned + $this->face1->refresh(); + $this->face2->refresh(); + self::assertEquals($this->person1->id, $this->face1->person_id); + self::assertEquals($this->person1->id, $this->face2->person_id); + } + + public function testBatchAssignCreatesNewPerson(): void + { + $response = $this->actingAs($this->userMayUpload1)->postJson('Face/batch', [ + 'face_ids' => [$this->face1->id, $this->face2->id], + 'action' => 'assign', + 'new_person_name' => 'Charlie', + ]); + $this->assertStatus($response, 200); + self::assertEquals(2, $response->json('affected_count')); + 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); + + // Verify faces were assigned + $this->face1->refresh(); + $this->face2->refresh(); + self::assertEquals($new_person->id, $this->face1->person_id); + self::assertEquals($new_person->id, $this->face2->person_id); + } + + public function testBatchAssignRequiresPersonIdOrName(): void + { + $response = $this->actingAs($this->userMayUpload1)->postJson('Face/batch', [ + 'face_ids' => [$this->face1->id], + 'action' => 'assign', + ]); + $this->assertStatus($response, 422); + } + + // ── BATCH UNASSIGN ─────────────────────────────────────────── + + public function testBatchUnassign(): void + { + // Assign faces first + $this->face1->person_id = $this->person1->id; + $this->face1->save(); + $this->face2->person_id = $this->person2->id; + $this->face2->save(); + + $response = $this->actingAs($this->userMayUpload1)->postJson('Face/batch', [ + 'face_ids' => [$this->face1->id, $this->face2->id], + 'action' => 'unassign', + ]); + $this->assertStatus($response, 200); + self::assertEquals(2, $response->json('affected_count')); + self::assertNull($response->json('person_id')); + + // Verify faces were unassigned + $this->face1->refresh(); + $this->face2->refresh(); + self::assertNull($this->face1->person_id); + self::assertNull($this->face2->person_id); + } + + public function testBatchUnassignPartial(): void + { + // Only face1 is assigned + $this->face1->person_id = $this->person1->id; + $this->face1->save(); + + $response = $this->actingAs($this->userMayUpload1)->postJson('Face/batch', [ + 'face_ids' => [$this->face1->id, $this->face2->id], + 'action' => 'unassign', + ]); + $this->assertStatus($response, 200); + self::assertEquals(2, $response->json('affected_count')); + + // Verify both faces are now unassigned + $this->face1->refresh(); + $this->face2->refresh(); + self::assertNull($this->face1->person_id); + self::assertNull($this->face2->person_id); + } + + // ── BATCH VALIDATION ────────────────────────────────────────── + + public function testBatchRequiresFaceIds(): void + { + $response = $this->actingAs($this->userMayUpload1)->postJson('Face/batch', [ + 'action' => 'assign', + 'person_id' => $this->person1->id, + ]); + $this->assertStatus($response, 422); + } + + public function testBatchRequiresAction(): void + { + $response = $this->actingAs($this->userMayUpload1)->postJson('Face/batch', [ + 'face_ids' => [$this->face1->id], + ]); + $this->assertStatus($response, 422); + } + + public function testBatchRejectsInvalidAction(): void + { + $response = $this->actingAs($this->userMayUpload1)->postJson('Face/batch', [ + 'face_ids' => [$this->face1->id], + 'action' => 'delete', + ]); + $this->assertStatus($response, 422); + } + + // ── UNCLUSTER FACES ─────────────────────────────────────────── + + public function testUnclusterFacesFromCluster(): void + { + // Set up a cluster + $this->face1->cluster_label = 42; + $this->face1->save(); + $this->face2->cluster_label = 42; + $this->face2->save(); + $this->face3->cluster_label = 42; + $this->face3->save(); + + $response = $this->actingAs($this->userMayUpload1)->postJson('clusters/42/uncluster', [ + 'face_ids' => [$this->face1->id, $this->face2->id], + ]); + $this->assertStatus($response, 200); + self::assertEquals(2, $response->json('unclustered_count')); + + // Verify faces were unclustered + $this->face1->refresh(); + $this->face2->refresh(); + $this->face3->refresh(); + self::assertNull($this->face1->cluster_label); + self::assertNull($this->face2->cluster_label); + self::assertEquals(42, $this->face3->cluster_label); // unchanged + } + + public function testUnclusterIgnoresAlreadyAssignedFaces(): void + { + // Set up a cluster with one face assigned to a person + $this->face1->cluster_label = 42; + $this->face1->person_id = $this->person1->id; + $this->face1->save(); + $this->face2->cluster_label = 42; + $this->face2->save(); + + $response = $this->actingAs($this->userMayUpload1)->postJson('clusters/42/uncluster', [ + 'face_ids' => [$this->face1->id, $this->face2->id], + ]); + $this->assertStatus($response, 200); + self::assertEquals(1, $response->json('unclustered_count')); // only face2 + + // Verify only unassigned face was unclustered + $this->face1->refresh(); + $this->face2->refresh(); + self::assertEquals(42, $this->face1->cluster_label); // unchanged (assigned) + self::assertNull($this->face2->cluster_label); // unclustered + } + + public function testUnclusterIgnoresDismissedFaces(): void + { + // Set up a cluster with one face dismissed + $this->face1->cluster_label = 42; + $this->face1->is_dismissed = true; + $this->face1->save(); + $this->face2->cluster_label = 42; + $this->face2->save(); + + $response = $this->actingAs($this->userMayUpload1)->postJson('clusters/42/uncluster', [ + 'face_ids' => [$this->face1->id, $this->face2->id], + ]); + $this->assertStatus($response, 200); + self::assertEquals(1, $response->json('unclustered_count')); // only face2 + + // Verify only non-dismissed face was unclustered + $this->face1->refresh(); + $this->face2->refresh(); + self::assertEquals(42, $this->face1->cluster_label); // unchanged (dismissed) + self::assertNull($this->face2->cluster_label); // unclustered + } + + public function testUnclusterRequiresFaceIds(): void + { + $response = $this->actingAs($this->userMayUpload1)->postJson('clusters/42/uncluster', []); + $this->assertStatus($response, 422); + } +} diff --git a/tests/Feature_v2/AiVision/FaceClusterFacesTest.php b/tests/Feature_v2/AiVision/FaceClusterFacesTest.php new file mode 100644 index 00000000000..f854b85580f --- /dev/null +++ b/tests/Feature_v2/AiVision/FaceClusterFacesTest.php @@ -0,0 +1,109 @@ +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(); + } + + // ── GET /FaceDetection/clusters/{label}/faces ──────────────── + + public function testGetFacesForCluster(): void + { + Face::factory()->for_photo($this->photo1)->with_cluster(10)->count(3)->create(); + + $response = $this->actingAs($this->userMayUpload1)->getJson('FaceDetection/clusters/10/faces'); + $this->assertOk($response); + + $data = $response->json('data'); + self::assertCount(3, $data); + } + + public function testGetFacesForClusterReturns404ForUnknownCluster(): void + { + $response = $this->actingAs($this->admin)->getJson('FaceDetection/clusters/9999/faces'); + $this->assertNotFound($response); + } + + public function testGetFacesExcludesAssignedFaces(): void + { + $person = Person::factory()->create(); + Face::factory()->for_photo($this->photo1)->for_person($person)->with_cluster(20)->create(); + // Only an unassigned face in cluster 20 should be returned + Face::factory()->for_photo($this->photo1)->with_cluster(20)->create(); + + $response = $this->actingAs($this->admin)->getJson('FaceDetection/clusters/20/faces'); + $this->assertOk($response); + + $data = $response->json('data'); + self::assertCount(1, $data); + self::assertNull($data[0]['person_id']); + } + + public function testGetFacesExcludesDismissedFaces(): void + { + Face::factory()->for_photo($this->photo1)->dismissed()->with_cluster(30)->create(); + // The dismissed face above should cause 404 since no qualifying faces remain + $response = $this->actingAs($this->admin)->getJson('FaceDetection/clusters/30/faces'); + $this->assertNotFound($response); + } + + public function testGetFacesAsGuestUnauthorized(): void + { + $response = $this->getJson('FaceDetection/clusters/10/faces'); + $this->assertUnauthorized($response); + } + + public function testGetFacesPaginated(): void + { + Face::factory()->for_photo($this->photo1)->with_cluster(40)->count(5)->create(); + + $response = $this->actingAs($this->admin)->getJson('FaceDetection/clusters/40/faces?page=1'); + $this->assertOk($response); + + $data = $response->json('data'); + self::assertCount(5, $data); + + $firstFace = $data[0]; + self::assertArrayHasKey('id', $firstFace); + self::assertArrayHasKey('photo_id', $firstFace); + self::assertArrayHasKey('confidence', $firstFace); + self::assertArrayHasKey('is_dismissed', $firstFace); + self::assertFalse($firstFace['is_dismissed']); + } +} 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/FaceMaintenanceTest.php b/tests/Feature_v2/AiVision/FaceMaintenanceTest.php new file mode 100644 index 00000000000..5d98a71c97b --- /dev/null +++ b/tests/Feature_v2/AiVision/FaceMaintenanceTest.php @@ -0,0 +1,128 @@ +requireSe(); + + Configs::set('ai_vision_enabled', '1'); + Configs::set('ai_vision_face_enabled', '1'); + Configs::set('ai_vision_face_permission_mode', 'public'); + + $this->admin = User::factory()->may_administrate()->create(); + $this->nonAdmin = User::factory()->create(); + $this->photo = Photo::factory()->create(['owner_id' => $this->admin->id]); + } + + public function tearDown(): void + { + $this->resetSe(); + parent::tearDown(); + } + + // ── GET /Face/maintenance ───────────────────────────────── + + public function testListFacesSortedByConfidenceAscending(): void + { + Face::factory()->for_photo($this->photo)->create(['confidence' => 0.5, 'laplacian_variance' => 100.0]); + Face::factory()->for_photo($this->photo)->create(['confidence' => 0.9, 'laplacian_variance' => 50.0]); + Face::factory()->for_photo($this->photo)->create(['confidence' => 0.7, 'laplacian_variance' => 200.0]); + + $response = $this->actingAs($this->admin)->getJson('Face/maintenance?sort_by=confidence&sort_dir=asc'); + $this->assertOk($response); + + $data = $response->json('data'); + self::assertCount(3, $data); + self::assertLessThanOrEqual($data[1]['confidence'], $data[0]['confidence']); + self::assertLessThanOrEqual($data[2]['confidence'], $data[1]['confidence']); + } + + public function testListFacesSortedByLaplacianVarianceAscending(): void + { + Face::factory()->for_photo($this->photo)->create(['confidence' => 0.8, 'laplacian_variance' => 300.0]); + Face::factory()->for_photo($this->photo)->create(['confidence' => 0.6, 'laplacian_variance' => 10.0]); + Face::factory()->for_photo($this->photo)->create(['confidence' => 0.7, 'laplacian_variance' => 150.0]); + + $response = $this->actingAs($this->admin)->getJson('Face/maintenance?sort_by=laplacian_variance&sort_dir=asc'); + $this->assertOk($response); + + $data = $response->json('data'); + self::assertCount(3, $data); + self::assertLessThanOrEqual($data[1]['laplacian_variance'], $data[0]['laplacian_variance']); + self::assertLessThanOrEqual($data[2]['laplacian_variance'], $data[1]['laplacian_variance']); + } + + public function testDefaultSortIsConfidenceAsc(): void + { + Face::factory()->for_photo($this->photo)->create(['confidence' => 0.2]); + Face::factory()->for_photo($this->photo)->create(['confidence' => 0.9]); + + $response = $this->actingAs($this->admin)->getJson('Face/maintenance'); + $this->assertOk($response); + + $data = $response->json('data'); + self::assertCount(2, $data); + // Default sort_by=confidence, sort_dir=asc: lowest confidence first + self::assertLessThanOrEqual($data[1]['confidence'], $data[0]['confidence']); + } + + public function testPaginationWorks(): void + { + Face::factory()->for_photo($this->photo)->count(5)->create(); + + $response = $this->actingAs($this->admin)->getJson('Face/maintenance?per_page=2&page=1'); + $this->assertOk($response); + + $data = $response->json('data'); + $meta = $response->json('meta'); + self::assertCount(2, $data); + self::assertEquals(5, $meta['total']); + self::assertEquals(2, $meta['per_page']); + self::assertEquals(3, $meta['last_page']); + } + + public function testAdminOnlyNonAdminGetsForbidden(): void + { + $response = $this->actingAs($this->nonAdmin)->getJson('Face/maintenance'); + $this->assertForbidden($response); + } + + public function testResponseIncludesPersonNameAndClusterLabel(): void + { + Face::factory()->for_photo($this->photo)->with_cluster(7)->create(['confidence' => 0.8]); + + $response = $this->actingAs($this->admin)->getJson('Face/maintenance'); + $this->assertOk($response); + + $data = $response->json('data'); + self::assertCount(1, $data); + self::assertEquals(0.8, $data[0]['confidence']); + self::assertEquals(7, $data[0]['cluster_label']); + self::assertArrayHasKey('laplacian_variance', $data[0]); + } +} diff --git a/tests/Feature_v2/AiVision/MaintenanceResetFaceScanStatusTest.php b/tests/Feature_v2/AiVision/MaintenanceResetFaceScanStatusTest.php new file mode 100644 index 00000000000..f18a195f304 --- /dev/null +++ b/tests/Feature_v2/AiVision/MaintenanceResetFaceScanStatusTest.php @@ -0,0 +1,163 @@ +delete(); + DB::table('faces')->delete(); + DB::table('persons')->delete(); + parent::tearDown(); + } + + // ── CHECK (GET) ───────────────────────────────────────────── + + public function testCheckAsGuest(): void + { + $response = $this->getJson('Maintenance::resetFaceScanStatus'); + $this->assertUnauthorized($response); + } + + public function testCheckAsUser(): void + { + $response = $this->actingAs($this->userMayUpload1)->getJson('Maintenance::resetFaceScanStatus'); + $this->assertForbidden($response); + } + + public function testCheckAsAdmin(): void + { + // Create a stuck pending photo and a failed photo + Photo::where('id', $this->photo1->id)->update([ + 'face_scan_status' => FaceScanStatus::PENDING, + 'updated_at' => Carbon::now()->subMinutes(800), + ]); + + Photo::where('id', $this->photo2->id)->update([ + 'face_scan_status' => FaceScanStatus::FAILED, + ]); + + $response = $this->actingAs($this->admin)->getJson('Maintenance::resetFaceScanStatus'); + $this->assertOk($response); + + $count = $response->json(); + self::assertGreaterThanOrEqual(2, $count); // Should count both stuck and failed + } + + public function testCheckReturnsZeroWhenNoneStuck(): void + { + $response = $this->actingAs($this->admin)->getJson('Maintenance::resetFaceScanStatus'); + $this->assertOk($response); + self::assertEquals(0, $response->json()); + } + + // ── DO (POST) ─────────────────────────────────────────────── + + public function testDoAsGuest(): void + { + $response = $this->postJson('Maintenance::resetFaceScanStatus'); + $this->assertUnauthorized($response); + } + + public function testDoAsUser(): void + { + $response = $this->actingAs($this->userMayUpload1)->postJson('Maintenance::resetFaceScanStatus'); + $this->assertForbidden($response); + } + + public function testDoResetsStuckPending(): void + { + // Create a stuck pending photo (older than default threshold of 720 min) + 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::resetFaceScanStatus'); + $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 testDoResetsFailed(): void + { + // Create a failed photo + Photo::where('id', $this->photo1->id)->update([ + 'face_scan_status' => FaceScanStatus::FAILED, + ]); + + $response = $this->actingAs($this->admin)->postJson('Maintenance::resetFaceScanStatus'); + $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 testDoResetsBothStuckAndFailed(): void + { + // Create stuck pending and failed photos + Photo::where('id', $this->photo1->id)->update([ + 'face_scan_status' => FaceScanStatus::PENDING, + 'updated_at' => Carbon::now()->subMinutes(800), + ]); + + Photo::where('id', $this->photo2->id)->update([ + 'face_scan_status' => FaceScanStatus::FAILED, + ]); + + $response = $this->actingAs($this->admin)->postJson('Maintenance::resetFaceScanStatus'); + $this->assertOk($response); + self::assertGreaterThanOrEqual(2, $response->json('reset_count')); + + // Both photos should be reset to null + $this->photo1->refresh(); + $this->photo2->refresh(); + self::assertNull($this->photo1->face_scan_status); + self::assertNull($this->photo2->face_scan_status); + } + + public function testDoDoesNotResetRecentPending(): void + { + // Set pending photo that is recent (5 minutes) - should NOT be reset + 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::resetFaceScanStatus'); + $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..0ea0e7013b2 --- /dev/null +++ b/tests/Feature_v2/AiVision/PersonPhotosTest.php @@ -0,0 +1,106 @@ +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('photos'))->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('photos')); + } + + public function testNextPreviousIdsAreSetRelativeToPersonCollection(): void + { + Face::factory()->for_photo($this->photo1)->for_person($this->person1)->create(); + Face::factory()->for_photo($this->photo2)->for_person($this->person1)->create(); + Face::factory()->for_photo($this->photo3)->for_person($this->person1)->create(); + + $response = $this->actingAs($this->admin)->getJson('Person/' . $this->person1->id . '/photos'); + $this->assertOk($response); + + $photos = $response->json('photos'); + self::assertCount(3, $photos); + + // First photo: previous_photo_id must be null + self::assertNull($photos[0]['previous_photo_id']); + // First photo points forward to second + self::assertSame($photos[1]['id'], $photos[0]['next_photo_id']); + // Middle photo chained correctly + self::assertSame($photos[0]['id'], $photos[1]['previous_photo_id']); + self::assertSame($photos[2]['id'], $photos[1]['next_photo_id']); + // Last photo: next_photo_id must be null + self::assertNull($photos[2]['next_photo_id']); + self::assertSame($photos[1]['id'], $photos[2]['previous_photo_id']); + } + + 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/PhotoPolicyFaceTest.php b/tests/Feature_v2/AiVision/PhotoPolicyFaceTest.php new file mode 100644 index 00000000000..2167e065e8f --- /dev/null +++ b/tests/Feature_v2/AiVision/PhotoPolicyFaceTest.php @@ -0,0 +1,297 @@ +requireSe(); + + Configs::set('ai_vision_enabled', '1'); + Configs::set('ai_vision_face_enabled', '1'); + } + + public function tearDown(): void + { + DB::table('face_suggestions')->delete(); + DB::table('faces')->delete(); + DB::table('persons')->delete(); + $this->resetSe(); + parent::tearDown(); + } + + // \u2500\u2500 canViewFaceOverlays \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + + public function testCanViewFaceOverlaysPublicModeGuestCannotSeePrivatePhoto(): void + { + Configs::set('ai_vision_face_permission_mode', 'public'); + // album1 is private; guest cannot see photo1 + Auth::logout(); + $this->assertFalse(Gate::check(PhotoPolicy::CAN_VIEW_FACE_OVERLAYS, $this->photo1)); + } + + public function testCanViewFaceOverlaysPublicModeOwnerCanView(): void + { + Configs::set('ai_vision_face_permission_mode', 'public'); + $this->actingAs($this->userMayUpload1); + $this->assertTrue(Gate::check(PhotoPolicy::CAN_VIEW_FACE_OVERLAYS, $this->photo1)); + } + + public function testCanViewFaceOverlaysPublicModeLoggedNonOwnerWithAlbumAccess(): void + { + Configs::set('ai_vision_face_permission_mode', 'public'); + // userMayUpload2 has perm1 on album1 -> can access -> can view overlays. + // Use a fresh model so the album\'s cached access_permissions include perm1. + $this->actingAs($this->userMayUpload2); + $fresh_photo = Photo::find($this->photo1->id); + $this->assertTrue(Gate::check(PhotoPolicy::CAN_VIEW_FACE_OVERLAYS, $fresh_photo)); + } + + public function testCanViewFaceOverlaysPrivateModeLoggedOwnerCanView(): void + { + Configs::set('ai_vision_face_permission_mode', 'private'); + $this->actingAs($this->userMayUpload1); + $this->assertTrue(Gate::check(PhotoPolicy::CAN_VIEW_FACE_OVERLAYS, $this->photo1)); + } + + public function testCanViewFaceOverlaysPrivateModeLoggedNonOwnerCanView(): void + { + Configs::set('ai_vision_face_permission_mode', 'private'); + $this->actingAs($this->userMayUpload2); + $this->assertTrue(Gate::check(PhotoPolicy::CAN_VIEW_FACE_OVERLAYS, $this->photo1)); + } + + public function testCanViewFaceOverlaysPrivateModeGuestCannotView(): void + { + Configs::set('ai_vision_face_permission_mode', 'private'); + Auth::logout(); + $this->assertFalse(Gate::check(PhotoPolicy::CAN_VIEW_FACE_OVERLAYS, $this->photo1)); + } + + public function testCanViewFaceOverlaysPrivacyPreservingOwnerCanView(): void + { + Configs::set('ai_vision_face_permission_mode', 'privacy-preserving'); + $this->actingAs($this->userMayUpload1); + $this->assertTrue(Gate::check(PhotoPolicy::CAN_VIEW_FACE_OVERLAYS, $this->photo1)); + } + + public function testCanViewFaceOverlaysPrivacyPreservingNonOwnerDenied(): void + { + Configs::set('ai_vision_face_permission_mode', 'privacy-preserving'); + $this->actingAs($this->userMayUpload2); + $this->assertFalse(Gate::check(PhotoPolicy::CAN_VIEW_FACE_OVERLAYS, $this->photo1)); + } + + public function testCanViewFaceOverlaysPrivacyPreservingGuestDenied(): void + { + Configs::set('ai_vision_face_permission_mode', 'privacy-preserving'); + Auth::logout(); + $this->assertFalse(Gate::check(PhotoPolicy::CAN_VIEW_FACE_OVERLAYS, $this->photo1)); + } + + public function testCanViewFaceOverlaysRestrictedOwnerCanView(): void + { + Configs::set('ai_vision_face_permission_mode', 'restricted'); + $this->actingAs($this->userMayUpload1); + $this->assertTrue(Gate::check(PhotoPolicy::CAN_VIEW_FACE_OVERLAYS, $this->photo1)); + } + + public function testCanViewFaceOverlaysRestrictedNonOwnerDenied(): void + { + Configs::set('ai_vision_face_permission_mode', 'restricted'); + $this->actingAs($this->userMayUpload2); + $this->assertFalse(Gate::check(PhotoPolicy::CAN_VIEW_FACE_OVERLAYS, $this->photo1)); + } + + public function testCanViewFaceOverlaysAdminAlwaysTrue(): void + { + $this->actingAs($this->admin); + foreach (['public', 'private', 'privacy-preserving', 'restricted'] as $mode) { + Configs::set('ai_vision_face_permission_mode', $mode); + $this->assertTrue(Gate::check(PhotoPolicy::CAN_VIEW_FACE_OVERLAYS, $this->photo1), "Admin failed for mode: {$mode}"); + } + } + + public function testCanViewFaceOverlaysAiVisionDisabledDeniesNonAdmin(): void + { + Configs::set('ai_vision_enabled', '0'); + Configs::set('ai_vision_face_permission_mode', 'public'); + $this->actingAs($this->userMayUpload1); + $this->assertFalse(Gate::check(PhotoPolicy::CAN_VIEW_FACE_OVERLAYS, $this->photo1)); + } + + // \u2500\u2500 canDismissFace \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + + public function testCanDismissFaceOwnerCanDismissInAllModes(): void + { + $this->actingAs($this->userMayUpload1); + foreach (['public', 'private', 'privacy-preserving', 'restricted'] as $mode) { + Configs::set('ai_vision_face_permission_mode', $mode); + $this->assertTrue(Gate::check(PhotoPolicy::CAN_DISMISS_FACE, $this->photo1), "Owner failed for mode: {$mode}"); + } + } + + public function testCanDismissFaceNonOwnerDeniedInAllModes(): void + { + $this->actingAs($this->userMayUpload2); + foreach (['public', 'private', 'privacy-preserving', 'restricted'] as $mode) { + Configs::set('ai_vision_face_permission_mode', $mode); + $this->assertFalse(Gate::check(PhotoPolicy::CAN_DISMISS_FACE, $this->photo1), "Non-owner passed for mode: {$mode}"); + } + } + + public function testCanDismissFaceGuestDenied(): void + { + Auth::logout(); + Configs::set('ai_vision_face_permission_mode', 'public'); + $this->assertFalse(Gate::check(PhotoPolicy::CAN_DISMISS_FACE, $this->photo1)); + } + + public function testCanDismissFaceAdminAlwaysTrue(): void + { + $this->actingAs($this->admin); + Configs::set('ai_vision_face_permission_mode', 'restricted'); + $this->assertTrue(Gate::check(PhotoPolicy::CAN_DISMISS_FACE, $this->photo1)); + } + + public function testCanDismissFaceAiVisionDisabledDeniesNonAdmin(): void + { + Configs::set('ai_vision_enabled', '0'); + $this->actingAs($this->userMayUpload1); + $this->assertFalse(Gate::check(PhotoPolicy::CAN_DISMISS_FACE, $this->photo1)); + } + + // \u2500\u2500 canAssignFaceOnPhoto \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + + public function testCanAssignFaceOnPhotoPublicModeOwnerCanAssign(): void + { + Configs::set('ai_vision_face_permission_mode', 'public'); + $this->actingAs($this->userMayUpload1); + $this->assertTrue(Gate::check(PhotoPolicy::CAN_ASSIGN_FACE_ON_PHOTO, $this->photo1)); + } + + public function testCanAssignFaceOnPhotoPublicModeNonOwnerLoggedInCanAssign(): void + { + Configs::set('ai_vision_face_permission_mode', 'public'); + $this->actingAs($this->userMayUpload2); + $this->assertTrue(Gate::check(PhotoPolicy::CAN_ASSIGN_FACE_ON_PHOTO, $this->photo1)); + } + + public function testCanAssignFaceOnPhotoPublicModeGuestDenied(): void + { + Configs::set('ai_vision_face_permission_mode', 'public'); + Auth::logout(); + $this->assertFalse(Gate::check(PhotoPolicy::CAN_ASSIGN_FACE_ON_PHOTO, $this->photo1)); + } + + public function testCanAssignFaceOnPhotoPrivacyPreservingOwnerOnly(): void + { + Configs::set('ai_vision_face_permission_mode', 'privacy-preserving'); + $this->actingAs($this->userMayUpload1); + $this->assertTrue(Gate::check(PhotoPolicy::CAN_ASSIGN_FACE_ON_PHOTO, $this->photo1)); + $this->actingAs($this->userMayUpload2); + $this->assertFalse(Gate::check(PhotoPolicy::CAN_ASSIGN_FACE_ON_PHOTO, $this->photo1)); + } + + public function testCanAssignFaceOnPhotoRestrictedDeniesEveryone(): void + { + Configs::set('ai_vision_face_permission_mode', 'restricted'); + $this->actingAs($this->userMayUpload1); + $this->assertFalse(Gate::check(PhotoPolicy::CAN_ASSIGN_FACE_ON_PHOTO, $this->photo1)); + $this->actingAs($this->userMayUpload2); + $this->assertFalse(Gate::check(PhotoPolicy::CAN_ASSIGN_FACE_ON_PHOTO, $this->photo1)); + } + + public function testCanAssignFaceOnPhotoAdminAlwaysTrue(): void + { + $this->actingAs($this->admin); + foreach (['public', 'private', 'privacy-preserving', 'restricted'] as $mode) { + Configs::set('ai_vision_face_permission_mode', $mode); + $this->assertTrue(Gate::check(PhotoPolicy::CAN_ASSIGN_FACE_ON_PHOTO, $this->photo1), "Admin failed for mode: {$mode}"); + } + } + + // \u2500\u2500 canTriggerScanOnPhoto \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + + public function testCanTriggerScanOnPhotoPublicAndPrivateModeLoggedUser(): void + { + foreach (['public', 'private'] as $mode) { + Configs::set('ai_vision_face_permission_mode', $mode); + $this->actingAs($this->userMayUpload1); + $this->assertTrue(Gate::check(PhotoPolicy::CAN_TRIGGER_SCAN_ON_PHOTO, $this->photo1), "Owner failed for mode: {$mode}"); + $this->actingAs($this->userMayUpload2); + $this->assertTrue(Gate::check(PhotoPolicy::CAN_TRIGGER_SCAN_ON_PHOTO, $this->photo1), "Non-owner failed for mode: {$mode}"); + } + } + + public function testCanTriggerScanOnPhotoPublicModeGuestDenied(): void + { + Configs::set('ai_vision_face_permission_mode', 'public'); + Auth::logout(); + $this->assertFalse(Gate::check(PhotoPolicy::CAN_TRIGGER_SCAN_ON_PHOTO, $this->photo1)); + } + + public function testCanTriggerScanOnPhotoPrivacyPreservingOwnerOnly(): void + { + Configs::set('ai_vision_face_permission_mode', 'privacy-preserving'); + $this->actingAs($this->userMayUpload1); + $this->assertTrue(Gate::check(PhotoPolicy::CAN_TRIGGER_SCAN_ON_PHOTO, $this->photo1)); + $this->actingAs($this->userMayUpload2); + $this->assertFalse(Gate::check(PhotoPolicy::CAN_TRIGGER_SCAN_ON_PHOTO, $this->photo1)); + } + + public function testCanTriggerScanOnPhotoRestrictedOwnerOnly(): void + { + Configs::set('ai_vision_face_permission_mode', 'restricted'); + $this->actingAs($this->userMayUpload1); + $this->assertTrue(Gate::check(PhotoPolicy::CAN_TRIGGER_SCAN_ON_PHOTO, $this->photo1)); + $this->actingAs($this->userMayUpload2); + $this->assertFalse(Gate::check(PhotoPolicy::CAN_TRIGGER_SCAN_ON_PHOTO, $this->photo1)); + } + + public function testCanTriggerScanOnPhotoAdminAlwaysTrue(): void + { + $this->actingAs($this->admin); + foreach (['public', 'private', 'privacy-preserving', 'restricted'] as $mode) { + Configs::set('ai_vision_face_permission_mode', $mode); + $this->assertTrue(Gate::check(PhotoPolicy::CAN_TRIGGER_SCAN_ON_PHOTO, $this->photo1), "Admin failed for mode: {$mode}"); + } + } + + public function testCanTriggerScanOnPhotoAiVisionDisabledDeniesNonAdmin(): void + { + Configs::set('ai_vision_enabled', '0'); + $this->actingAs($this->userMayUpload1); + $this->assertFalse(Gate::check(PhotoPolicy::CAN_TRIGGER_SCAN_ON_PHOTO, $this->photo1)); + } +} 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/Actions/Db/OptimizeDbTest.php b/tests/Unit/Actions/Db/OptimizeDbTest.php index 0d33dae6305..578d0fee057 100644 --- a/tests/Unit/Actions/Db/OptimizeDbTest.php +++ b/tests/Unit/Actions/Db/OptimizeDbTest.php @@ -32,6 +32,6 @@ public function testOptimizeDb(): void { $optimize = new OptimizeDb(); $output = count($optimize->do()); - self::assertTrue(in_array($output, [3, 39], true), 'OptimizeDb should return either 3 or 39: ' . $output); + self::assertTrue(in_array($output, [3, 42], true), 'OptimizeDb should return either 3 or 42: ' . $output); } } diff --git a/tests/Unit/Actions/Db/OptimizeTablesTest.php b/tests/Unit/Actions/Db/OptimizeTablesTest.php index d13a2a982f0..017c479a267 100644 --- a/tests/Unit/Actions/Db/OptimizeTablesTest.php +++ b/tests/Unit/Actions/Db/OptimizeTablesTest.php @@ -32,6 +32,6 @@ public function testOptimizeTables(): void { $optimize = new OptimizeTables(); $output = count($optimize->do()); - self::assertTrue(in_array($output, [38, 39], true), 'OptimizeTables should return either 38 or 39: ' . $output); + self::assertTrue(in_array($output, [41, 42], true), 'OptimizeTables should return either 41 or 42: ' . $output); } } diff --git a/tests/Unit/Models/FaceCounterPersonTest.php b/tests/Unit/Models/FaceCounterPersonTest.php new file mode 100644 index 00000000000..c47ed1d66ff --- /dev/null +++ b/tests/Unit/Models/FaceCounterPersonTest.php @@ -0,0 +1,187 @@ +user = User::factory()->may_upload()->create(); + $this->photo = Photo::factory()->owned_by($this->user)->create(); + $this->person = Person::factory()->create(); + } + + // ── (a) creating a non-dismissed face with person_id ────────────────── + + public function testCreatingActiveFaceIncrementsPersonCounters(): void + { + Face::factory()->for_photo($this->photo)->for_person($this->person)->create(); + + $this->person->refresh(); + self::assertEquals(1, $this->person->face_count); + self::assertEquals(1, $this->person->photo_count); + } + + // ── (b) second face for the SAME person+photo ───────────────────────── + + public function testSecondFaceOnSamePhotoIncrementsFaceCountOnly(): void + { + Face::factory()->for_photo($this->photo)->for_person($this->person)->create(); + Face::factory()->for_photo($this->photo)->for_person($this->person)->create(); + + $this->person->refresh(); + self::assertEquals(2, $this->person->face_count); + // photo_count must still be 1 (same photo) + self::assertEquals(1, $this->person->photo_count); + } + + // ── (c) dismissing one face leaves photo_count unchanged ───────────── + + public function testDismissingOneFaceOfTwoDecrementsFaceCountOnly(): void + { + $face1 = Face::factory()->for_photo($this->photo)->for_person($this->person)->create(); + Face::factory()->for_photo($this->photo)->for_person($this->person)->create(); + + $face1->is_dismissed = true; + $face1->save(); + + $this->person->refresh(); + // face_count decremented by 1 (dismissed face no longer counted) + self::assertEquals(1, $this->person->face_count); + // photo_count unchanged — the other face still active on same photo + self::assertEquals(1, $this->person->photo_count); + } + + // ── (d) dismissing the LAST face for a person+photo ───────────────── + + public function testDismissingLastFaceDecrementsBothCounters(): void + { + $face = Face::factory()->for_photo($this->photo)->for_person($this->person)->create(); + + $face->is_dismissed = true; + $face->save(); + + $this->person->refresh(); + self::assertEquals(0, $this->person->face_count); + self::assertEquals(0, $this->person->photo_count); + } + + // ── (e) undismissing a face re-increments the relevant counters ─────── + + public function testUndismissingFaceReincrementsCounters(): void + { + $face = Face::factory()->for_photo($this->photo)->for_person($this->person)->dismissed()->create(); + + // dismissed face should not have been counted by the observer + $this->person->refresh(); + self::assertEquals(0, $this->person->face_count); + self::assertEquals(0, $this->person->photo_count); + + $face->is_dismissed = false; + $face->save(); + + $this->person->refresh(); + self::assertEquals(1, $this->person->face_count); + self::assertEquals(1, $this->person->photo_count); + } + + // ── (f) deleting a non-dismissed face decrements the counters ───────── + + public function testDeletingActiveFaceDecrementsPersonCounters(): void + { + $face = Face::factory()->for_photo($this->photo)->for_person($this->person)->create(); + + $face->delete(); + + $this->person->refresh(); + self::assertEquals(0, $this->person->face_count); + self::assertEquals(0, $this->person->photo_count); + } + + // ── (g) deleting a dismissed face leaves counters unchanged ────────── + + public function testDeletingDismissedFaceLeavesPersonCountersUnchanged(): void + { + // Create one active face so counters are non-zero + Face::factory()->for_photo($this->photo)->for_person($this->person)->create(); + $dismissed = Face::factory()->for_photo($this->photo)->for_person($this->person)->dismissed()->create(); + + $this->person->refresh(); + $face_count_before = $this->person->face_count; + $photo_count_before = $this->person->photo_count; + + $dismissed->delete(); + + $this->person->refresh(); + self::assertEquals($face_count_before, $this->person->face_count); + self::assertEquals($photo_count_before, $this->person->photo_count); + } + + // ── (h) unassigning a face decrements counters for the old person ───── + + public function testUnassigningFaceDecrementsOldPersonCounters(): void + { + $face = Face::factory()->for_photo($this->photo)->for_person($this->person)->create(); + + $this->person->refresh(); + self::assertEquals(1, $this->person->face_count); + + $face->person_id = null; + $face->save(); + + $this->person->refresh(); + self::assertEquals(0, $this->person->face_count); + self::assertEquals(0, $this->person->photo_count); + } + + // ── reassigning a face from one person to another ───────────────────── + + public function testReassigningFaceUpdatesCountersOnBothPersons(): void + { + $person2 = Person::factory()->create(); + $face = Face::factory()->for_photo($this->photo)->for_person($this->person)->create(); + + $face->person_id = $person2->id; + $face->save(); + + $this->person->refresh(); + self::assertEquals(0, $this->person->face_count); + self::assertEquals(0, $this->person->photo_count); + + $person2->refresh(); + self::assertEquals(1, $person2->face_count); + self::assertEquals(1, $person2->photo_count); + } +} diff --git a/tests/Unit/Models/FaceCounterPhotoTest.php b/tests/Unit/Models/FaceCounterPhotoTest.php new file mode 100644 index 00000000000..e35c4f1e68b --- /dev/null +++ b/tests/Unit/Models/FaceCounterPhotoTest.php @@ -0,0 +1,143 @@ +user = User::factory()->may_upload()->create(); + $this->photo = Photo::factory()->owned_by($this->user)->create(); + } + + // ── (a) creating a non-dismissed face increments photo.face_count ───── + + public function testCreatingActiveFaceIncrementsPhotoFaceCount(): void + { + Face::factory()->for_photo($this->photo)->create(); + + $this->photo->refresh(); + self::assertEquals(1, $this->photo->face_count); + } + + // ── (b) creating a dismissed face does NOT increment photo.face_count ─ + + public function testCreatingDismissedFaceDoesNotIncrementPhotoFaceCount(): void + { + Face::factory()->for_photo($this->photo)->dismissed()->create(); + + $this->photo->refresh(); + self::assertEquals(0, $this->photo->face_count); + } + + // ── (c) dismissing a previously non-dismissed face decrements count ─── + + public function testDismissingActiveFaceDecrementsPhotoFaceCount(): void + { + $face = Face::factory()->for_photo($this->photo)->create(); + + $this->photo->refresh(); + self::assertEquals(1, $this->photo->face_count); + + $face->is_dismissed = true; + $face->save(); + + $this->photo->refresh(); + self::assertEquals(0, $this->photo->face_count); + } + + // ── (d) undismissing a face increments photo.face_count ────────────── + + public function testUndismissingFaceIncrementsPhotoFaceCount(): void + { + $face = Face::factory()->for_photo($this->photo)->dismissed()->create(); + + $this->photo->refresh(); + self::assertEquals(0, $this->photo->face_count); + + $face->is_dismissed = false; + $face->save(); + + $this->photo->refresh(); + self::assertEquals(1, $this->photo->face_count); + } + + // ── (e) deleting a non-dismissed face decrements photo.face_count ───── + + public function testDeletingActiveFaceDecrementsPhotoFaceCount(): void + { + $face = Face::factory()->for_photo($this->photo)->create(); + + $this->photo->refresh(); + self::assertEquals(1, $this->photo->face_count); + + $face->delete(); + + $this->photo->refresh(); + self::assertEquals(0, $this->photo->face_count); + } + + // ── (f) deleting a dismissed face leaves photo.face_count unchanged ─── + + public function testDeletingDismissedFaceLeavesPhotoFaceCountUnchanged(): void + { + // Create one active face so face_count is non-zero + Face::factory()->for_photo($this->photo)->create(); + $dismissed = Face::factory()->for_photo($this->photo)->dismissed()->create(); + + $this->photo->refresh(); + $count_before = $this->photo->face_count; + + $dismissed->delete(); + + $this->photo->refresh(); + self::assertEquals($count_before, $this->photo->face_count); + } + + // ── (g) changing person_id does NOT affect photo.face_count ────────── + + public function testChangingPersonIdDoesNotAffectPhotoFaceCount(): void + { + $person = Person::factory()->create(); + $face = Face::factory()->for_photo($this->photo)->create(); + + $this->photo->refresh(); + $count_before = $this->photo->face_count; + + $face->person_id = $person->id; + $face->save(); + + $this->photo->refresh(); + self::assertEquals($count_before, $this->photo->face_count); + } +} 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); + } +}