20 Commits

Author SHA1 Message Date
a4f64fad58 docs: update README for vision pipeline and Postgres stack
Reflect current state: Postgres+pgvector replaces SQLite, vision
pipeline (YOLO, CLIP, InsightFace, OCR) is shipped, card-grid browse
views for tags/colors/ratings/people, map view, duplicate detection,
and semantic search are all live. Remove completed items from future
features.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:50:28 +02:00
4bc6dc1dc8 feat: refactor grouping views into card-grid browse pattern
Replace Timeline-based grouped views (tags, colors, rated) with
dedicated card-grid components that drill into Timeline detail views
on click/Enter. Adds shared useCardGridNav hook for arrow-key
navigation across all four card grids (tags, colors, rated, people).

- TagsView, ColorsView, RatedView: card grid → inline Timeline detail
- PeopleView: migrated to same pattern (Timeline replaces custom grid)
- Tags endpoint: fall back to first associated photo for representative
- Filter store: add ratingMax for exact rating filtering in RatedView
- Timeline: remove tag/rating/color grouping; skip date headers when
  groupBy != 'date' so detail views render flat grids
- SettingsDialog: bump z-index above Leaflet map layers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:48:50 +02:00
fa9b21856f feat: replace face pipeline with InsightFace, add content classifier
Face detection/recognition:
- Replace YuNet + SFace with InsightFace buffalo_l (RetinaFace + ArcFace)
- 512-d ArcFace embeddings (was 128-d SFace), migration 0006 resizes column
- Remove YOLO person-bbox workaround — RetinaFace is accurate enough
- Detection threshold 0.65 cleanly separates real faces (0.72+) from
  false positives on dogs/paintings (0.56-0.61)

Content-type classification:
- CLIP zero-shot classifier using native PyTorch text encoder + ONNX
  image encoder for high-quality text-image similarity
- Categories: photograph, screenshot, document, receipt, meme, artwork
- Writes Tag(kind=content_type) per photo via photo_tags
- Margin-based confidence: top-1 vs top-2 score difference
- New ClassifierSettings in config (enabled, min_confidence)
- Wired into vision_fanout pipeline

Tested: 6 real faces from 4 photos (zero false positives), 11/13 photos
classified (8 photograph, 2 artwork, 1 meme).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:49:02 +02:00
f48e099bd2 fix: verify faces against YOLO person detections for precision
Cross-reference face detections with YOLO 'person' bounding boxes —
only keep faces that overlap >= 50% with a detected human body. This
eliminates false positives on dogs, paintings, and cartoons without
needing an aggressive score threshold.

Lower face detection threshold back to 0.6 since the person-overlap
check is now the primary precision filter.

Tested: 6 verified faces from 4 photos, zero false positives.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:13:01 +02:00
40d570f2c2 fix: raise face detection threshold to 0.85, tighten clustering
Eliminates false positives (dog faces, painting faces) by requiring
score >= 0.85. Tighten cluster eps to 0.25 for better separation.
Tested: 4 real faces from 2 photos, no false positives.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:09:43 +02:00
5f3fa5240e fix: search endpoint — correct column name and DISTINCT/ORDER BY
- Fix Photo.created_at → Photo.added_at (column doesn't exist)
- Fix Postgres DISTINCT + ORDER BY conflict by using a subquery for
  tag_id filtering instead of JOIN + DISTINCT on the outer query

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:55:55 +02:00
aba061dd43 fix: People view shows person's photos inline instead of navigating away
Clicking a person card now opens a detail sub-view within the People
section showing their photo grid. Back arrow returns to the card grid.
Photos are clickable to open the preview. Rename is available in both
the card grid and the detail header.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:51:22 +02:00
229611b4c3 fix: face clusters write photo_tags, raise detection threshold to 0.7
- recluster_faces now writes photo_tags rows for each face cluster so
  the tag count and tag_ids filter work (previously count was always 0)
- Old cluster tags and photo_tags are cleaned up before re-clustering
- Raise face detection threshold from 0.4 to 0.7 to reduce false
  positives (was detecting dog faces as people)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:48:19 +02:00
db20cbb7d8 fix: face detection — use OpenCV FaceDetectorYN and full-res originals
- Rewrite faces.py to use cv2.FaceDetectorYN instead of raw ONNX
  (handles multi-scale anchor decoding and NMS internally)
- Load original photo files at up to 4000px for face detection instead
  of 240px thumbnails — faces were too small to detect at thumbnail res
- Falls back to thumbnail if original is unavailable

Tested: 33 faces extracted from 13 photos, clustered into 1 person.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:43:29 +02:00
17a69a271e feat: add People view with person cards and click-to-filter
Replace the tag-grouping people section with a dedicated PeopleView:
- Grid of face cluster cards showing representative photo thumbnail,
  person name, and photo count
- Click a card → navigates to all-photos filtered by that person's tag
- Inline rename via pencil icon on hover
- Empty state when no faces have been clustered yet

Wired into App.tsx as a section-level route alongside Map and Duplicates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:32:20 +02:00
7558aeb5e6 fix: sync DB sessions in Celery, letterbox YuNet, dedupe detections
- Rewrite all vision tasks to use sync psycopg2 sessions instead of
  asyncpg — fixes 'another operation in progress' and event loop errors
  when Celery forks workers sharing the async connection pool
- Letterbox-pad images to exactly 640x640 for YuNet face detector
  (was crashing on non-square thumbnails)
- Deduplicate object detections per label per photo — keep highest
  confidence only to avoid photo_tags PK violation on multiple
  detections of the same class
- Add all queues (-Q default,high,low,vision) to worker command

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:28:17 +02:00
2a6661f779 fix: model weights setup — export scripts, ORT compat, bootstrap
- Add export_models.py for OpenCLIP ViT-B/32 and YOLOv8n ONNX export
- Fix ArgMax(13) ORT ARM64 incompatibility by passing eot_indices as a
  separate ONNX input (computed outside the graph in embed.py)
- Use legacy TorchScript exporter (dynamo=False) for IR version 9 compat
- Upgrade onnxruntime to 1.18.1
- Rewrite bootstrap_models.py with clear separation of auto-downloadable
  models (YuNet, SFace) vs manually-exported ones (OpenCLIP, YOLOv8n)
- Wire bootstrap into worker CMD (runs before Celery)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:41:25 +02:00
29177f0c1a feat: wire frontend to vision pipeline search and tags
- Add search API client (POST /photos/search) and useSearchQuery hook
  for hybrid FTS + semantic search with RRF ranking
- Extend Tag type with kind, source, representative_photo_id fields
- Add tags.merge() API method
- Update useTagsQuery to accept optional kind filter
- Add People section to sidebar (face clusters from GET /tags?kind=face_cluster)
- Sidebar Tags count now shows user tags only; People shows face clusters

The existing GET /photos?q= flow is preserved for browsing; the new
search hook activates when the search box has a non-empty query.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:19:47 +02:00
ad007e4cd4 feat: add face detection, recognition, and clustering
- Create face_embeddings table with pgvector Vector(128) + HNSW index
- Implement extract_faces task (YuNet detection + SFace recognition)
- Implement recluster_faces task (DBSCAN clustering → Tag(kind=face_cluster))
- Clusters are named "Person N" and get representative_photo_id
- cluster_id FK → tags.id, SET NULL on delete for merge/rename support

Migration 0005 creates the face_embeddings table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:13:41 +02:00
1ebc4bfe73 feat: add YOLO object detection writing to unified Tag model
Implement detect_objects Celery task:
- Runs YOLOv8n on 640px thumbnail via ONNX Runtime
- Creates Tag(kind=object) rows for each COCO class detected
- Writes photo_tags associations with confidence, bbox, and source
- Wipes previous detections per source model on re-run

No new tables/migrations — uses the unified Tag model from PR3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:11:26 +02:00
842a4fc864 feat: add OCR text extraction and Postgres full-text search
- Create ocr_text table for storing per-region OCR results
- Add tsvector search_vector column to photos with GIN index and
  auto-update trigger on filename/user_title/user_notes
- Implement ocr_photo Celery task using rapidocr-onnxruntime
- Add FTS leg to hybrid search: queries photos.search_vector and
  ocr_text via UNION, fused with semantic results via RRF (k=60)

Migration 0004 backfills search_vector for existing rows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:10:14 +02:00
649437dc85 feat: add embeddings pipeline and semantic search endpoint
Wire the full embedding flow:
- Rewrite Embedding model to use pgvector Vector(512) with HNSW index
- Add embed_photo, vision_fanout, backfill_vision Celery tasks on
  dedicated `vision` queue
- Hook vision_fanout into generate_thumbnails completion
- Add POST /api/v1/photos/search with hybrid RRF ranking (semantic-only
  for now; FTS leg added in PR5)
- Stub ocr_photo, detect_objects, extract_faces tasks for later PRs

Migration 0003 drops/recreates the embeddings table (was never populated).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:07:32 +02:00
b1c2bdf7f0 feat: extend Tag model for unified ML tagging
Unify object detections, scene labels, and face clusters with user tags
via new columns on the existing Tag model:
- kind (user|object|scene|face_cluster), source, representative_photo_id
- photo_tags gains confidence, bbox (JSONB), source per-association
- Uniqueness moves from (name) to (name, kind) so ML labels coexist
  with user tags without collision

Add Alembic migration 0002 with defensive IF NOT EXISTS guards.

Update tags router: kind filter on GET, merge endpoint for combining
auto-detected clusters/objects, include kind/source in list response.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:02:32 +02:00
9282a5c734 feat: add vision pipeline scaffolding with ONNX backend
Introduce the app/services/vision/ module with ABC interfaces, ONNX
Runtime backend, model registry, and per-task implementations:
- OpenCLIP ViT-B/32 embedder (image + text, 512-d)
- RapidOCR engine (PP-OCRv4 via ONNX, no PaddlePaddle)
- YOLOv8n object detector (raw ONNX, no ultralytics runtime)
- YuNet + SFace face processor (Apache 2.0, opencv_zoo, 128-d)
- DBSCAN face clustering helper

Add VisionSettings to config (mulita.yml + Pydantic), bootstrap_models.py
for first-boot weight downloads, models_data Docker volume, and ROCm
backend stub for future GPU acceleration.

No Celery tasks wired yet — models load but nothing invokes them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:00:06 +02:00
dea04ceed9 feat: migrate to Postgres + pgvector with Alembic scaffolding
Switch the default database from SQLite to Postgres + pgvector (via
pgvector/pgvector:pg16 Docker image) to support the upcoming vision
pipeline (embeddings, OCR, object detection, face clustering).

- Add `db` service to docker-compose.yml with healthcheck
- Wire `alembic upgrade head` into backend CMD before uvicorn
- Bootstrap empty 0001_baseline revision (schema still owned by create_all)
- Guard SQLite-only PRAGMAs and inline ALTERs behind _is_sqlite flag
- Run `CREATE EXTENSION IF NOT EXISTS vector` on Postgres init
- Add asyncpg, psycopg2-binary, pgvector to requirements
- Provide docker-compose.sqlite.yml escape hatch for legacy SQLite mode

Fresh DB + rescan assumed — no SQLite→Postgres data migration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:46:20 +02:00
59 changed files with 3784 additions and 274 deletions

2
.gitignore vendored
View File

@@ -65,4 +65,4 @@ docker-compose.override.yml
# Thumbnails # Thumbnails
/thumbs/ /thumbs/
/trash/ /trash/backend/yolov8n.pt

View File

@@ -6,21 +6,27 @@ A self-hosted, Docker-deployed photo management application inspired by Lightroo
- **Photo Organization**: Browse photos in a timeline view with virtual scrolling for performance - **Photo Organization**: Browse photos in a timeline view with virtual scrolling for performance
- **Thumbnail Generation**: Automatic thumbnail generation for all photo formats including RAW - **Thumbnail Generation**: Automatic thumbnail generation for all photo formats including RAW
- **Metadata Extraction**: Full EXIF/XMP metadata extraction and search - **Metadata Extraction**: Full EXIF/XMP metadata extraction and GPS mapping
- **Keyboard Shortcuts**: Lightroom-style keyboard navigation and actions - **Keyboard Shortcuts**: Lightroom-style keyboard navigation and actions
- **File Support**: JPEG, PNG, RAW formats (CR2, CR3, NEF, ARW, etc.), HEIC/HEIF, and videos - **File Support**: JPEG, PNG, RAW formats (CR2, CR3, NEF, ARW, etc.), HEIC/HEIF, and videos
- **Heaps**: Temporary collections for organizing photos - **Heaps**: Temporary collections for organizing photos
- **Tags & Ratings**: Organize with tags, star ratings, and color labels - **Tags & Ratings**: Organize with tags, star ratings, and color labels — each with a card-grid browse view that drills into a full Timeline detail
- **Dark Mode**: Photography-optimized dark interface - **Dark Mode**: Photography-optimized dark interface
- **Vision Pipeline**: YOLO object detection, OCR text extraction, CLIP embeddings for semantic search, InsightFace face detection and clustering
- **People View**: Browse identified people as cards, click to see all photos of a person
- **Map View**: Browse GPS-tagged photos on an interactive Leaflet map
- **Duplicate Detection**: Perceptual hash-based duplicate grouping with best-pick UI
- **Semantic Search**: Natural-language photo search powered by CLIP embeddings
## Tech Stack ## Tech Stack
### Backend ### Backend
- Python 3.12 with FastAPI - Python 3.12 with FastAPI
- SQLite with SQLAlchemy (async) - PostgreSQL + pgvector with SQLAlchemy (async) and Alembic migrations
- Celery + Redis for background tasks - Celery + Redis for background tasks
- pyvips for fast thumbnail generation - pyvips for fast thumbnail generation
- ExifTool for metadata extraction - ExifTool for metadata extraction
- ONNX Runtime for vision models (YOLO, CLIP, InsightFace)
### Frontend ### Frontend
- React 18 with TypeScript - React 18 with TypeScript
@@ -161,9 +167,9 @@ The application consists of 5 Docker services:
- **frontend**: React SPA served by Nginx - **frontend**: React SPA served by Nginx
- **backend**: FastAPI REST API - **backend**: FastAPI REST API
- **worker**: Celery workers for background tasks - **worker**: Celery workers for background tasks (thumbnails, metadata, vision pipeline)
- **redis**: Message broker for Celery - **redis**: Message broker for Celery
- **db**: SQLite database (file-based) - **db**: PostgreSQL with pgvector extension (for CLIP/face embeddings)
## Keyboard Shortcuts ## Keyboard Shortcuts
@@ -172,8 +178,7 @@ The application consists of 5 Docker services:
| `` `` `` `` | Navigate photos | | `` `` `` `` | Navigate photos |
| `Space` | Quick preview | | `Space` | Quick preview |
| `Enter` | Open loupe view | | `Enter` | Open loupe view |
| `P` | Pick photo | | `T` | Add to active heap |
| `X` | Reject photo |
| `1-5` | Set star rating | | `1-5` | Set star rating |
| `Tab` | Toggle left sidebar | | `Tab` | Toggle left sidebar |
| `I` | Toggle metadata panel | | `I` | Toggle metadata panel |
@@ -211,14 +216,12 @@ Source roots are managed by the UI / API (the database owns them). Edit
- Handles 100,000+ photos efficiently - Handles 100,000+ photos efficiently
- Virtual scrolling for smooth timeline navigation - Virtual scrolling for smooth timeline navigation
- Thumbnail generation at 10+ photos/second - Thumbnail generation at 10+ photos/second
- SQLite FTS5 for fast full-text search - PostgreSQL full-text search with tsvector indexing
- pgvector for fast nearest-neighbor embedding search
## Future Features (Phase 2) ## Future Features
- AI-powered scene classification - Smart albums (auto-populated by saved filters)
- Face detection and clustering
- Smart albums
- Duplicate detection
- Export presets - Export presets
- Multi-user support - Multi-user support

View File

@@ -30,7 +30,7 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
# Create necessary directories # Create necessary directories
RUN mkdir -p /data/thumbs /data/db /data/proxies /app/config RUN mkdir -p /data/thumbs /data/db /data/proxies /data/models /app/config
# Expose port # Expose port
EXPOSE 8000 EXPOSE 8000

48
backend/alembic.ini Normal file
View File

@@ -0,0 +1,48 @@
# Alembic configuration for PhotoVault.
#
# The actual database URL is loaded at runtime by alembic/env.py from the
# DATABASE_URL environment variable (with the async driver suffix stripped).
# The placeholder below is only used for `alembic revision --autogenerate`
# when no env var is set.
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql+psycopg2://mulita:mulita@localhost:5432/mulita
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

96
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,96 @@
"""
Alembic environment for PhotoVault.
Pulls DATABASE_URL from the environment so the same migrations work in
docker compose and locally. Strips the async driver suffix because Alembic
runs synchronously via psycopg2.
Future-migration note
---------------------
Fresh installs run `Base.metadata.create_all` in `app.database.init_db`
*before* migrations would normally apply, so any migration that adds a
column / index / table to an object the model already declares will see
that object already present. Write migrations defensively:
op.execute("ALTER TABLE photos ADD COLUMN IF NOT EXISTS new_col TEXT")
op.execute("CREATE INDEX IF NOT EXISTS ix_foo ON foo(bar)")
For brand-new tables that the model also declares, the same applies — use
`op.execute("CREATE TABLE IF NOT EXISTS ...")` or check first.
"""
from logging.config import fileConfig
import os
import sys
from pathlib import Path
from sqlalchemy import engine_from_config, pool
from alembic import context
# Make `app` importable from this script.
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.database import Base # noqa: E402
# Import all models so they're registered on Base.metadata for autogenerate.
from app.models import ( # noqa: E402, F401
Photo,
Folder,
SourceRoot,
Tag,
Heap,
HeapPhoto,
Embedding,
)
config = context.config
# Resolve DATABASE_URL from env. Strip async driver suffixes — Alembic
# uses sync drivers.
db_url = os.environ.get("DATABASE_URL") or config.get_main_option("sqlalchemy.url")
if db_url:
if "+asyncpg" in db_url:
db_url = db_url.replace("+asyncpg", "+psycopg2")
elif db_url.startswith("postgresql://"):
db_url = db_url.replace("postgresql://", "postgresql+psycopg2://", 1)
elif "+aiosqlite" in db_url:
db_url = db_url.replace("+aiosqlite", "")
config.set_main_option("sqlalchemy.url", db_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode (emit SQL only)."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations against a live database."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,27 @@
"""baseline (empty)
Revision ID: 0001_baseline
Revises:
Create Date: 2026-04-10
The current schema is created by SQLAlchemy `Base.metadata.create_all` in
`app.database.init_db()` on first boot. Alembic only owns deltas from
PR3 onward. This baseline is intentionally empty so `alembic upgrade head`
on a fresh DB simply creates the `alembic_version` table and stamps it.
"""
from typing import Sequence, Union
# revision identifiers, used by Alembic.
revision: str = "0001_baseline"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@@ -0,0 +1,85 @@
"""extend tags for vision pipeline
Revision ID: 0002_extend_tags
Revises: 0001_baseline
Create Date: 2026-04-10
Add kind, source, representative_photo_id to tags table.
Add confidence, bbox, source to photo_tags association.
Switch uniqueness from (name) to (name, kind).
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision: str = "0002_extend_tags"
down_revision: Union[str, None] = "0001_baseline"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ── tags table ────────────────────────────────────────────────────
op.execute("ALTER TABLE tags ADD COLUMN IF NOT EXISTS kind VARCHAR NOT NULL DEFAULT 'user'")
op.execute("ALTER TABLE tags ADD COLUMN IF NOT EXISTS source VARCHAR")
op.execute("ALTER TABLE tags ADD COLUMN IF NOT EXISTS representative_photo_id VARCHAR REFERENCES photos(id) ON DELETE SET NULL")
# Create index on kind for filtering
op.execute("CREATE INDEX IF NOT EXISTS ix_tags_kind ON tags(kind)")
# Drop old unique constraint on name (if it exists) and add (name, kind).
# SQLAlchemy create_all may have created either — handle both cases.
op.execute("""
DO $$
BEGIN
-- Drop the old single-column unique index/constraint if present.
IF EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'tags' AND indexname = 'ix_tags_name'
) THEN
DROP INDEX ix_tags_name;
END IF;
-- Some SQLAlchemy versions create a unique constraint directly.
IF EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'tags' AND constraint_name = 'tags_name_key'
) THEN
ALTER TABLE tags DROP CONSTRAINT tags_name_key;
END IF;
END $$;
""")
op.execute("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'uq_tags_name_kind'
) THEN
ALTER TABLE tags ADD CONSTRAINT uq_tags_name_kind UNIQUE (name, kind);
END IF;
END $$;
""")
# ── photo_tags table ──────────────────────────────────────────────
op.execute("ALTER TABLE photo_tags ADD COLUMN IF NOT EXISTS confidence FLOAT")
op.execute("ALTER TABLE photo_tags ADD COLUMN IF NOT EXISTS bbox JSONB")
op.execute("ALTER TABLE photo_tags ADD COLUMN IF NOT EXISTS source VARCHAR")
def downgrade() -> None:
# photo_tags columns
op.drop_column("photo_tags", "source")
op.drop_column("photo_tags", "bbox")
op.drop_column("photo_tags", "confidence")
# tags: restore old unique constraint
op.execute("ALTER TABLE tags DROP CONSTRAINT IF EXISTS uq_tags_name_kind")
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_tags_name ON tags(name)")
# tags columns
op.drop_column("tags", "representative_photo_id")
op.drop_column("tags", "source")
op.drop_column("tags", "kind")

View File

@@ -0,0 +1,52 @@
"""pgvector embeddings
Revision ID: 0003_pgvector_embeddings
Revises: 0002_extend_tags
Create Date: 2026-04-10
Rewrite the embeddings table to use pgvector Vector(512) instead of
LargeBinary. Add composite PK (photo_id, model), created_at, and
HNSW index on vector column.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0003_pgvector_embeddings"
down_revision: Union[str, None] = "0002_extend_tags"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Drop the old placeholder table and recreate with pgvector types.
# No data to preserve — it was never populated.
op.execute("DROP TABLE IF EXISTS embeddings")
op.execute("""
CREATE TABLE embeddings (
photo_id VARCHAR NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
model VARCHAR(64) NOT NULL,
vector vector(512),
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (photo_id, model)
)
""")
# HNSW index for cosine similarity search.
# Defer creation on large backfills — drop and recreate afterward.
op.execute("""
CREATE INDEX IF NOT EXISTS ix_embeddings_vector_hnsw
ON embeddings USING hnsw (vector vector_cosine_ops)
""")
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS embeddings")
op.execute("""
CREATE TABLE embeddings (
photo_id VARCHAR NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
model VARCHAR,
vector BYTEA,
PRIMARY KEY (photo_id)
)
""")

View File

@@ -0,0 +1,82 @@
"""ocr_text table and Postgres FTS
Revision ID: 0004_ocr_fts
Revises: 0003_pgvector_embeddings
Create Date: 2026-04-10
Create ocr_text table for storing OCR results. Add a tsvector column
to photos for unified full-text search (filename + user_title +
user_notes) with a GIN index. OCR text is rolled up into a materialized
view or joined at query time.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0004_ocr_fts"
down_revision: Union[str, None] = "0003_pgvector_embeddings"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ── ocr_text table ────────────────────────────────────────────────
op.execute("""
CREATE TABLE IF NOT EXISTS ocr_text (
id VARCHAR PRIMARY KEY,
photo_id VARCHAR NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
text TEXT NOT NULL,
language VARCHAR(8) DEFAULT '',
confidence FLOAT,
bbox JSONB,
created_at TIMESTAMPTZ DEFAULT now()
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_ocr_text_photo_id ON ocr_text(photo_id)")
# ── tsvector column on photos ─────────────────────────────────────
op.execute("ALTER TABLE photos ADD COLUMN IF NOT EXISTS search_vector tsvector")
op.execute("CREATE INDEX IF NOT EXISTS ix_photos_search_vector ON photos USING GIN (search_vector)")
# Trigger to auto-update search_vector on INSERT/UPDATE
op.execute("""
CREATE OR REPLACE FUNCTION photos_search_vector_update() RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('english', coalesce(NEW.filename, '')), 'A') ||
setweight(to_tsvector('english', coalesce(NEW.user_title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(NEW.user_notes, '')), 'B');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
""")
op.execute("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'photos_search_vector_trigger'
) THEN
CREATE TRIGGER photos_search_vector_trigger
BEFORE INSERT OR UPDATE OF filename, user_title, user_notes
ON photos
FOR EACH ROW
EXECUTE FUNCTION photos_search_vector_update();
END IF;
END $$;
""")
# Backfill existing rows
op.execute("""
UPDATE photos SET search_vector =
setweight(to_tsvector('english', coalesce(filename, '')), 'A') ||
setweight(to_tsvector('english', coalesce(user_title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(user_notes, '')), 'B')
""")
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS photos_search_vector_trigger ON photos")
op.execute("DROP FUNCTION IF EXISTS photos_search_vector_update()")
op.execute("DROP INDEX IF EXISTS ix_photos_search_vector")
op.execute("ALTER TABLE photos DROP COLUMN IF EXISTS search_vector")
op.execute("DROP TABLE IF EXISTS ocr_text")

View File

@@ -0,0 +1,41 @@
"""face_embeddings table
Revision ID: 0005_face_embeddings
Revises: 0004_ocr_fts
Create Date: 2026-04-10
Create face_embeddings table with pgvector Vector(128) for SFace
recognition embeddings and HNSW index.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0005_face_embeddings"
down_revision: Union[str, None] = "0004_ocr_fts"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS face_embeddings (
id VARCHAR PRIMARY KEY,
photo_id VARCHAR NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
bbox JSONB,
vector vector(128),
cluster_id VARCHAR REFERENCES tags(id) ON DELETE SET NULL,
quality FLOAT,
created_at TIMESTAMPTZ DEFAULT now()
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_face_embeddings_photo_id ON face_embeddings(photo_id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_face_embeddings_cluster_id ON face_embeddings(cluster_id)")
op.execute("""
CREATE INDEX IF NOT EXISTS ix_face_embeddings_vector_hnsw
ON face_embeddings USING hnsw (vector vector_cosine_ops)
""")
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS face_embeddings")

View File

@@ -0,0 +1,39 @@
"""face_embeddings vector 128 -> 512
Revision ID: 0006_face_512d
Revises: 0005_face_embeddings
Create Date: 2026-04-10
Resize face_embeddings.vector from Vector(128) to Vector(512) for
ArcFace embeddings (InsightFace). Drops existing data and HNSW index,
recreates both.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0006_face_512d"
down_revision: Union[str, None] = "0005_face_embeddings"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Drop index, truncate (old 128-d vectors are incompatible), resize
op.execute("DROP INDEX IF EXISTS ix_face_embeddings_vector_hnsw")
op.execute("DELETE FROM face_embeddings")
op.execute("ALTER TABLE face_embeddings ALTER COLUMN vector TYPE vector(512)")
op.execute("""
CREATE INDEX IF NOT EXISTS ix_face_embeddings_vector_hnsw
ON face_embeddings USING hnsw (vector vector_cosine_ops)
""")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_face_embeddings_vector_hnsw")
op.execute("DELETE FROM face_embeddings")
op.execute("ALTER TABLE face_embeddings ALTER COLUMN vector TYPE vector(128)")
op.execute("""
CREATE INDEX IF NOT EXISTS ix_face_embeddings_vector_hnsw
ON face_embeddings USING hnsw (vector vector_cosine_ops)
""")

View File

@@ -29,6 +29,48 @@ class PerformanceSettings(BaseModel):
db_pool_size: int = 20 db_pool_size: int = 20
db_pool_recycle: int = 3600 db_pool_recycle: int = 3600
class EmbedderSettings(BaseModel):
"""CLIP / SigLIP embedding model settings"""
name: str = "openclip_vitb32"
batch_size: int = 8
class OCRSettings(BaseModel):
"""PaddleOCR / rapidocr settings"""
enabled: bool = True
languages: list[str] = ["en"]
min_confidence: float = 0.5
class DetectorSettings(BaseModel):
"""YOLOv8n object detection settings"""
enabled: bool = True
min_confidence: float = 0.35
max_detections: int = 50
class FacesSettings(BaseModel):
"""YuNet + SFace face detection/recognition settings"""
enabled: bool = True
min_face_size: int = 40
recognition_threshold: float = 0.65
cluster_eps: float = 0.5
class ClassifierSettings(BaseModel):
"""CLIP zero-shot content classification settings"""
enabled: bool = True
min_confidence: float = 0.3
class VisionSettings(BaseModel):
"""AI vision pipeline settings. Disabled when running on SQLite
(pgvector is required for embedding storage)."""
enabled: bool = True
backend: str = "onnx" # "onnx" | "rocm" (future)
models_dir: str = "/data/models"
embedder: EmbedderSettings = EmbedderSettings()
ocr: OCRSettings = OCRSettings()
detector: DetectorSettings = DetectorSettings()
faces: FacesSettings = FacesSettings()
classifier: ClassifierSettings = ClassifierSettings()
worker_concurrency: int = 2
class MulitaConfig(BaseModel): class MulitaConfig(BaseModel):
"""Main configuration from YAML file. Source roots and the discard """Main configuration from YAML file. Source roots and the discard
workflow are owned by the database now — only operational settings workflow are owned by the database now — only operational settings
@@ -36,12 +78,15 @@ class MulitaConfig(BaseModel):
thumbnails: ThumbnailSettings = ThumbnailSettings() thumbnails: ThumbnailSettings = ThumbnailSettings()
scanner: ScannerSettings = ScannerSettings() scanner: ScannerSettings = ScannerSettings()
performance: PerformanceSettings = PerformanceSettings() performance: PerformanceSettings = PerformanceSettings()
vision: VisionSettings = VisionSettings()
class Settings(BaseSettings): class Settings(BaseSettings):
"""Application settings""" """Application settings"""
# Database # Database — Postgres + pgvector by default. The SQLite escape hatch
# remains supported via the docker-compose.sqlite.yml override and by
# setting DATABASE_URL=sqlite+aiosqlite:///... in .env for local dev.
database_url: str = Field( database_url: str = Field(
default="sqlite+aiosqlite:///data/db/mulita.db", default="postgresql+asyncpg://mulita:mulita@db:5432/mulita",
env="DATABASE_URL" env="DATABASE_URL"
) )
@@ -125,6 +170,10 @@ class Settings(BaseSettings):
def performance(self) -> PerformanceSettings: def performance(self) -> PerformanceSettings:
return self.config.performance return self.config.performance
@property
def vision(self) -> VisionSettings:
return self.config.vision
class Config: class Config:
env_file = ".env" env_file = ".env"
case_sensitive = False case_sensitive = False

View File

@@ -1,38 +1,50 @@
""" """
Database configuration and session management Database configuration and session management.
Schema management strategy
--------------------------
Postgres (default): Alembic owns schema deltas. `alembic upgrade head` is
run before the app starts (in the container CMD). `init_db()` calls
`create_all` afterward as the source of truth for fresh installs — it is
idempotent for existing tables and creates any tables defined on
`Base.metadata` that don't yet exist. Future Alembic migrations should be
written defensively (`IF NOT EXISTS` etc.) so they remain safe to run on a
fresh DB where `create_all` has already laid down the same objects.
SQLite (escape hatch via docker-compose.sqlite.yml): no Alembic. The
historical inline ALTER TABLE block stays in place so existing dev
installs keep upgrading.
""" """
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import declarative_base from sqlalchemy.orm import declarative_base
from sqlalchemy import event, text from sqlalchemy import text
import logging import logging
import os
from pathlib import Path from pathlib import Path
from app.config import settings from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Create database directory if it doesn't exist _is_sqlite = settings.database_url.startswith("sqlite")
db_path = Path(settings.database_url.replace("sqlite+aiosqlite:///", "")) _is_postgres = settings.database_url.startswith("postgresql")
db_path.parent.mkdir(parents=True, exist_ok=True)
# Create async engine if _is_sqlite:
# SQLite doesn't support pool configuration db_path = Path(settings.database_url.replace("sqlite+aiosqlite:///", ""))
if "sqlite" in settings.database_url: db_path.parent.mkdir(parents=True, exist_ok=True)
engine = create_async_engine( engine = create_async_engine(
settings.database_url, settings.database_url,
echo=False, # Set to True for SQL debugging echo=False,
connect_args={ connect_args={
"check_same_thread": False, # SQLite specific "check_same_thread": False,
"timeout": 30 "timeout": 30,
} },
) )
else: else:
engine = create_async_engine( engine = create_async_engine(
settings.database_url, settings.database_url,
echo=False, # Set to True for SQL debugging echo=False,
pool_size=settings.performance.db_pool_size, pool_size=settings.performance.db_pool_size,
pool_recycle=settings.performance.db_pool_recycle pool_recycle=settings.performance.db_pool_recycle,
) )
# Create async session factory # Create async session factory
@@ -59,26 +71,32 @@ async def init_db():
# Import all models to register them with Base # Import all models to register them with Base
from app.models import Photo, Folder, SourceRoot, Tag, PhotoTag, Heap, HeapPhoto, Embedding from app.models import Photo, Folder, SourceRoot, Tag, PhotoTag, Heap, HeapPhoto, Embedding
# Postgres: ensure pgvector is available before create_all touches
# any Vector columns (added in later PRs but the extension is cheap
# and idempotent to create now).
if _is_postgres:
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
# Create all tables. Note: create_all only creates *missing* tables — # Create all tables. Note: create_all only creates *missing* tables —
# it does NOT add new columns to existing tables when the model gains # it does NOT add new columns to existing tables when the model gains
# them. Anything new on an existing table needs an explicit ALTER # them. On Postgres, Alembic handles deltas; on SQLite, the inline
# below. # ALTER block below is the legacy fallback.
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
# Enable WAL mode for SQLite (better concurrency) gps_columns_added = False
if "sqlite" in settings.database_url:
if _is_sqlite:
# WAL mode for better concurrency.
await conn.execute(text("PRAGMA journal_mode=WAL")) await conn.execute(text("PRAGMA journal_mode=WAL"))
await conn.execute(text("PRAGMA synchronous=NORMAL")) await conn.execute(text("PRAGMA synchronous=NORMAL"))
await conn.execute(text("PRAGMA cache_size=10000")) await conn.execute(text("PRAGMA cache_size=10000"))
await conn.execute(text("PRAGMA temp_store=MEMORY")) await conn.execute(text("PRAGMA temp_store=MEMORY"))
# ── Idempotent column adds ──────────────────────────────────────── # ── Idempotent column adds (SQLite only) ─────────────────────
# The project does not use Alembic; we lean on create_all + a small # SQLite supports ADD COLUMN but not "IF NOT EXISTS" for
# set of inline ALTER TABLE statements for the columns we've added # columns, so introspect via PRAGMA first. Each entry is
# post-launch. SQLite supports ADD COLUMN but not "IF NOT EXISTS" # (column_name, ALTER statement). Add new columns at the
# for columns, so introspect via PRAGMA first. Each entry is # bottom. On Postgres these live in Alembic migrations.
# (column_name, ALTER statement). Add new columns at the bottom.
if "sqlite" in settings.database_url:
existing_cols = { existing_cols = {
row[1] row[1]
for row in ( for row in (
@@ -94,18 +112,12 @@ async def init_db():
("latitude", "ALTER TABLE photos ADD COLUMN latitude REAL"), ("latitude", "ALTER TABLE photos ADD COLUMN latitude REAL"),
("longitude", "ALTER TABLE photos ADD COLUMN longitude REAL"), ("longitude", "ALTER TABLE photos ADD COLUMN longitude REAL"),
] ]
# Track whether the GPS columns were just added so we can kick
# off a one-shot backfill of existing photos at the end of init.
gps_columns_added = False
for col_name, alter_sql in pending_alters: for col_name, alter_sql in pending_alters:
if col_name not in existing_cols: if col_name not in existing_cols:
logger.info(f"Adding photos.{col_name} column") logger.info(f"Adding photos.{col_name} column")
await conn.execute(text(alter_sql)) await conn.execute(text(alter_sql))
if col_name in ("latitude", "longitude"): if col_name in ("latitude", "longitude"):
gps_columns_added = True gps_columns_added = True
# Indexes for the new duplicate-detection columns. CREATE INDEX
# IF NOT EXISTS is supported on SQLite so this is safe to run
# every startup.
await conn.execute( await conn.execute(
text("CREATE INDEX IF NOT EXISTS ix_photos_phash ON photos(phash)") text("CREATE INDEX IF NOT EXISTS ix_photos_phash ON photos(phash)")
) )
@@ -124,11 +136,12 @@ async def init_db():
logger.info("Database initialized successfully") logger.info("Database initialized successfully")
# If we just introduced the GPS columns on an existing install, kick # If we just introduced the GPS columns on an existing SQLite
# off a one-shot backfill so the Map view is populated without a # install, kick off a one-shot backfill so the Map view is
# manual full re-scan. Imported lazily to avoid pulling Celery into # populated without a manual full re-scan. Postgres installs are
# the import graph for non-worker processes that don't need it. # always fresh (no SQLite→PG migration path), so this code path
if "sqlite" in settings.database_url and gps_columns_added: # is SQLite-only.
if _is_sqlite and gps_columns_added:
try: try:
from app.tasks.scan import backfill_gps from app.tasks.scan import backfill_gps
backfill_gps.delay() backfill_gps.delay()
@@ -137,8 +150,9 @@ async def init_db():
logger.warning(f"Could not queue backfill_gps task: {e}") logger.warning(f"Could not queue backfill_gps task: {e}")
async def create_fts_table(): async def create_fts_table():
"""Create Full-Text Search table for SQLite""" """Create Full-Text Search table for SQLite. On Postgres this is
if "sqlite" in settings.database_url: replaced by a tsvector column on the photos table (added in PR5)."""
if _is_sqlite:
async with engine.begin() as conn: async with engine.begin() as conn:
# Create FTS5 virtual table for full-text search # Create FTS5 virtual table for full-text search
await conn.execute(text(""" await conn.execute(text("""
@@ -151,4 +165,4 @@ async def create_fts_table():
tokenize='unicode61' tokenize='unicode61'
) )
""")) """))
logger.info("FTS5 table created successfully") logger.info("FTS5 table created successfully")

View File

@@ -11,7 +11,7 @@ import os
from app.config import settings from app.config import settings
from app.database import init_db from app.database import init_db
from app.routers import photos, folders, heaps, tags, discard, library from app.routers import photos, folders, heaps, tags, discard, library, search
from app.services.scanner import start_initial_scan, bootstrap_default_source_root from app.services.scanner import start_initial_scan, bootstrap_default_source_root
from app.services.cleanup import cleanup_data_integrity from app.services.cleanup import cleanup_data_integrity
@@ -91,6 +91,7 @@ app.include_router(heaps.router, prefix="/api/v1/heaps", tags=["heaps"])
app.include_router(tags.router, prefix="/api/v1/tags", tags=["tags"]) app.include_router(tags.router, prefix="/api/v1/tags", tags=["tags"])
app.include_router(discard.router, prefix="/api/v1/discard", tags=["discard"]) app.include_router(discard.router, prefix="/api/v1/discard", tags=["discard"])
app.include_router(library.router, prefix="/api/v1/library", tags=["library"]) app.include_router(library.router, prefix="/api/v1/library", tags=["library"])
app.include_router(search.router, prefix="/api/v1/photos/search", tags=["search"])
@app.get("/") @app.get("/")
async def root(): async def root():

View File

@@ -6,6 +6,8 @@ from app.models.folders import Folder, SourceRoot
from app.models.tags import Tag, PhotoTag from app.models.tags import Tag, PhotoTag
from app.models.heaps import Heap, HeapPhoto from app.models.heaps import Heap, HeapPhoto
from app.models.embeddings import Embedding from app.models.embeddings import Embedding
from app.models.ocr_text import OCRText
from app.models.face_embedding import FaceEmbedding
__all__ = [ __all__ = [
'Photo', 'Photo',
@@ -15,5 +17,7 @@ __all__ = [
'PhotoTag', 'PhotoTag',
'Heap', 'Heap',
'HeapPhoto', 'HeapPhoto',
'Embedding' 'Embedding',
'OCRText',
'FaceEmbedding',
] ]

View File

@@ -1,17 +1,19 @@
""" """
Embedding model definition (placeholder for AI features) Embedding model — stores CLIP/SigLIP image embeddings via pgvector.
Composite PK (photo_id, model) allows re-embedding with newer models
without clobbering old vectors.
""" """
from sqlalchemy import Column, String, ForeignKey, LargeBinary from sqlalchemy import Column, String, ForeignKey, DateTime, func
import uuid from pgvector.sqlalchemy import Vector
from app.database import Base from app.database import Base
class Embedding(Base): class Embedding(Base):
"""
Placeholder table for future AI embeddings (CLIP, face recognition, etc.)
"""
__tablename__ = 'embeddings' __tablename__ = 'embeddings'
photo_id = Column(String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True) photo_id = Column(String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True)
model = Column(String) # e.g., 'clip-vit-b32', 'face-recognition', etc. model = Column(String(64), primary_key=True) # e.g. 'openclip_vitb32'
vector = Column(LargeBinary) # raw float32 bytes for embedding vector vector = Column(Vector(512)) # OpenCLIP ViT-B/32 → 512-d
created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -0,0 +1,24 @@
"""
Face embedding model — stores per-face detection + recognition vectors.
cluster_id FKs to tags.id where kind='face_cluster'. Null means
unclustered (will be assigned by recluster_faces).
"""
from sqlalchemy import Column, String, Float, ForeignKey, DateTime, func
from sqlalchemy.dialects.postgresql import JSONB
from pgvector.sqlalchemy import Vector
import uuid
from app.database import Base
class FaceEmbedding(Base):
__tablename__ = 'face_embeddings'
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
photo_id = Column(String, ForeignKey('photos.id', ondelete='CASCADE'), nullable=False, index=True)
bbox = Column(JSONB) # [x1, y1, x2, y2] normalized 0-1
vector = Column(Vector(512)) # ArcFace → 512-d
cluster_id = Column(String, ForeignKey('tags.id', ondelete='SET NULL'), nullable=True, index=True)
quality = Column(Float)
created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -0,0 +1,20 @@
"""
OCR text model — stores text regions extracted from photos via rapidocr.
"""
from sqlalchemy import Column, String, Float, ForeignKey, Text, DateTime, func
from sqlalchemy.dialects.postgresql import JSONB
import uuid
from app.database import Base
class OCRText(Base):
__tablename__ = 'ocr_text'
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
photo_id = Column(String, ForeignKey('photos.id', ondelete='CASCADE'), nullable=False, index=True)
text = Column(Text, nullable=False)
language = Column(String(8), default='')
confidence = Column(Float)
bbox = Column(JSONB) # [x1, y1, x2, y2] normalized 0-1
created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -1,8 +1,14 @@
""" """
Tag model definitions Tag model definitions.
Tags are unified across user-created tags, ML-detected objects, scene
labels, and face clusters via the `kind` column. The `photo_tags`
association carries per-photo ML metadata (confidence, bounding box,
source model).
""" """
from sqlalchemy import Column, String, ForeignKey, Table, Index from sqlalchemy import Column, String, Float, ForeignKey, Table, Index, UniqueConstraint
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import JSONB
import uuid import uuid
from app.database import Base from app.database import Base
@@ -13,20 +19,41 @@ photo_tags = Table(
Base.metadata, Base.metadata,
Column('photo_id', String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True), Column('photo_id', String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True),
Column('tag_id', String, ForeignKey('tags.id', ondelete='CASCADE'), primary_key=True), Column('tag_id', String, ForeignKey('tags.id', ondelete='CASCADE'), primary_key=True),
# ML metadata — null for user-applied tags
Column('confidence', Float, nullable=True),
Column('bbox', JSONB, nullable=True), # [x1, y1, x2, y2] normalized 0-1
Column('source', String, nullable=True), # e.g. "vision:yolov8n", "vision:sface"
Index('ix_photo_tags_photo_id', 'photo_id'), Index('ix_photo_tags_photo_id', 'photo_id'),
Index('ix_photo_tags_tag_id', 'tag_id'), Index('ix_photo_tags_tag_id', 'tag_id'),
) )
class Tag(Base): class Tag(Base):
__tablename__ = 'tags' __tablename__ = 'tags'
__table_args__ = (
UniqueConstraint('name', 'kind', name='uq_tags_name_kind'),
)
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String, unique=True, nullable=False, index=True) name = Column(String, nullable=False, index=True)
color = Column(String) # Hex color code for UI display color = Column(String) # Hex color code for UI display
# Tag classification
kind = Column(String, nullable=False, default='user', index=True)
# kind values: 'user' | 'object' | 'scene' | 'face_cluster'
# Which model produced this tag (null for user-created)
source = Column(String, nullable=True)
# e.g. "vision:yolov8n", "vision:sface", null
# For face clusters: the photo used as the cluster representative thumbnail
representative_photo_id = Column(
String, ForeignKey('photos.id', ondelete='SET NULL'), nullable=True
)
# Relationships # Relationships
photos = relationship("Photo", secondary=photo_tags, backref="tags") photos = relationship("Photo", secondary=photo_tags, backref="tags")
class PhotoTag: class PhotoTag:
"""Helper class for photo-tag associations (not a table model)""" """Helper class for photo-tag associations (not a table model)"""
pass pass

View File

@@ -0,0 +1,74 @@
"""
Search API router — unified hybrid search endpoint.
"""
from typing import Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models import Photo
from app.services.search import hybrid_search
router = APIRouter()
class SearchRequest(BaseModel):
q: Optional[str] = None
filters: Optional[dict] = None
limit: int = 50
offset: int = 0
@router.post("")
async def search_photos(body: SearchRequest, db: AsyncSession = Depends(get_db)):
"""Unified search endpoint. Every query runs hybrid (FTS + semantic)
by default — the user never picks a mode.
Filters:
- tag_ids: list of tag IDs (any kind: user, object, face_cluster)
- date_from / date_to: ISO date strings
"""
filters = body.filters or {}
results = await hybrid_search(
db=db,
q=body.q,
tag_ids=filters.get("tag_ids"),
date_from=filters.get("date_from"),
date_to=filters.get("date_to"),
limit=body.limit,
offset=body.offset,
)
if not results:
return {"results": [], "total": 0}
# Hydrate with photo data
photo_ids = [r["photo_id"] for r in results]
stmt = select(Photo).where(Photo.id.in_(photo_ids))
rows = (await db.execute(stmt)).scalars().all()
photo_map = {p.id: p for p in rows}
hydrated = []
for r in results:
photo = photo_map.get(r["photo_id"])
if not photo:
continue
hydrated.append({
"id": photo.id,
"filename": photo.filename,
"filepath": photo.filepath,
"media_type": photo.media_type,
"width": photo.width,
"height": photo.height,
"taken_at": photo.taken_at.isoformat() if photo.taken_at else None,
"rating": photo.rating,
"color_label": photo.color_label,
"thumb_small": photo.thumb_small,
"thumb_medium": photo.thumb_medium,
"score": r["score"],
})
return {"results": hydrated, "total": len(hydrated)}

View File

@@ -1,10 +1,15 @@
""" """
Tags API router Tags API router.
Unified across user tags, ML-detected objects, and face clusters via
the `kind` query parameter. Default behaviour (no kind filter) returns
all tags — the frontend's "Hide auto-generated tags" toggle filters
client-side or passes `kind=user`.
""" """
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select, func, insert, delete from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
@@ -19,6 +24,7 @@ router = APIRouter()
class TagCreate(BaseModel): class TagCreate(BaseModel):
name: str name: str
color: Optional[str] = None color: Optional[str] = None
kind: str = "user"
class TagUpdate(BaseModel): class TagUpdate(BaseModel):
@@ -26,24 +32,35 @@ class TagUpdate(BaseModel):
color: Optional[str] = None color: Optional[str] = None
class TagMerge(BaseModel):
target_id: str # tag to merge INTO
# ── Endpoints ───────────────────────────────────────────────────────────── # ── Endpoints ─────────────────────────────────────────────────────────────
@router.get("") @router.get("")
async def list_tags(db: AsyncSession = Depends(get_db)): async def list_tags(
"""List all tags with their photo counts.""" kind: Optional[str] = Query(None, description="Filter by kind: user, object, scene, face_cluster"),
db: AsyncSession = Depends(get_db),
):
"""List all tags with their photo counts, optionally filtered by kind."""
count_subq = ( count_subq = (
select( select(
photo_tags.c.tag_id, photo_tags.c.tag_id,
func.count(photo_tags.c.photo_id).label("photo_count"), func.count(photo_tags.c.photo_id).label("photo_count"),
func.min(photo_tags.c.photo_id).label("first_photo_id"),
) )
.group_by(photo_tags.c.tag_id) .group_by(photo_tags.c.tag_id)
.subquery() .subquery()
) )
stmt = ( stmt = (
select(Tag, count_subq.c.photo_count) select(Tag, count_subq.c.photo_count, count_subq.c.first_photo_id)
.outerjoin(count_subq, Tag.id == count_subq.c.tag_id) .outerjoin(count_subq, Tag.id == count_subq.c.tag_id)
.order_by(Tag.name.asc())
) )
if kind:
stmt = stmt.where(Tag.kind == kind)
stmt = stmt.order_by(Tag.name.asc())
result = await db.execute(stmt) result = await db.execute(stmt)
rows = result.all() rows = result.all()
@@ -52,38 +69,48 @@ async def list_tags(db: AsyncSession = Depends(get_db)):
"id": tag.id, "id": tag.id,
"name": tag.name, "name": tag.name,
"color": tag.color, "color": tag.color,
"kind": tag.kind,
"source": tag.source,
"representative_photo_id": tag.representative_photo_id or first_photo_id,
"photo_count": int(count or 0), "photo_count": int(count or 0),
} }
for tag, count in rows for tag, count, first_photo_id in rows
] ]
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)): async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
"""Create a new tag. Names are unique — re-creating an existing name """Create a new tag. The (name, kind) pair is unique — re-creating an
returns the existing row instead of erroring (idempotent for the existing pair returns the existing row (idempotent for autocomplete)."""
autocomplete UI flow)."""
name = (body.name or "").strip() name = (body.name or "").strip()
if not name: if not name:
raise HTTPException(status_code=400, detail="Tag name is required") raise HTTPException(status_code=400, detail="Tag name is required")
existing = await db.execute(select(Tag).where(Tag.name == name)) existing = await db.execute(
select(Tag).where(Tag.name == name, Tag.kind == body.kind)
)
found = existing.scalar_one_or_none() found = existing.scalar_one_or_none()
if found: if found:
return {"id": found.id, "name": found.name, "color": found.color, "photo_count": 0} return {
"id": found.id, "name": found.name, "color": found.color,
"kind": found.kind, "photo_count": 0,
}
tag = Tag(name=name, color=body.color) tag = Tag(name=name, color=body.color, kind=body.kind)
db.add(tag) db.add(tag)
await db.commit() await db.commit()
await db.refresh(tag) await db.refresh(tag)
return {"id": tag.id, "name": tag.name, "color": tag.color, "photo_count": 0} return {
"id": tag.id, "name": tag.name, "color": tag.color,
"kind": tag.kind, "photo_count": 0,
}
@router.patch("/{tag_id}") @router.patch("/{tag_id}")
async def update_tag( async def update_tag(
tag_id: str, body: TagUpdate, db: AsyncSession = Depends(get_db) tag_id: str, body: TagUpdate, db: AsyncSession = Depends(get_db)
): ):
"""Rename or recolor a tag.""" """Rename or recolor a tag (works for any kind — user, object, face_cluster)."""
result = await db.execute(select(Tag).where(Tag.id == tag_id)) result = await db.execute(select(Tag).where(Tag.id == tag_id))
tag = result.scalar_one_or_none() tag = result.scalar_one_or_none()
if not tag: if not tag:
@@ -99,7 +126,52 @@ async def update_tag(
await db.commit() await db.commit()
await db.refresh(tag) await db.refresh(tag)
return {"id": tag.id, "name": tag.name, "color": tag.color} return {"id": tag.id, "name": tag.name, "color": tag.color, "kind": tag.kind}
@router.post("/{tag_id}/merge")
async def merge_tag(
tag_id: str, body: TagMerge, db: AsyncSession = Depends(get_db)
):
"""Merge tag_id INTO target_id. All photo associations from the source
tag are moved to the target, then the source tag is deleted.
Useful for merging auto-detected face clusters (e.g. "Person 3""Alice")
or merging duplicate object labels."""
if tag_id == body.target_id:
raise HTTPException(status_code=400, detail="Cannot merge a tag into itself")
source = (await db.execute(select(Tag).where(Tag.id == tag_id))).scalar_one_or_none()
target = (await db.execute(select(Tag).where(Tag.id == body.target_id))).scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Source tag not found")
if not target:
raise HTTPException(status_code=404, detail="Target tag not found")
# Move photo associations: update tag_id from source → target.
# Skip any that would violate the PK (photo already tagged with target).
existing_target_photos = select(photo_tags.c.photo_id).where(
photo_tags.c.tag_id == body.target_id
)
await db.execute(
update(photo_tags)
.where(
photo_tags.c.tag_id == tag_id,
photo_tags.c.photo_id.notin_(existing_target_photos),
)
.values(tag_id=body.target_id)
)
# Delete remaining source associations (duplicates that couldn't move)
from sqlalchemy import delete as sa_delete
await db.execute(
sa_delete(photo_tags).where(photo_tags.c.tag_id == tag_id)
)
# Delete source tag
await db.delete(source)
await db.commit()
return {"merged_into": target.id, "target_name": target.name}
@router.delete("/{tag_id}", status_code=204) @router.delete("/{tag_id}", status_code=204)

View File

@@ -0,0 +1,140 @@
"""
Unified search service — hybrid FTS + semantic (RRF) search.
Phase 1 (PR4): semantic-only via pgvector cosine similarity.
Phase 2 (PR5): adds FTS via tsvector, enables RRF fusion.
"""
import logging
from typing import Optional
import numpy as np
from sqlalchemy import select, text, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Photo
from app.models.embeddings import Embedding
from app.config import settings
logger = logging.getLogger(__name__)
async def hybrid_search(
db: AsyncSession,
q: Optional[str] = None,
tag_ids: Optional[list[str]] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
limit: int = 50,
offset: int = 0,
) -> list[dict]:
"""Run hybrid search (FTS + semantic) with RRF fusion.
Currently semantic-only; FTS leg added in PR5.
"""
model_name = settings.vision.embedder.name
results = {}
# ── Semantic search (CLIP text → pgvector cosine) ─────────────────
if q:
try:
from app.services.vision.registry import registry
embedder = registry.get_embedder()
query_vec = embedder.embed_text(q)
# pgvector cosine distance: <=> returns distance (lower = closer)
vec_str = "[" + ",".join(str(float(v)) for v in query_vec) + "]"
stmt = text("""
SELECT e.photo_id,
(e.vector <=> :qvec::vector) AS distance
FROM embeddings e
WHERE e.model = :model
ORDER BY e.vector <=> :qvec::vector
LIMIT 200
""")
rows = (await db.execute(stmt, {"qvec": vec_str, "model": model_name})).fetchall()
for rank, (photo_id, distance) in enumerate(rows):
if photo_id not in results:
results[photo_id] = {"semantic_rank": rank, "fts_rank": None}
else:
results[photo_id]["semantic_rank"] = rank
except Exception as e:
logger.warning("Semantic search failed (models may not be loaded): %s", e)
# ── FTS search (photos.search_vector + ocr_text) ────────────────
if q:
try:
fts_stmt = text("""
SELECT id, ts_rank(search_vector, plainto_tsquery('english', :q)) AS rank
FROM photos
WHERE search_vector @@ plainto_tsquery('english', :q)
UNION
SELECT o.photo_id AS id,
MAX(o.confidence) AS rank
FROM ocr_text o
WHERE to_tsvector('english', o.text) @@ plainto_tsquery('english', :q)
GROUP BY o.photo_id
ORDER BY rank DESC
LIMIT 200
""")
fts_rows = (await db.execute(fts_stmt, {"q": q})).fetchall()
for rank, (photo_id, score) in enumerate(fts_rows):
if photo_id not in results:
results[photo_id] = {"semantic_rank": None, "fts_rank": rank}
else:
results[photo_id]["fts_rank"] = rank
except Exception as e:
logger.warning("FTS search failed: %s", e)
# ── RRF fusion ────────────────────────────────────────────────────
k = 60
scored = []
for photo_id, ranks in results.items():
score = 0.0
if ranks["semantic_rank"] is not None:
score += 1.0 / (k + ranks["semantic_rank"])
if ranks.get("fts_rank") is not None:
score += 1.0 / (k + ranks["fts_rank"])
scored.append((photo_id, score))
scored.sort(key=lambda x: -x[1])
# If no text query, fall back to recent photos
if not q:
if tag_ids:
from app.models.tags import photo_tags
# Subquery to get distinct photo_ids matching the tag filter
sub = select(photo_tags.c.photo_id).where(
photo_tags.c.tag_id.in_(tag_ids)
).distinct().subquery()
stmt = select(Photo.id).join(sub, Photo.id == sub.c.photo_id)
else:
stmt = select(Photo.id)
stmt = stmt.order_by(Photo.added_at.desc())
if date_from:
stmt = stmt.where(Photo.taken_at >= date_from)
if date_to:
stmt = stmt.where(Photo.taken_at <= date_to)
stmt = stmt.offset(offset).limit(limit)
rows = (await db.execute(stmt)).fetchall()
return [{"photo_id": row[0], "score": 0.0} for row in rows]
# Apply filters to scored results
photo_ids = [pid for pid, _ in scored]
if not photo_ids:
return []
# Filter by tags if requested
if tag_ids:
from app.models.tags import photo_tags
stmt = select(photo_tags.c.photo_id).where(
photo_tags.c.photo_id.in_(photo_ids),
photo_tags.c.tag_id.in_(tag_ids),
).distinct()
valid_ids = {row[0] for row in (await db.execute(stmt)).fetchall()}
scored = [(pid, s) for pid, s in scored if pid in valid_ids]
# Paginate
page = scored[offset : offset + limit]
return [{"photo_id": pid, "score": score} for pid, score in page]

View File

@@ -0,0 +1,7 @@
"""
Vision pipeline services — embedding, OCR, object detection, face recognition.
All inference is done through the ModelRegistry singleton, which lazy-loads
ONNX Runtime sessions on first use and caches them for the lifetime of the
worker process.
"""

View File

@@ -0,0 +1,105 @@
"""
Abstract base classes for vision backends.
Each ABC defines the contract a backend must satisfy. The default
implementation is ONNXBackend (onnx_backend.py). A ROCm backend can be
added later by subclassing these ABCs and registering via
settings.vision.backend.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
import numpy as np
@dataclass
class DetectionBox:
"""A single object detection result."""
label: str
confidence: float
bbox: list[float] # [x1, y1, x2, y2] normalized 0-1
@dataclass
class OCRResult:
"""A single OCR text region."""
text: str
confidence: float
bbox: list[float] # [x1, y1, x2, y2] normalized 0-1
language: str = ""
@dataclass
class FaceDetection:
"""A detected face with its recognition embedding."""
bbox: list[float] # [x1, y1, x2, y2] normalized 0-1
embedding: np.ndarray # float32 vector (128-d for SFace)
quality: float
@dataclass
class ClassificationResult:
"""A content-type classification."""
label: str
confidence: float
class Embedder(ABC):
"""Generates image and text embeddings (e.g. OpenCLIP ViT-B/32)."""
@abstractmethod
def embed_image(self, image: np.ndarray) -> np.ndarray:
"""Return a normalized float32 embedding vector for an RGB image."""
...
@abstractmethod
def embed_text(self, text: str) -> np.ndarray:
"""Return a normalized float32 embedding vector for a text query."""
...
@property
@abstractmethod
def dim(self) -> int:
"""Dimensionality of the output embedding."""
...
class OCREngine(ABC):
"""Extracts text from images (e.g. rapidocr-onnxruntime)."""
@abstractmethod
def run(self, image: np.ndarray) -> list[OCRResult]:
"""Return OCR results for an RGB image."""
...
class ObjectDetector(ABC):
"""Detects objects in images (e.g. YOLOv8n)."""
@abstractmethod
def detect(self, image: np.ndarray) -> list[DetectionBox]:
"""Return detections for an RGB image."""
...
class ContentClassifier(ABC):
"""Classifies images into content types (screenshot, document, etc.)."""
@abstractmethod
def classify(self, image: np.ndarray) -> list[ClassificationResult]:
"""Return content type classifications for an RGB image."""
...
class FaceProcessor(ABC):
"""Detects faces and extracts recognition embeddings (e.g. YuNet + SFace)."""
@abstractmethod
def process(self, image: np.ndarray) -> list[FaceDetection]:
"""Return face detections with embeddings for an RGB image."""
...
@property
@abstractmethod
def embedding_dim(self) -> int:
"""Dimensionality of face embedding vectors."""
...

View File

@@ -0,0 +1,85 @@
"""
Download vision model weights on first worker boot.
Run as: python -m app.services.vision.bootstrap_models
Or called from the vision worker entrypoint before Celery starts.
Downloads are idempotent — existing files are skipped.
For models that require export (OpenCLIP, YOLOv8n), see export_models.py.
Those must be exported once on any machine with pip, then placed in
the models volume before the worker starts.
"""
import logging
import os
from pathlib import Path
from urllib.request import urlretrieve
from app.config import settings
logger = logging.getLogger(__name__)
# (relative_path, url, description)
# Models with url=None must be pre-exported via export_models.py.
# InsightFace (RetinaFace + ArcFace) auto-downloads via the insightface
# package on first use — no manual download entries needed.
DOWNLOADS = []
# Models that need manual export via export_models.py
EXPORTS = [
("embed/visual.onnx", "OpenCLIP ViT-B/32 visual encoder"),
("embed/textual.onnx", "OpenCLIP ViT-B/32 textual encoder"),
("detect/yolov8n.onnx", "YOLOv8n object detector"),
]
def bootstrap(models_dir: str | None = None):
"""Ensure all model files are present. Download what we can, warn about
files that need manual export."""
base = Path(models_dir or settings.vision.models_dir)
base.mkdir(parents=True, exist_ok=True)
# Download auto-downloadable models
for rel_path, url, desc in DOWNLOADS:
dest = base / rel_path
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
logger.debug("Already exists: %s (%s)", dest, desc)
continue
logger.info("Downloading %s%s", desc, dest)
try:
urlretrieve(url, str(dest))
size_kb = dest.stat().st_size / 1024
logger.info("Downloaded %s (%.0f KB)", desc, size_kb)
except Exception as e:
logger.error("Failed to download %s: %s", desc, e)
if dest.exists():
dest.unlink()
# Check for manually-exported models
missing = []
for rel_path, desc in EXPORTS:
dest = base / rel_path
if not dest.exists():
missing.append((rel_path, desc))
if missing:
logger.warning(
"Missing %d model(s) that require manual export via export_models.py:",
len(missing),
)
for rel_path, desc in missing:
logger.warning(" %s%s", base / rel_path, desc)
logger.warning(
"Run: python -m app.services.vision.export_models --models-dir %s",
base,
)
else:
logger.info("All model files present in %s", base)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
bootstrap()

View File

@@ -0,0 +1,106 @@
"""
CLIP zero-shot content-type classifier.
Uses the native OpenCLIP PyTorch text encoder for high-quality text
embeddings (the ONNX text encoder has degraded quality due to the
eot_indices workaround). Image embeddings use the ONNX visual encoder
which works well.
"""
import logging
import numpy as np
import torch
from app.config import VisionSettings
from app.services.vision.base import ContentClassifier, ClassificationResult
logger = logging.getLogger(__name__)
CATEGORY_PROMPTS = {
"screenshot": [
"a screenshot of a computer screen",
"a screenshot of a phone screen",
"a screen capture of a user interface",
],
"document": [
"a scanned document",
"a photo of a document with printed text",
"a photo of a page of text on paper",
],
"receipt": [
"a photo of a receipt",
"a photo of a bill or invoice",
],
"meme": [
"an internet meme with text overlay",
"a funny image with caption text",
],
"artwork": [
"a painting or drawing",
"a sketch or illustration",
"digital art or graphic design",
],
"photograph": [
"a photograph taken with a camera",
"a real photo of a real scene or person",
"a candid photograph",
],
}
class CLIPContentClassifier(ContentClassifier):
"""Zero-shot content classifier using CLIP text-image similarity.
Uses native PyTorch for text encoding, ONNX for image encoding."""
def __init__(self, settings: VisionSettings):
import open_clip
self._min_confidence = settings.classifier.min_confidence
# Load native model for text encoding only
logger.info("Loading OpenCLIP text encoder for content classification")
model, _, _ = open_clip.create_model_and_transforms(
"ViT-B-32", pretrained="laion2b_s34b_b79k"
)
model.eval()
self._model = model
self._tokenizer = open_clip.get_tokenizer("ViT-B-32")
# Get the ONNX image embedder from the registry
from app.services.vision.registry import registry
self._embedder = registry.get_embedder()
# Pre-compute text embeddings for each category
self._category_embeddings: dict[str, np.ndarray] = {}
for category, prompts in CATEGORY_PROMPTS.items():
tokens = self._tokenizer(prompts)
with torch.no_grad():
text_features = model.encode_text(tokens)
text_features /= text_features.norm(dim=-1, keepdim=True)
avg = text_features.mean(dim=0)
avg /= avg.norm()
self._category_embeddings[category] = avg.numpy().astype(np.float32)
logger.info("Content classifier ready with %d categories", len(self._category_embeddings))
def classify(self, image: np.ndarray) -> list[ClassificationResult]:
img_vec = self._embedder.embed_image(image)
# Cosine similarity against each category
scores = {}
for category, cat_vec in self._category_embeddings.items():
scores[category] = float(np.dot(img_vec, cat_vec))
# Sort by score descending
ranked = sorted(scores.items(), key=lambda x: -x[1])
best_cat, best_score = ranked[0]
second_score = ranked[1][1]
margin = best_score - second_score
# Normalize: 0.01 margin → ~0.5 confidence, 0.03+ → ~1.0
confidence = min(1.0, margin * 30)
if confidence >= self._min_confidence:
return [ClassificationResult(label=best_cat, confidence=confidence)]
return []

View File

@@ -0,0 +1,42 @@
"""
Face embedding clustering using DBSCAN with cosine distance.
Called by the periodic `recluster_faces` Celery task (PR7).
"""
import logging
import numpy as np
from sklearn.cluster import DBSCAN
logger = logging.getLogger(__name__)
def cluster_faces(
embeddings: np.ndarray,
eps: float = 0.35,
min_samples: int = 2,
) -> np.ndarray:
"""Cluster face embeddings using DBSCAN with cosine metric.
Args:
embeddings: (N, D) float32 array of L2-normalized face embeddings.
eps: Maximum cosine distance between two samples to be in the
same neighborhood. Lower = tighter clusters.
min_samples: Minimum cluster size.
Returns:
(N,) int array of cluster labels. -1 = noise / unclustered.
"""
if len(embeddings) < min_samples:
return np.full(len(embeddings), -1, dtype=int)
db = DBSCAN(eps=eps, min_samples=min_samples, metric="cosine")
labels = db.fit_predict(embeddings)
n_clusters = len(set(labels) - {-1})
n_noise = (labels == -1).sum()
logger.info(
"Face clustering: %d embeddings → %d clusters, %d noise",
len(embeddings), n_clusters, n_noise,
)
return labels

View File

@@ -0,0 +1,140 @@
"""
YOLOv8n object detector using raw ONNX Runtime.
Expects {models_dir}/detect/yolov8n.onnx, exported from ultralytics
via bootstrap_models.py. We do NOT ship ultralytics at runtime to
avoid dragging in torch.
"""
import logging
from pathlib import Path
import numpy as np
import onnxruntime as ort
from app.config import VisionSettings
from app.services.vision.base import ObjectDetector, DetectionBox
logger = logging.getLogger(__name__)
_INPUT_SIZE = 640
# COCO class names (80 classes)
COCO_LABELS = [
"person", "bicycle", "car", "motorcycle", "airplane", "bus", "train",
"truck", "boat", "traffic light", "fire hydrant", "stop sign",
"parking meter", "bench", "bird", "cat", "dog", "horse", "sheep",
"cow", "elephant", "bear", "zebra", "giraffe", "backpack", "umbrella",
"handbag", "tie", "suitcase", "frisbee", "skis", "snowboard",
"sports ball", "kite", "baseball bat", "baseball glove", "skateboard",
"surfboard", "tennis racket", "bottle", "wine glass", "cup", "fork",
"knife", "spoon", "bowl", "banana", "apple", "sandwich", "orange",
"broccoli", "carrot", "hot dog", "pizza", "donut", "cake", "chair",
"couch", "potted plant", "bed", "dining table", "toilet", "tv",
"laptop", "mouse", "remote", "keyboard", "cell phone", "microwave",
"oven", "toaster", "sink", "refrigerator", "book", "clock", "vase",
"scissors", "teddy bear", "hair drier", "toothbrush",
]
def _preprocess(image: np.ndarray) -> tuple[np.ndarray, float, float]:
"""Letterbox-resize + normalize to NCHW float32. Returns input tensor
and scale factors for mapping boxes back to original coords."""
from PIL import Image
img = Image.fromarray(image).convert("RGB")
orig_w, orig_h = img.size
scale = min(_INPUT_SIZE / orig_w, _INPUT_SIZE / orig_h)
new_w = int(orig_w * scale)
new_h = int(orig_h * scale)
img = img.resize((new_w, new_h), Image.BICUBIC)
# Paste onto gray canvas
canvas = np.full((_INPUT_SIZE, _INPUT_SIZE, 3), 114, dtype=np.uint8)
pad_x = (_INPUT_SIZE - new_w) // 2
pad_y = (_INPUT_SIZE - new_h) // 2
canvas[pad_y : pad_y + new_h, pad_x : pad_x + new_w] = np.array(img)
blob = canvas.astype(np.float32) / 255.0
blob = blob.transpose(2, 0, 1)[np.newaxis] # NCHW
return blob, scale, pad_x, pad_y
def _postprocess(
outputs: np.ndarray,
scale: float,
pad_x: int,
pad_y: int,
orig_w: int,
orig_h: int,
conf_threshold: float,
max_detections: int,
) -> list[DetectionBox]:
"""Parse YOLOv8 output (1, 84, N) → list of DetectionBox."""
# outputs shape: (1, 84, N) where 84 = 4 box coords + 80 class scores
preds = outputs[0] # (84, N)
preds = preds.T # (N, 84)
boxes_xywh = preds[:, :4]
scores = preds[:, 4:]
class_ids = np.argmax(scores, axis=1)
confidences = scores[np.arange(len(scores)), class_ids]
mask = confidences >= conf_threshold
boxes_xywh = boxes_xywh[mask]
class_ids = class_ids[mask]
confidences = confidences[mask]
if len(confidences) == 0:
return []
# Sort by confidence, take top N
order = np.argsort(-confidences)[:max_detections]
boxes_xywh = boxes_xywh[order]
class_ids = class_ids[order]
confidences = confidences[order]
results = []
for i in range(len(confidences)):
cx, cy, w, h = boxes_xywh[i]
# Remove letterbox padding and rescale to original image
x1 = (cx - w / 2 - pad_x) / scale
y1 = (cy - h / 2 - pad_y) / scale
x2 = (cx + w / 2 - pad_x) / scale
y2 = (cy + h / 2 - pad_y) / scale
# Normalize to 0-1
bbox = [
max(0, x1 / orig_w),
max(0, y1 / orig_h),
min(1, x2 / orig_w),
min(1, y2 / orig_h),
]
label = COCO_LABELS[class_ids[i]] if class_ids[i] < len(COCO_LABELS) else f"class_{class_ids[i]}"
results.append(DetectionBox(label=label, confidence=float(confidences[i]), bbox=bbox))
return results
class YOLOv8Detector(ObjectDetector):
def __init__(self, settings: VisionSettings):
model_path = Path(settings.models_dir) / "detect" / "yolov8n.onnx"
opts = ort.SessionOptions()
opts.inter_op_num_threads = 2
opts.intra_op_num_threads = 2
logger.info("Loading YOLOv8n from %s", model_path)
self._session = ort.InferenceSession(str(model_path), opts, providers=["CPUExecutionProvider"])
self._conf_threshold = settings.detector.min_confidence
self._max_detections = settings.detector.max_detections
def detect(self, image: np.ndarray) -> list[DetectionBox]:
orig_h, orig_w = image.shape[:2]
blob, scale, pad_x, pad_y = _preprocess(image)
input_name = self._session.get_inputs()[0].name
outputs = self._session.run(None, {input_name: blob})[0]
return _postprocess(
outputs, scale, pad_x, pad_y, orig_w, orig_h,
self._conf_threshold, self._max_detections,
)

View File

@@ -0,0 +1,86 @@
"""
OpenCLIP ViT-B/32 embedder using ONNX Runtime.
Expects two ONNX files under {models_dir}/embed/:
- visual.onnx (image encoder)
- textual.onnx (text encoder)
These are exported from open_clip via bootstrap_models.py.
"""
import logging
from pathlib import Path
import numpy as np
import onnxruntime as ort
from app.config import VisionSettings
from app.services.vision.base import Embedder
logger = logging.getLogger(__name__)
# OpenCLIP ViT-B/32 preprocessing constants (ImageNet norm)
_MEAN = np.array([0.48145466, 0.4578275, 0.40821073], dtype=np.float32)
_STD = np.array([0.26862954, 0.26130258, 0.27577711], dtype=np.float32)
_INPUT_SIZE = 224
def _preprocess_image(image: np.ndarray) -> np.ndarray:
"""Resize, center-crop, normalize an RGB uint8 image to NCHW float32."""
from PIL import Image
img = Image.fromarray(image).convert("RGB")
# Resize shortest edge to _INPUT_SIZE, then center crop
w, h = img.size
scale = _INPUT_SIZE / min(w, h)
img = img.resize((int(w * scale), int(h * scale)), Image.BICUBIC)
w, h = img.size
left = (w - _INPUT_SIZE) // 2
top = (h - _INPUT_SIZE) // 2
img = img.crop((left, top, left + _INPUT_SIZE, top + _INPUT_SIZE))
arr = np.array(img, dtype=np.float32) / 255.0
arr = (arr - _MEAN) / _STD
arr = arr.transpose(2, 0, 1) # HWC → CHW
return arr[np.newaxis] # NCHW
class OpenCLIPEmbedder(Embedder):
def __init__(self, settings: VisionSettings):
model_dir = Path(settings.models_dir) / "embed"
visual_path = model_dir / "visual.onnx"
textual_path = model_dir / "textual.onnx"
opts = ort.SessionOptions()
opts.inter_op_num_threads = 2
opts.intra_op_num_threads = 2
logger.info("Loading visual encoder from %s", visual_path)
self._visual = ort.InferenceSession(str(visual_path), opts, providers=["CPUExecutionProvider"])
logger.info("Loading textual encoder from %s", textual_path)
self._textual = ort.InferenceSession(str(textual_path), opts, providers=["CPUExecutionProvider"])
def embed_image(self, image: np.ndarray) -> np.ndarray:
inp = _preprocess_image(image)
input_name = self._visual.get_inputs()[0].name
out = self._visual.run(None, {input_name: inp})[0][0]
out = out / np.linalg.norm(out)
return out.astype(np.float32)
def embed_text(self, text: str) -> np.ndarray:
import open_clip
tokenizer = open_clip.get_tokenizer("ViT-B-32")
tokens = tokenizer([text]).numpy().astype(np.int64)
# Compute EOT indices outside ONNX (avoids ArgMax(13) op)
eot_indices = tokens.argmax(axis=-1).astype(np.int64)
inputs = self._textual.get_inputs()
out = self._textual.run(None, {
inputs[0].name: tokens,
inputs[1].name: eot_indices,
})[0][0]
out = out / np.linalg.norm(out)
return out.astype(np.float32)
@property
def dim(self) -> int:
return 512

View File

@@ -0,0 +1,177 @@
"""
Export / download all vision model weights to ONNX format.
Run ONCE on any machine with Python + pip (doesn't need GPU):
pip install open-clip-torch ultralytics onnx
python -m app.services.vision.export_models [--models-dir /data/models]
This produces:
embed/visual.onnx (~350 MB)
embed/textual.onnx (~250 MB)
detect/yolov8n.onnx (~12 MB)
YuNet and SFace are downloaded by bootstrap_models.py at worker boot
(Apache 2.0, lightweight, no export step needed).
After export, copy the /data/models directory into your Docker volume:
docker cp /data/models mulita-worker:/data/models
Or mount a host path in docker-compose.yml.
"""
import argparse
import logging
import sys
from pathlib import Path
logger = logging.getLogger(__name__)
def export_openclip(models_dir: Path):
"""Export OpenCLIP ViT-B/32 to two ONNX files (visual + textual)."""
import torch
import open_clip
out_dir = models_dir / "embed"
out_dir.mkdir(parents=True, exist_ok=True)
visual_path = out_dir / "visual.onnx"
textual_path = out_dir / "textual.onnx"
if visual_path.exists() and textual_path.exists():
logger.info("OpenCLIP ONNX files already exist, skipping export")
return
logger.info("Loading OpenCLIP ViT-B-32 laion2b_s34b_b79k...")
model, _, preprocess = open_clip.create_model_and_transforms(
"ViT-B-32", pretrained="laion2b_s34b_b79k"
)
model.eval()
# Use dynamo=False to get the legacy TorchScript exporter which
# produces IR version 9 (compatible with onnxruntime 1.17.x).
# The new torch.onnx.export default (dynamo=True) emits IR 10.
export_kwargs = dict(opset_version=14, dynamo=False)
# ── Visual encoder ────────────────────────────────────────────────
if not visual_path.exists():
logger.info("Exporting visual encoder → %s", visual_path)
dummy_image = torch.randn(1, 3, 224, 224)
torch.onnx.export(
model.visual,
dummy_image,
str(visual_path),
input_names=["image"],
output_names=["embedding"],
dynamic_axes={"image": {0: "batch"}},
**export_kwargs,
)
size_mb = visual_path.stat().st_size / 1e6
logger.info("Visual encoder exported (%.1f MB)", size_mb)
# ── Textual encoder ───────────────────────────────────────────────
if not textual_path.exists():
logger.info("Exporting textual encoder → %s", textual_path)
tokenizer = open_clip.get_tokenizer("ViT-B-32")
dummy_text = tokenizer(["a photo"]).to(torch.int64)
class TextEncoder(torch.nn.Module):
"""Wrap the CLIP text encoder to avoid argmax in the ONNX graph.
OpenCLIP uses argmax to find the EOT token position, but ORT
ARM64 doesn't support ArgMax(13). We pre-compute the EOT index
from the token sequence and pass it directly."""
def __init__(self, clip_model):
super().__init__()
self.transformer = clip_model.transformer
self.token_embedding = clip_model.token_embedding
self.positional_embedding = clip_model.positional_embedding
self.ln_final = clip_model.ln_final
self.text_projection = clip_model.text_projection
def forward(self, text, eot_indices):
x = self.token_embedding(text)
x = x + self.positional_embedding
x = x.permute(1, 0, 2) # NLD -> LND
x = self.transformer(x)
x = x.permute(1, 0, 2) # LND -> NLD
x = self.ln_final(x)
# Take the feature at the EOT token. The EOT index is
# passed in as a separate input (computed outside ONNX)
# to avoid ArgMax(13) which ORT ARM64 doesn't support.
x = x[torch.arange(x.shape[0]), eot_indices]
x = x @ self.text_projection
return x
text_enc = TextEncoder(model)
text_enc.eval()
# Compute EOT indices from dummy tokens (argmax of token ids)
dummy_eot = dummy_text.argmax(dim=-1)
torch.onnx.export(
text_enc,
(dummy_text, dummy_eot),
str(textual_path),
input_names=["text", "eot_indices"],
output_names=["embedding"],
dynamic_axes={"text": {0: "batch"}, "eot_indices": {0: "batch"}},
**export_kwargs,
)
size_mb = textual_path.stat().st_size / 1e6
logger.info("Textual encoder exported (%.1f MB)", size_mb)
def export_yolov8n(models_dir: Path):
"""Export YOLOv8n to ONNX."""
out_dir = models_dir / "detect"
out_dir.mkdir(parents=True, exist_ok=True)
onnx_path = out_dir / "yolov8n.onnx"
if onnx_path.exists():
logger.info("YOLOv8n ONNX already exists, skipping export")
return
logger.info("Exporting YOLOv8n → %s", onnx_path)
from ultralytics import YOLO
model = YOLO("yolov8n.pt")
model.export(format="onnx", imgsz=640, simplify=True)
# ultralytics exports to cwd as yolov8n.onnx — move to target
exported = Path("yolov8n.onnx")
if exported.exists():
exported.rename(onnx_path)
size_mb = onnx_path.stat().st_size / 1e6
logger.info("YOLOv8n exported (%.1f MB)", size_mb)
def main():
parser = argparse.ArgumentParser(description="Export vision model weights to ONNX")
parser.add_argument(
"--models-dir",
type=Path,
default=Path("/data/models"),
help="Directory to write model files (default: /data/models)",
)
args = parser.parse_args()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
models_dir = args.models_dir
models_dir.mkdir(parents=True, exist_ok=True)
logger.info("Exporting models to %s", models_dir)
export_openclip(models_dir)
export_yolov8n(models_dir)
logger.info("Done. Run bootstrap_models.py next to download YuNet + SFace.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,148 @@
"""
Face detection (YuNet) + recognition (SFace) using OpenCV DNN.
YuNet is loaded via cv2.FaceDetectorYN which handles the multi-scale
anchor decoding and NMS internally. SFace recognition uses raw ONNX
Runtime for the 128-d embedding.
Both models are from opencv_zoo (Apache 2.0 license).
Expects {models_dir}/face/:
- yunet.onnx (~233 KB)
- sface.onnx (~37 MB, 128-d embeddings)
"""
import logging
from pathlib import Path
import numpy as np
import cv2
import onnxruntime as ort
from app.config import VisionSettings
from app.services.vision.base import FaceProcessor, FaceDetection
logger = logging.getLogger(__name__)
def _align_face(image: np.ndarray, landmarks: np.ndarray) -> np.ndarray:
"""Align and crop a 112x112 face patch using 5-point landmarks."""
left_eye = landmarks[0]
right_eye = landmarks[1]
dx = right_eye[0] - left_eye[0]
dy = right_eye[1] - left_eye[1]
angle = np.degrees(np.arctan2(dy, dx))
eye_center = ((left_eye[0] + right_eye[0]) / 2, (left_eye[1] + right_eye[1]) / 2)
eye_dist = np.sqrt(dx * dx + dy * dy)
M = cv2.getRotationMatrix2D(eye_center, angle, 1.0)
rotated = cv2.warpAffine(image, M, (image.shape[1], image.shape[0]))
# Crop around face center
scale = 64.0 / max(eye_dist, 1e-6)
cx, cy = eye_center
half = 56.0 / scale
x1 = max(0, int(cx - half))
y1 = max(0, int(cy - half * 0.8))
x2 = min(rotated.shape[1], int(cx + half))
y2 = min(rotated.shape[0], int(cy + half * 1.2))
crop = rotated[y1:y2, x1:x2]
if crop.size == 0:
return np.zeros((112, 112, 3), dtype=np.float32)
return cv2.resize(crop, (112, 112)).astype(np.float32)
class YuNetSFaceProcessor(FaceProcessor):
def __init__(self, settings: VisionSettings):
face_dir = Path(settings.models_dir) / "face"
yunet_path = str(face_dir / "yunet.onnx")
sface_path = str(face_dir / "sface.onnx")
# YuNet via OpenCV's FaceDetectorYN — handles anchor decoding + NMS
self._detector = cv2.FaceDetectorYN.create(
yunet_path,
"",
(640, 640),
settings.faces.recognition_threshold,
0.3, # NMS threshold
5000, # top_k
)
logger.info("YuNet face detector loaded via OpenCV")
# SFace via ONNX Runtime
opts = ort.SessionOptions()
opts.inter_op_num_threads = 2
opts.intra_op_num_threads = 2
ort.set_default_logger_severity(3)
self._recognizer = ort.InferenceSession(sface_path, opts, providers=["CPUExecutionProvider"])
logger.info("SFace recognizer loaded via ONNX Runtime")
self._min_face_size = settings.faces.min_face_size
def process(self, image: np.ndarray) -> list[FaceDetection]:
orig_h, orig_w = image.shape[:2]
# Convert RGB → BGR for OpenCV
bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
# Set input size to actual image dimensions
self._detector.setInputSize((orig_w, orig_h))
# Detect faces
_, faces_raw = self._detector.detect(bgr)
if faces_raw is None or len(faces_raw) == 0:
return []
results = []
for face in faces_raw:
# face: [x, y, w, h, right_eye_x, right_eye_y, left_eye_x, left_eye_y,
# nose_x, nose_y, right_mouth_x, right_mouth_y, left_mouth_x, left_mouth_y, score]
x, y, w, h = int(face[0]), int(face[1]), int(face[2]), int(face[3])
score = float(face[14])
# Filter small faces
face_size = max(w, h)
if face_size < self._min_face_size:
continue
# Normalized bbox
bbox = [
max(0, x / orig_w),
max(0, y / orig_h),
min(1, (x + w) / orig_w),
min(1, (y + h) / orig_h),
]
# Extract 5-point landmarks for alignment
landmarks = np.array([
[face[4], face[5]], # right eye
[face[6], face[7]], # left eye
[face[8], face[9]], # nose
[face[10], face[11]], # right mouth
[face[12], face[13]], # left mouth
], dtype=np.float32)
# Align face for recognition
face_crop = _align_face(image, landmarks)
# SFace expects (1, 3, 112, 112) float32, BGR
face_bgr = cv2.cvtColor(face_crop.astype(np.uint8), cv2.COLOR_RGB2BGR)
face_blob = (face_bgr.astype(np.float32) / 255.0).transpose(2, 0, 1)[np.newaxis]
rec_input = self._recognizer.get_inputs()[0].name
embedding = self._recognizer.run(None, {rec_input: face_blob})[0][0]
embedding = embedding / np.linalg.norm(embedding)
results.append(FaceDetection(
bbox=bbox,
embedding=embedding.astype(np.float32),
quality=score,
))
return results
@property
def embedding_dim(self) -> int:
return 128

View File

@@ -0,0 +1,70 @@
"""
Face detection + recognition using InsightFace (RetinaFace + ArcFace).
Uses the buffalo_l model pack which auto-downloads on first use (~300MB).
Produces 512-d ArcFace embeddings. Non-commercial research license —
fine for homelab self-hosting.
"""
import logging
from pathlib import Path
import numpy as np
from app.config import VisionSettings
from app.services.vision.base import FaceProcessor, FaceDetection
logger = logging.getLogger(__name__)
class InsightFaceProcessor(FaceProcessor):
def __init__(self, settings: VisionSettings):
from insightface.app import FaceAnalysis
model_root = str(Path(settings.models_dir) / "face" / "insightface")
logger.info("Loading InsightFace buffalo_l from %s", model_root)
self._app = FaceAnalysis(
name="buffalo_l",
root=model_root,
providers=["CPUExecutionProvider"],
)
self._app.prepare(ctx_id=-1, det_size=(640, 640))
self._min_det_score = settings.faces.recognition_threshold
def process(self, image: np.ndarray) -> list[FaceDetection]:
orig_h, orig_w = image.shape[:2]
# InsightFace expects BGR
bgr = image[:, :, ::-1].copy()
faces = self._app.get(bgr)
if not faces:
return []
results = []
for face in faces:
if face.det_score < self._min_det_score:
continue
# face.bbox is [x1, y1, x2, y2] in pixel coords
x1, y1, x2, y2 = face.bbox
bbox = [
max(0, float(x1) / orig_w),
max(0, float(y1) / orig_h),
min(1, float(x2) / orig_w),
min(1, float(y2) / orig_h),
]
embedding = face.normed_embedding # already L2-normalized, 512-d
results.append(FaceDetection(
bbox=bbox,
embedding=embedding.astype(np.float32),
quality=float(face.det_score),
))
return results
@property
def embedding_dim(self) -> int:
return 512

View File

@@ -0,0 +1,45 @@
"""
OCR engine using rapidocr-onnxruntime (PP-OCRv4 weights).
No PaddlePaddle dependency — pure ONNX Runtime. Language packs are
downloaded automatically by rapidocr on first use.
"""
import logging
import numpy as np
from app.config import VisionSettings
from app.services.vision.base import OCREngine, OCRResult
logger = logging.getLogger(__name__)
class RapidOCREngine(OCREngine):
def __init__(self, settings: VisionSettings):
from rapidocr_onnxruntime import RapidOCR
self._min_confidence = settings.ocr.min_confidence
self._engine = RapidOCR()
logger.info("RapidOCR engine initialized")
def run(self, image: np.ndarray) -> list[OCRResult]:
result, _ = self._engine(image)
if not result:
return []
out = []
for box, text, score in result:
if score < self._min_confidence:
continue
# box is [[x1,y1],[x2,y2],[x3,y3],[x4,y4]] — take bounding rect
xs = [p[0] for p in box]
ys = [p[1] for p in box]
h, w = image.shape[:2]
bbox = [
min(xs) / w,
min(ys) / h,
max(xs) / w,
max(ys) / h,
]
out.append(OCRResult(text=text, confidence=float(score), bbox=bbox))
return out

View File

@@ -0,0 +1,41 @@
"""
ONNX Runtime backend — default CPU inference for all vision models.
Each create_* method returns a concrete implementation of the
corresponding ABC from base.py. Models are loaded from ONNX files
under settings.vision.models_dir, downloaded on first boot by
bootstrap_models.py.
"""
import logging
from app.config import VisionSettings
from app.services.vision.base import Embedder, OCREngine, ObjectDetector, FaceProcessor, ContentClassifier
logger = logging.getLogger(__name__)
class ONNXBackend:
"""Factory for ONNX Runtime-based vision model instances."""
def __init__(self, vision_settings: VisionSettings):
self._settings = vision_settings
def create_embedder(self) -> Embedder:
from app.services.vision.embed import OpenCLIPEmbedder
return OpenCLIPEmbedder(self._settings)
def create_ocr(self) -> OCREngine:
from app.services.vision.ocr import RapidOCREngine
return RapidOCREngine(self._settings)
def create_detector(self) -> ObjectDetector:
from app.services.vision.detect import YOLOv8Detector
return YOLOv8Detector(self._settings)
def create_face_processor(self) -> FaceProcessor:
from app.services.vision.insightface_processor import InsightFaceProcessor
return InsightFaceProcessor(self._settings)
def create_classifier(self) -> ContentClassifier:
from app.services.vision.classify import CLIPContentClassifier
return CLIPContentClassifier(self._settings)

View File

@@ -0,0 +1,84 @@
"""
ModelRegistry — singleton that lazy-loads vision models per worker process.
Usage from Celery tasks:
from app.services.vision.registry import registry
embedder = registry.get_embedder()
vec = embedder.embed_image(img)
Models are created on first access and cached for the worker's lifetime.
The registry reads settings.vision to decide which backend to use and
where model weights live.
"""
import logging
from functools import lru_cache
from app.config import settings
from app.services.vision.base import Embedder, OCREngine, ObjectDetector, FaceProcessor, ContentClassifier
logger = logging.getLogger(__name__)
class ModelRegistry:
"""Central access point for all vision models."""
def __init__(self):
self._vision = settings.vision
@lru_cache(maxsize=1)
def get_embedder(self) -> Embedder:
logger.info("Loading embedder: %s (backend=%s)", self._vision.embedder.name, self._vision.backend)
return self._load_backend().create_embedder()
@lru_cache(maxsize=1)
def get_ocr(self) -> OCREngine:
logger.info("Loading OCR engine (backend=%s)", self._vision.backend)
return self._load_backend().create_ocr()
@lru_cache(maxsize=1)
def get_detector(self) -> ObjectDetector:
logger.info("Loading object detector (backend=%s)", self._vision.backend)
return self._load_backend().create_detector()
@lru_cache(maxsize=1)
def get_face_processor(self) -> FaceProcessor:
logger.info("Loading face processor (backend=%s)", self._vision.backend)
return self._load_backend().create_face_processor()
@lru_cache(maxsize=1)
def get_classifier(self) -> ContentClassifier:
logger.info("Loading content classifier (backend=%s)", self._vision.backend)
return self._load_backend().create_classifier()
@lru_cache(maxsize=1)
def _load_backend(self):
"""Import and instantiate the configured backend."""
backend_name = self._vision.backend
if backend_name == "onnx":
from app.services.vision.onnx_backend import ONNXBackend
return ONNXBackend(self._vision)
elif backend_name == "rocm":
from app.services.vision.rocm_backend import ROCmBackend
return ROCmBackend(self._vision)
else:
raise ValueError(f"Unknown vision backend: {backend_name}")
def warmup(self):
"""Pre-load all enabled models. Called from Celery worker_process_init
on the vision queue to avoid cold-start latency on the first task."""
logger.info("Warming up vision models...")
self.get_embedder()
if self._vision.ocr.enabled:
self.get_ocr()
if self._vision.detector.enabled:
self.get_detector()
if self._vision.faces.enabled:
self.get_face_processor()
if self._vision.classifier.enabled:
self.get_classifier()
logger.info("Vision model warmup complete")
# Module-level singleton. Import this from tasks.
registry = ModelRegistry()

View File

@@ -0,0 +1,25 @@
"""
ROCm backend — GPU-accelerated inference for Radeon 760M-class hardware.
Stub: raises NotImplementedError on all factory methods. To enable,
set `vision.backend: rocm` in mulita.yml once ROCm support is implemented.
"""
from app.config import VisionSettings
from app.services.vision.base import Embedder, OCREngine, ObjectDetector, FaceProcessor
class ROCmBackend:
def __init__(self, vision_settings: VisionSettings):
self._settings = vision_settings
def create_embedder(self) -> Embedder:
raise NotImplementedError("ROCm backend not yet implemented — use 'onnx'")
def create_ocr(self) -> OCREngine:
raise NotImplementedError("ROCm backend not yet implemented — use 'onnx'")
def create_detector(self) -> ObjectDetector:
raise NotImplementedError("ROCm backend not yet implemented — use 'onnx'")
def create_face_processor(self) -> FaceProcessor:
raise NotImplementedError("ROCm backend not yet implemented — use 'onnx'")

View File

@@ -9,7 +9,7 @@ celery_app = Celery(
'mulita', 'mulita',
broker=settings.celery_broker_url, broker=settings.celery_broker_url,
backend=settings.celery_result_backend, backend=settings.celery_result_backend,
include=['app.tasks.scan', 'app.tasks.thumbs'] include=['app.tasks.scan', 'app.tasks.thumbs', 'app.tasks.vision']
) )
# Configure Celery # Configure Celery
@@ -22,6 +22,13 @@ celery_app.conf.update(
task_routes={ task_routes={
'app.tasks.thumbs.*': {'queue': 'high'}, 'app.tasks.thumbs.*': {'queue': 'high'},
'app.tasks.scan.*': {'queue': 'low'}, 'app.tasks.scan.*': {'queue': 'low'},
'app.tasks.vision.*': {'queue': 'vision'},
'embed_photo': {'queue': 'vision'},
'ocr_photo': {'queue': 'vision'},
'detect_objects': {'queue': 'vision'},
'extract_faces': {'queue': 'vision'},
'classify_content': {'queue': 'vision'},
'vision_fanout': {'queue': 'vision'},
}, },
task_default_queue='default', task_default_queue='default',
task_default_exchange='default', task_default_exchange='default',

View File

@@ -317,6 +317,15 @@ async def _generate_thumbnails_async(photo_id: str, task):
await session.commit() await session.commit()
logger.info(f"Thumbnails generated for photo {photo_id}") logger.info(f"Thumbnails generated for photo {photo_id}")
# Dispatch vision pipeline (embedding, OCR, detection, faces)
# after thumbs are ready so vision tasks have images to read.
try:
from app.tasks.vision import vision_fanout
vision_fanout.delay(photo_id)
except Exception as e:
logger.warning(f"Could not dispatch vision_fanout for {photo_id}: {e}")
return {'status': 'success', 'photo_id': photo_id} return {'status': 'success', 'photo_id': photo_id}
except Exception as e: except Exception as e:

479
backend/app/tasks/vision.py Normal file
View File

@@ -0,0 +1,479 @@
"""
Celery tasks for the vision pipeline — embedding, OCR, object detection,
face recognition.
All tasks run on the dedicated `vision` queue with limited concurrency
(memory-bound CPU inference). They read thumbnails generated by
generate_thumbnails, so they MUST run after thumbs complete.
DB access uses sync psycopg2 sessions (not asyncpg) because Celery
forks workers and asyncpg connections can't be shared across forks.
"""
import logging
from pathlib import Path
import numpy as np
from celery import shared_task
from sqlalchemy import create_engine, text as sa_text, select, delete
from sqlalchemy.orm import Session, sessionmaker
from PIL import Image
from app.models.embeddings import Embedding
from app.config import settings
logger = logging.getLogger(__name__)
def _get_sync_session() -> Session:
"""Create a sync DB session for use in Celery workers."""
sync_url = settings.database_url.replace("+asyncpg", "+psycopg2").replace("+aiosqlite", "")
engine = create_engine(sync_url, pool_pre_ping=True)
return sessionmaker(bind=engine)()
def _load_thumb(photo_id: str, size: str = "medium") -> np.ndarray | None:
"""Load a thumbnail as an RGB numpy array."""
thumb_path = Path(f"/data/thumbs/{photo_id}/{size}.webp")
if not thumb_path.exists():
logger.warning("Thumbnail not found: %s", thumb_path)
return None
img = Image.open(thumb_path).convert("RGB")
return np.array(img)
@shared_task(name='embed_photo', queue='vision')
def embed_photo(photo_id: str):
"""Generate CLIP embedding for a photo and store in pgvector."""
if not settings.vision.enabled:
return {'status': 'skipped', 'reason': 'vision disabled'}
image = _load_thumb(photo_id, "medium") # 640px
if image is None:
return {'status': 'error', 'message': 'thumbnail not found'}
from app.services.vision.registry import registry
embedder = registry.get_embedder()
vector = embedder.embed_image(image)
model_name = settings.vision.embedder.name
session = _get_sync_session()
try:
session.execute(
delete(Embedding).where(
Embedding.photo_id == photo_id,
Embedding.model == model_name,
)
)
emb = Embedding(
photo_id=photo_id,
model=model_name,
vector=vector.tolist(),
)
session.add(emb)
session.commit()
finally:
session.close()
logger.info("Embedded photo %s with %s", photo_id, model_name)
return {'status': 'success', 'photo_id': photo_id}
@shared_task(name='vision_fanout', queue='vision')
def vision_fanout(photo_id: str):
"""Dispatch all enabled vision tasks for a photo."""
if not settings.vision.enabled:
return {'status': 'skipped', 'reason': 'vision disabled'}
embed_photo.delay(photo_id)
if settings.vision.ocr.enabled:
ocr_photo.delay(photo_id)
if settings.vision.detector.enabled:
detect_objects.delay(photo_id)
if settings.vision.faces.enabled:
extract_faces.delay(photo_id)
if settings.vision.classifier.enabled:
classify_content.delay(photo_id)
return {'status': 'dispatched', 'photo_id': photo_id}
@shared_task(name='ocr_photo', queue='vision')
def ocr_photo(photo_id: str):
"""Run OCR on a photo and store text regions."""
if not settings.vision.enabled or not settings.vision.ocr.enabled:
return {'status': 'skipped', 'reason': 'OCR disabled'}
image = _load_thumb(photo_id, "large") # 1280px for better OCR accuracy
if image is None:
return {'status': 'error', 'message': 'thumbnail not found'}
from app.services.vision.registry import registry
ocr_engine = registry.get_ocr()
results = ocr_engine.run(image)
if not results:
logger.info("No OCR text found for photo %s", photo_id)
return {'status': 'success', 'photo_id': photo_id, 'regions': 0}
from app.models.ocr_text import OCRText
session = _get_sync_session()
try:
session.execute(delete(OCRText).where(OCRText.photo_id == photo_id))
for r in results:
session.add(OCRText(
photo_id=photo_id,
text=r.text,
language=r.language,
confidence=r.confidence,
bbox=r.bbox,
))
session.commit()
finally:
session.close()
logger.info("OCR: %d text regions for photo %s", len(results), photo_id)
return {'status': 'success', 'photo_id': photo_id, 'regions': len(results)}
@shared_task(name='detect_objects', queue='vision')
def detect_objects(photo_id: str):
"""Detect objects in a photo, create Tag(kind=object) rows, and
link via photo_tags with confidence/bbox/source."""
if not settings.vision.enabled or not settings.vision.detector.enabled:
return {'status': 'skipped', 'reason': 'detection disabled'}
image = _load_thumb(photo_id, "medium") # 640px
if image is None:
return {'status': 'error', 'message': 'thumbnail not found'}
from app.services.vision.registry import registry
detector = registry.get_detector()
detections = detector.detect(image)
if not detections:
logger.info("No objects detected for photo %s", photo_id)
return {'status': 'success', 'photo_id': photo_id, 'objects': 0}
from app.models.tags import Tag, photo_tags
source_name = "vision:yolov8n"
session = _get_sync_session()
try:
# Wipe previous detection results for this photo from this model
session.execute(
delete(photo_tags).where(
photo_tags.c.photo_id == photo_id,
photo_tags.c.source == source_name,
)
)
# Group detections by label, keep highest confidence per label
best_per_label: dict[str, tuple[float, list]] = {}
for det in detections:
if det.label not in best_per_label or det.confidence > best_per_label[det.label][0]:
best_per_label[det.label] = (det.confidence, det.bbox)
for label, (confidence, bbox) in best_per_label.items():
# Find or create the object tag
tag = session.execute(
select(Tag).where(Tag.name == label, Tag.kind == 'object')
).scalar_one_or_none()
if not tag:
tag = Tag(name=label, kind='object', source=source_name)
session.add(tag)
session.flush() # get tag.id
# Insert photo_tags association with ML metadata
session.execute(
photo_tags.insert().values(
photo_id=photo_id,
tag_id=tag.id,
confidence=confidence,
bbox=bbox,
source=source_name,
)
)
session.commit()
finally:
session.close()
labels = [d.label for d in detections]
logger.info("Detected %d objects in photo %s: %s", len(detections), photo_id, labels)
return {'status': 'success', 'photo_id': photo_id, 'objects': len(detections)}
@shared_task(name='classify_content', queue='vision')
def classify_content(photo_id: str):
"""Classify image content type (screenshot, document, artwork, etc.)
using CLIP zero-shot classification. Writes Tag(kind=content_type)."""
if not settings.vision.enabled or not settings.vision.classifier.enabled:
return {'status': 'skipped', 'reason': 'classifier disabled'}
image = _load_thumb(photo_id, "medium")
if image is None:
return {'status': 'error', 'message': 'thumbnail not found'}
from app.services.vision.registry import registry
classifier = registry.get_classifier()
results = classifier.classify(image)
if not results:
logger.info("No confident classification for photo %s", photo_id)
return {'status': 'success', 'photo_id': photo_id, 'content_type': None}
from app.models.tags import Tag, photo_tags
source_name = "vision:clip_classifier"
best = results[0]
session = _get_sync_session()
try:
# Wipe previous classification for this photo
session.execute(
delete(photo_tags).where(
photo_tags.c.photo_id == photo_id,
photo_tags.c.source == source_name,
)
)
# Find or create content_type tag
tag = session.execute(
select(Tag).where(Tag.name == best.label, Tag.kind == 'content_type')
).scalar_one_or_none()
if not tag:
tag = Tag(name=best.label, kind='content_type', source=source_name)
session.add(tag)
session.flush()
session.execute(
photo_tags.insert().values(
photo_id=photo_id,
tag_id=tag.id,
confidence=best.confidence,
source=source_name,
)
)
session.commit()
finally:
session.close()
logger.info("Classified photo %s as '%s' (%.2f)", photo_id, best.label, best.confidence)
return {'status': 'success', 'photo_id': photo_id, 'content_type': best.label}
def _load_original(photo_id: str) -> np.ndarray | None:
"""Load the original photo file as an RGB numpy array, resized to
max 1280px on the longest edge for face detection."""
from sqlalchemy import create_engine, select as sa_select, text as sa_text
from app.models import Photo
session = _get_sync_session()
try:
photo = session.execute(
sa_select(Photo).where(Photo.id == photo_id)
).scalar_one_or_none()
if not photo or not photo.filepath:
return None
filepath = photo.filepath
finally:
session.close()
if not Path(filepath).exists():
logger.warning("Original file not found: %s", filepath)
return None
try:
img = Image.open(filepath).convert("RGB")
# Cap at 4000px on longest edge to avoid OOM, but keep as large
# as possible for face detection accuracy
max_dim = 4000
w, h = img.size
if max(w, h) > max_dim:
scale = max_dim / max(w, h)
img = img.resize((int(w * scale), int(h * scale)), Image.BICUBIC)
return np.array(img)
except Exception as e:
logger.warning("Failed to load original %s: %s", filepath, e)
return None
@shared_task(name='extract_faces', queue='vision')
def extract_faces(photo_id: str):
"""Detect faces and store recognition embeddings using InsightFace
(RetinaFace + ArcFace). No YOLO workaround needed — RetinaFace has
strong human-vs-non-human precision on its own."""
if not settings.vision.enabled or not settings.vision.faces.enabled:
return {'status': 'skipped', 'reason': 'faces disabled'}
image = _load_original(photo_id)
if image is None:
image = _load_thumb(photo_id, "large")
if image is None:
return {'status': 'error', 'message': 'no image available'}
from app.services.vision.registry import registry
face_proc = registry.get_face_processor()
faces = face_proc.process(image)
if not faces:
logger.info("No faces detected for photo %s", photo_id)
return _save_faces(photo_id, faces)
def _save_faces(photo_id: str, faces) -> dict:
from app.models.face_embedding import FaceEmbedding
session = _get_sync_session()
try:
session.execute(delete(FaceEmbedding).where(FaceEmbedding.photo_id == photo_id))
for face in faces:
session.add(FaceEmbedding(
photo_id=photo_id,
bbox=face.bbox,
vector=face.embedding.tolist(),
quality=face.quality,
cluster_id=None,
))
session.commit()
finally:
session.close()
if faces:
logger.info("Extracted %d verified face(s) from photo %s", len(faces), photo_id)
return {'status': 'success', 'photo_id': photo_id, 'faces': len(faces)}
@shared_task(name='recluster_faces', queue='vision')
def recluster_faces():
"""Run DBSCAN clustering over all face embeddings and assign/create
Tag(kind=face_cluster) entries."""
if not settings.vision.enabled or not settings.vision.faces.enabled:
return {'status': 'skipped', 'reason': 'faces disabled'}
from app.models.face_embedding import FaceEmbedding
from app.models.tags import Tag, photo_tags
from app.services.vision.clustering import cluster_faces
source_name = "vision:sface"
session = _get_sync_session()
try:
face_rows = session.execute(
select(FaceEmbedding).order_by(FaceEmbedding.created_at)
).scalars().all()
if len(face_rows) < 2:
logger.info("Not enough faces for clustering (%d)", len(face_rows))
return {'status': 'success', 'clusters': 0}
embeddings = np.array([f.vector for f in face_rows], dtype=np.float32)
labels = cluster_faces(embeddings, eps=settings.vision.faces.cluster_eps)
# Clean up old face_cluster tags and their photo_tags
old_cluster_tags = session.execute(
select(Tag).where(Tag.kind == 'face_cluster', Tag.source == source_name)
).scalars().all()
for old_tag in old_cluster_tags:
session.execute(
delete(photo_tags).where(
photo_tags.c.tag_id == old_tag.id,
photo_tags.c.source == source_name,
)
)
session.delete(old_tag)
session.flush()
# Build new clusters
cluster_tag_map: dict[int, str] = {}
# Track which photos belong to which cluster
cluster_photos: dict[int, set[str]] = {}
for i, label in enumerate(labels):
if label == -1:
face_rows[i].cluster_id = None
continue
if label not in cluster_photos:
cluster_photos[label] = set()
cluster_photos[label].add(face_rows[i].photo_id)
if label not in cluster_tag_map:
cluster_name = f"Person {label + 1}"
tag = Tag(
name=cluster_name,
kind='face_cluster',
source=source_name,
representative_photo_id=face_rows[i].photo_id,
)
session.add(tag)
session.flush()
cluster_tag_map[label] = tag.id
face_rows[i].cluster_id = cluster_tag_map[label]
# Write photo_tags associations so the tag count and tag_ids
# filter work for face clusters
for label, photo_ids in cluster_photos.items():
tag_id = cluster_tag_map[label]
for pid in photo_ids:
session.execute(
photo_tags.insert().values(
photo_id=pid,
tag_id=tag_id,
source=source_name,
)
)
session.commit()
finally:
session.close()
n_clusters = len(cluster_tag_map)
logger.info("Face clustering: %d clusters from %d faces", n_clusters, len(face_rows))
return {'status': 'success', 'clusters': n_clusters, 'faces': len(face_rows)}
@shared_task(name='backfill_vision')
def backfill_vision(task: str | None = None, limit: int | None = None):
"""Queue vision tasks for photos that haven't been processed yet.
Uses a sync DB connection to avoid asyncpg conflicts in Celery."""
model_name = settings.vision.embedder.name
sql = """
SELECT p.id FROM photos p
LEFT JOIN embeddings e ON e.photo_id = p.id AND e.model = :model
WHERE e.photo_id IS NULL
AND p.processing_status = 'completed'
ORDER BY p.added_at DESC
"""
if limit:
sql += f" LIMIT {limit}"
session = _get_sync_session()
try:
result = session.execute(sa_text(sql), {"model": model_name})
photo_ids = [row[0] for row in result.fetchall()]
finally:
session.close()
count = 0
for pid in photo_ids:
if task == 'embed' or task is None:
embed_photo.delay(pid)
if task == 'ocr' or task is None:
ocr_photo.delay(pid)
if task == 'detect' or task is None:
detect_objects.delay(pid)
if task == 'faces' or task is None:
extract_faces.delay(pid)
count += 1
logger.info("Backfill queued %d photos for vision processing", count)
return {'status': 'queued', 'count': count}

View File

@@ -5,7 +5,10 @@ python-multipart==0.0.6
# Database # Database
sqlalchemy[asyncio]==2.0.25 sqlalchemy[asyncio]==2.0.25
aiosqlite==0.19.0 aiosqlite==0.19.0 # SQLite escape hatch (docker-compose.sqlite.yml override)
asyncpg==0.29.0 # async Postgres driver (default)
psycopg2-binary==2.9.9 # sync Postgres driver, used by Alembic CLI
pgvector==0.2.5 # pgvector SQLAlchemy types
alembic==1.13.1 alembic==1.13.1
# Redis and Celery # Redis and Celery
@@ -31,6 +34,14 @@ pyexiftool==0.5.6
# File watching # File watching
watchfiles==0.21.0 watchfiles==0.21.0
# Vision pipeline (ONNX Runtime CPU inference)
onnxruntime==1.18.1
open-clip-torch==2.24.0 # tokenizer + export helper; inference via ONNX
rapidocr-onnxruntime==1.3.22
scikit-learn==1.4.0 # DBSCAN for face clustering
insightface>=0.7.3 # RetinaFace + ArcFace face detection/recognition
numpy>=1.26.0,<2.0
# Utilities # Utilities
pyyaml==6.0.1 pyyaml==6.0.1
pydantic==2.5.3 pydantic==2.5.3

47
docker-compose.sqlite.yml Normal file
View File

@@ -0,0 +1,47 @@
# SQLite escape hatch override.
#
# Usage (omit the `db` service from the up command):
#
# docker compose -f docker-compose.yml -f docker-compose.sqlite.yml \
# up frontend backend worker redis
#
# This pins the backend and worker to the legacy SQLite database file at
# /data/db/mulita.db (in the existing db_data volume), drops the dependency
# on Postgres, and skips Alembic — the SQLite schema is still managed by
# the inline ALTERs in app/database.py:init_db.
#
# Vision features that depend on pgvector (PR4 onward) will refuse to enable
# in this mode; the search/embedding endpoints will return 503 with a clear
# error pointing back at the default Postgres setup.
services:
backend:
command: sh -c "uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
environment:
- DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db
- REDIS_URL=redis://redis:6379
- CELERY_BROKER_URL=redis://redis:6379
- CELERY_RESULT_BACKEND=redis://redis:6379
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- TZ=${TZ:-UTC}
depends_on:
redis:
condition: service_started
worker:
environment:
- DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db
- REDIS_URL=redis://redis:6379
- CELERY_BROKER_URL=redis://redis:6379
- CELERY_RESULT_BACKEND=redis://redis:6379
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
- CELERYD_CONCURRENCY=${CELERYD_CONCURRENCY:-4}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- TZ=${TZ:-UTC}
depends_on:
redis:
condition: service_started
backend:
condition: service_started

View File

@@ -1,5 +1,3 @@
version: '3.8'
services: services:
frontend: frontend:
build: build:
@@ -36,9 +34,13 @@ services:
- ${PHOTO_DIRS:-./photos}:/photos:rw - ${PHOTO_DIRS:-./photos}:/photos:rw
- thumbs_data:/data/thumbs - thumbs_data:/data/thumbs
- proxies_data:/data/proxies - proxies_data:/data/proxies
- db_data:/data/db - db_data:/data/db # retained so the docker-compose.sqlite.yml override has somewhere to put mulita.db
# Run Alembic migrations before starting uvicorn. On a fresh Postgres
# the empty 0001 baseline is a no-op stamp; create_all in init_db then
# builds the schema.
command: sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
environment: environment:
- DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db - DATABASE_URL=postgresql+asyncpg://mulita:mulita@db:5432/mulita
- REDIS_URL=redis://redis:6379 - REDIS_URL=redis://redis:6379
- CELERY_BROKER_URL=redis://redis:6379 - CELERY_BROKER_URL=redis://redis:6379
- CELERY_RESULT_BACKEND=redis://redis:6379 - CELERY_RESULT_BACKEND=redis://redis:6379
@@ -47,7 +49,10 @@ services:
- LOG_LEVEL=${LOG_LEVEL:-INFO} - LOG_LEVEL=${LOG_LEVEL:-INFO}
- TZ=${TZ:-UTC} - TZ=${TZ:-UTC}
depends_on: depends_on:
- redis redis:
condition: service_started
db:
condition: service_healthy
networks: networks:
- mulita-network - mulita-network
restart: unless-stopped restart: unless-stopped
@@ -57,15 +62,16 @@ services:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: mulita-worker container_name: mulita-worker
command: celery -A app.tasks.celery worker --loglevel=${LOG_LEVEL:-info} --concurrency=${CELERYD_CONCURRENCY:-4} command: sh -c "python -m app.services.vision.bootstrap_models && celery -A app.tasks.celery worker --loglevel=${LOG_LEVEL:-info} --concurrency=${CELERYD_CONCURRENCY:-4} -Q default,high,low,vision"
volumes: volumes:
- ./mulita.yml:/app/config/mulita.yml:ro - ./mulita.yml:/app/config/mulita.yml:ro
- ${PHOTO_DIRS:-./photos}:/photos:rw - ${PHOTO_DIRS:-./photos}:/photos:rw
- thumbs_data:/data/thumbs - thumbs_data:/data/thumbs
- proxies_data:/data/proxies - proxies_data:/data/proxies
- db_data:/data/db - db_data:/data/db
- models_data:/data/models
environment: environment:
- DATABASE_URL=sqlite+aiosqlite:////data/db/mulita.db - DATABASE_URL=postgresql+asyncpg://mulita:mulita@db:5432/mulita
- REDIS_URL=redis://redis:6379 - REDIS_URL=redis://redis:6379
- CELERY_BROKER_URL=redis://redis:6379 - CELERY_BROKER_URL=redis://redis:6379
- CELERY_RESULT_BACKEND=redis://redis:6379 - CELERY_RESULT_BACKEND=redis://redis:6379
@@ -74,12 +80,34 @@ services:
- LOG_LEVEL=${LOG_LEVEL:-INFO} - LOG_LEVEL=${LOG_LEVEL:-INFO}
- TZ=${TZ:-UTC} - TZ=${TZ:-UTC}
depends_on: depends_on:
- redis redis:
- backend condition: service_started
backend:
condition: service_started
db:
condition: service_healthy
networks: networks:
- mulita-network - mulita-network
restart: unless-stopped restart: unless-stopped
db:
image: pgvector/pgvector:pg16
container_name: mulita-db
environment:
POSTGRES_USER: mulita
POSTGRES_PASSWORD: mulita
POSTGRES_DB: mulita
volumes:
- pg_data:/var/lib/postgresql/data
networks:
- mulita-network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mulita -d mulita"]
interval: 5s
timeout: 5s
retries: 10
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: mulita-redis container_name: mulita-redis
@@ -102,4 +130,6 @@ volumes:
thumbs_data: thumbs_data:
proxies_data: proxies_data:
db_data: db_data:
redis_data: redis_data:
pg_data:
models_data:

View File

@@ -2,6 +2,10 @@ import { useState } from 'react'
import { Timeline } from './components/timeline/Timeline' import { Timeline } from './components/timeline/Timeline'
import { DuplicatesView } from './components/duplicates/DuplicatesView' import { DuplicatesView } from './components/duplicates/DuplicatesView'
import { MapView } from './components/map/MapView' import { MapView } from './components/map/MapView'
import { PeopleView } from './components/people/PeopleView'
import { TagsView } from './components/tags/TagsView'
import { ColorsView } from './components/colors/ColorsView'
import { RatedView } from './components/rated/RatedView'
import { LeftSidebar } from './components/layout/LeftSidebar' import { LeftSidebar } from './components/layout/LeftSidebar'
import { RightSidebar } from './components/layout/RightSidebar' import { RightSidebar } from './components/layout/RightSidebar'
import { TopBar } from './components/layout/TopBar' import { TopBar } from './components/layout/TopBar'
@@ -86,6 +90,14 @@ function App() {
<MapView /> <MapView />
) : currentSection === 'duplicates' ? ( ) : currentSection === 'duplicates' ? (
<DuplicatesView /> <DuplicatesView />
) : currentSection === 'people' ? (
<PeopleView />
) : currentSection === 'tags' ? (
<TagsView />
) : currentSection === 'colors' ? (
<ColorsView />
) : currentSection === 'rated' ? (
<RatedView />
) : ( ) : (
<Timeline /> <Timeline />
)} )}

View File

@@ -0,0 +1,183 @@
import { useState, useMemo, useCallback } from 'react'
import { Palette, ArrowLeft, Loader2 } from 'lucide-react'
import clsx from 'clsx'
import { photos as photosApi } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { COLOR_LABEL_OPTIONS, type ColorLabel } from '../../constants/colorLabels'
import { useCardGridNav } from '../../hooks/useCardGridNav'
import { Timeline } from '../timeline/Timeline'
import type { Photo } from '../../types/photo'
interface ColorGroup {
label: string
value: ColorLabel | null
className: string
count: number
representative: Photo | null
}
/**
* Colors view — two states:
* 1. Grid of color label cards (default) — arrow keys + Enter to browse
* 2. Detail view showing a color's photos in the full Timeline — Esc to go back
*/
export function ColorsView() {
const { data: allPhotos = [], isLoading } = usePhotosQuery()
const setColorLabel = useFilterStore((s) => s.setColorLabel)
const [selectedGroup, setSelectedGroup] = useState<ColorGroup | null>(null)
const groups = useMemo(() => {
const buckets = new Map<string, Photo[]>()
const uncolored: Photo[] = []
for (const photo of allPhotos) {
if (photo.color_label) {
const arr = buckets.get(photo.color_label) ?? []
arr.push(photo)
buckets.set(photo.color_label, arr)
} else {
uncolored.push(photo)
}
}
const result: ColorGroup[] = []
for (const { value, className } of COLOR_LABEL_OPTIONS) {
const photos = buckets.get(value) ?? []
if (photos.length === 0) continue
result.push({
label: value.charAt(0).toUpperCase() + value.slice(1),
value,
className,
count: photos.length,
representative: photos[0],
})
}
if (uncolored.length > 0) {
result.push({
label: 'Uncolored',
value: null,
className: 'bg-neutral-400',
count: uncolored.length,
representative: uncolored[0],
})
}
return result
}, [allPhotos])
const enterDetail = useCallback(
(group: ColorGroup) => {
setColorLabel((group.value ?? 'none') as ColorLabel)
setSelectedGroup(group)
},
[setColorLabel]
)
const exitDetail = useCallback(() => {
setColorLabel(null)
setSelectedGroup(null)
}, [setColorLabel])
const { activeIndex, gridRef } = useCardGridNav({
items: groups,
inDetail: selectedGroup !== null,
onEnter: enterDetail,
onExit: exitDetail,
})
if (selectedGroup) {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<button
onClick={exitDetail}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Back to colors"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
<span className={`inline-block h-3 w-3 rounded-full ${selectedGroup.className}`} />
<h2 className="text-sm font-semibold text-text">{selectedGroup.label}</h2>
</div>
</div>
<div className="flex-1 overflow-hidden">
<Timeline />
</div>
</div>
)
}
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-text-muted">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Loading colors...
</div>
)
}
if (groups.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
<Palette className="h-12 w-12 opacity-40" />
<p className="text-sm">No color labels assigned yet</p>
<p className="max-w-xs text-center text-xs opacity-70">
Color labels will appear here once you assign them to photos.
</p>
</div>
)
}
return (
<div className="h-full overflow-auto p-4">
<div className="mb-4 flex items-center gap-2 text-text-muted">
<Palette className="h-4 w-4" />
<span className="text-sm font-medium">
{groups.length} {groups.length === 1 ? 'color' : 'colors'}
</span>
</div>
<div
ref={gridRef}
className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3"
>
{groups.map((group, i) => (
<div
key={group.label}
className={clsx(
'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
i === activeIndex
? 'border-primary ring-1 ring-primary/30'
: 'border-border'
)}
onClick={() => enterDetail(group)}
>
<div className="relative aspect-square overflow-hidden bg-surface-2">
{group.representative ? (
<img
src={photosApi.getThumbnailUrl(group.representative.id, 'small')}
alt={group.label}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Palette className="h-10 w-10 text-text-muted/30" />
</div>
)}
<span className="absolute bottom-1.5 right-1.5 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm">
{group.count}
</span>
</div>
<div className="flex items-center gap-1.5 px-2 py-1.5">
<span className={`inline-block h-2.5 w-2.5 rounded-full ${group.className}`} />
<p className="truncate text-xs font-medium text-text">{group.label}</p>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -195,7 +195,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
if (!isOpen) return null if (!isOpen) return null
return ( return (
<div className="fixed inset-0 z-50"> <div className="fixed inset-0 z-[2000]">
<div <div
className="absolute inset-0 bg-black/60 backdrop-blur-sm" className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose} onClick={onClose}

View File

@@ -17,6 +17,7 @@ import {
Pencil, Pencil,
PanelLeftClose, PanelLeftClose,
Settings, Settings,
Users,
} from 'lucide-react' } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import { sourceFolders, photos as photosApi, type FolderTreeNode } from '../../services/api' import { sourceFolders, photos as photosApi, type FolderTreeNode } from '../../services/api'
@@ -63,6 +64,7 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
const navigateToSection = useFilterStore((s) => s.navigateToSection) const navigateToSection = useFilterStore((s) => s.navigateToSection)
const currentSection = useFilterStore((s) => s.currentSection) const currentSection = useFilterStore((s) => s.currentSection)
const { data: allTags = [] } = useTagsQuery() const { data: allTags = [] } = useTagsQuery()
const { data: faceClusters = [] } = useTagsQuery('face_cluster')
const { data: stats } = useLibraryStatsQuery() const { data: stats } = useLibraryStatsQuery()
const [dropTargetId, setDropTargetId] = useState<string | null>(null) const [dropTargetId, setDropTargetId] = useState<string | null>(null)
@@ -255,6 +257,9 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
case 'tags': case 'tags':
navigateToSection('tags', { groupBy: 'tag' }) navigateToSection('tags', { groupBy: 'tag' })
break break
case 'people':
navigateToSection('people', { groupBy: 'tag' })
break
case 'colors': case 'colors':
navigateToSection('colors', { groupBy: 'color' }) navigateToSection('colors', { groupBy: 'color' })
break break
@@ -349,8 +354,10 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
: undefined, : undefined,
}) })
// Total tag count for the badge on the Tags entry. // Total tag count for the badge on the Tags entry (user tags only).
const tagsTotalCount = allTags.reduce((sum, t) => sum + (t.photo_count || 0), 0) const userTags = allTags.filter((t) => t.kind === 'user')
const tagsTotalCount = userTags.reduce((sum, t) => sum + (t.photo_count || 0), 0)
const peopleTotalCount = faceClusters.reduce((sum, t) => sum + (t.photo_count || 0), 0)
const libraryTree: TreeItem[] = [ const libraryTree: TreeItem[] = [
{ {
@@ -361,6 +368,7 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: stats?.all_photos ?? 0 }, { id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: stats?.all_photos ?? 0 },
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: stats?.rated ?? 0 }, { id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: stats?.rated ?? 0 },
{ id: 'tags', label: 'Tags', icon: <TagIcon className="h-4 w-4" />, count: tagsTotalCount }, { id: 'tags', label: 'Tags', icon: <TagIcon className="h-4 w-4" />, count: tagsTotalCount },
{ id: 'people', label: 'People', icon: <Users className="h-4 w-4" />, count: peopleTotalCount },
{ id: 'colors', label: 'Colors', icon: <Palette className="h-4 w-4" />, count: stats?.colored ?? 0 }, { id: 'colors', label: 'Colors', icon: <Palette className="h-4 w-4" />, count: stats?.colored ?? 0 },
{ id: 'map', label: 'Map', icon: <MapPin className="h-4 w-4" />, count: stats?.with_gps ?? 0 }, { id: 'map', label: 'Map', icon: <MapPin className="h-4 w-4" />, count: stats?.with_gps ?? 0 },
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: stats?.duplicates ?? 0 }, { id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: stats?.duplicates ?? 0 },

View File

@@ -0,0 +1,237 @@
import { useState, useCallback } from 'react'
import { Users, Pencil, Check, X, Loader2, ArrowLeft } from 'lucide-react'
import clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTagsQuery } from '../../hooks/useTagsQuery'
import {
tags as tagsApi,
photos as photosApi,
type Tag,
} from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import { useCardGridNav } from '../../hooks/useCardGridNav'
import { Timeline } from '../timeline/Timeline'
import { toast } from '../ToastContainer'
/**
* People view — two states:
* 1. Grid of face cluster cards (default) — arrow keys + Enter to browse
* 2. Detail view showing a person's photos in the full Timeline — Esc to go back
*/
export function PeopleView() {
const { data: clusters = [], isLoading } = useTagsQuery('face_cluster')
const queryClient = useQueryClient()
const setTagIds = useFilterStore((s) => s.setTagIds)
const [selectedPerson, setSelectedPerson] = useState<Tag | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [editName, setEditName] = useState('')
const renameMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
tagsApi.update(id, { name }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tags'] })
setEditingId(null)
if (selectedPerson && editingId === selectedPerson.id) {
setSelectedPerson({ ...selectedPerson, name: editName.trim() })
}
toast.success('Renamed')
},
onError: (e: any) =>
toast.error('Rename failed', e?.response?.data?.detail || e.message),
})
const startEditing = (tag: Tag) => {
setEditingId(tag.id)
setEditName(tag.name)
}
const submitRename = () => {
if (!editingId || !editName.trim()) return
renameMutation.mutate({ id: editingId, name: editName.trim() })
}
const enterDetail = useCallback(
(person: Tag) => {
setTagIds([person.id])
setSelectedPerson(person)
},
[setTagIds]
)
const exitDetail = useCallback(() => {
setTagIds([])
setSelectedPerson(null)
}, [setTagIds])
const { activeIndex, gridRef } = useCardGridNav({
items: clusters,
inDetail: selectedPerson !== null,
onEnter: enterDetail,
onExit: exitDetail,
})
// ── Detail view: a person's photos ─────────────────────────────────
if (selectedPerson) {
const isEditing = editingId === selectedPerson.id
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<button
onClick={exitDetail}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Back to people"
>
<ArrowLeft className="h-4 w-4" />
</button>
{isEditing ? (
<div className="flex items-center gap-1.5">
<input
autoFocus
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submitRename()
if (e.key === 'Escape') setEditingId(null)
}}
className="rounded border border-border bg-bg px-2 py-1 text-sm text-text focus:border-primary focus:outline-none"
/>
<button onClick={submitRename} className="rounded p-1 text-green-500 hover:bg-green-500/10">
<Check className="h-4 w-4" />
</button>
<button onClick={() => setEditingId(null)} className="rounded p-1 text-text-muted hover:bg-surface-2">
<X className="h-4 w-4" />
</button>
</div>
) : (
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-text">{selectedPerson.name}</h2>
<button
onClick={() => startEditing(selectedPerson)}
className="rounded p-0.5 text-text-muted transition-colors hover:text-text"
title="Rename"
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
<div className="flex-1 overflow-hidden">
<Timeline />
</div>
</div>
)
}
// ── Card grid ──────────────────────────────────────────────────────
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-text-muted">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Loading people...
</div>
)
}
if (clusters.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
<Users className="h-12 w-12 opacity-40" />
<p className="text-sm">No people identified yet</p>
<p className="max-w-xs text-center text-xs opacity-70">
Face detection runs automatically when photos are scanned.
People will appear here once faces are found and clustered.
</p>
</div>
)
}
return (
<div className="h-full overflow-auto p-4">
<div className="mb-4 flex items-center gap-2 text-text-muted">
<Users className="h-4 w-4" />
<span className="text-sm font-medium">
{clusters.length} {clusters.length === 1 ? 'person' : 'people'} identified
</span>
</div>
<div
ref={gridRef}
className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3"
>
{clusters.map((tag, i) => (
<div
key={tag.id}
className={clsx(
'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
i === activeIndex
? 'border-primary ring-1 ring-primary/30'
: editingId === tag.id
? 'border-primary ring-1 ring-primary/30'
: 'border-border'
)}
onClick={() => {
if (editingId !== tag.id) enterDetail(tag)
}}
>
<div className="relative aspect-square overflow-hidden bg-surface-2">
{tag.representative_photo_id ? (
<img
src={photosApi.getThumbnailUrl(tag.representative_photo_id, 'small')}
alt={tag.name}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Users className="h-10 w-10 text-text-muted/30" />
</div>
)}
<span className="absolute bottom-1.5 right-1.5 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm">
{tag.photo_count}
</span>
<button
className="absolute right-1.5 top-1.5 rounded-full bg-black/50 p-1 text-white opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation()
startEditing(tag)
}}
title="Rename"
>
<Pencil className="h-3 w-3" />
</button>
</div>
<div className="px-2 py-1.5">
{editingId === tag.id ? (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<input
autoFocus
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submitRename()
if (e.key === 'Escape') setEditingId(null)
}}
className="min-w-0 flex-1 rounded border border-border bg-bg px-1.5 py-0.5 text-xs text-text focus:border-primary focus:outline-none"
/>
<button onClick={submitRename} className="rounded p-0.5 text-green-500 hover:bg-green-500/10">
<Check className="h-3 w-3" />
</button>
<button onClick={() => setEditingId(null)} className="rounded p-0.5 text-text-muted hover:bg-surface-2">
<X className="h-3 w-3" />
</button>
</div>
) : (
<p className="truncate text-xs font-medium text-text">{tag.name}</p>
)}
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,170 @@
import { useState, useMemo, useCallback } from 'react'
import { Star, ArrowLeft, Loader2 } from 'lucide-react'
import clsx from 'clsx'
import { photos as photosApi } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { useCardGridNav } from '../../hooks/useCardGridNav'
import { Timeline } from '../timeline/Timeline'
import type { Photo } from '../../types/photo'
interface RatingGroup {
rating: number
label: string
count: number
representative: Photo | null
}
/**
* Rated view — two states:
* 1. Grid of rating-level cards (default) — arrow keys + Enter to browse
* 2. Detail view showing a rating level's photos in the full Timeline — Esc to go back
*/
export function RatedView() {
const { data: allPhotos = [], isLoading } = usePhotosQuery()
const setRatingMin = useFilterStore((s) => s.setRatingMin)
const setRatingMax = useFilterStore((s) => s.setRatingMax)
const [selectedGroup, setSelectedGroup] = useState<RatingGroup | null>(null)
const groups = useMemo(() => {
const buckets = new Map<number, Photo[]>()
for (const photo of allPhotos) {
if (photo.rating > 0) {
const arr = buckets.get(photo.rating) ?? []
arr.push(photo)
buckets.set(photo.rating, arr)
}
}
// Highest rating first
const result: RatingGroup[] = []
for (let r = 5; r >= 1; r--) {
const photos = buckets.get(r) ?? []
if (photos.length === 0) continue
result.push({
rating: r,
label: '★'.repeat(r),
count: photos.length,
representative: photos[0],
})
}
return result
}, [allPhotos])
const enterDetail = useCallback(
(group: RatingGroup) => {
setRatingMin(group.rating)
setRatingMax(group.rating)
setSelectedGroup(group)
},
[setRatingMin, setRatingMax]
)
const exitDetail = useCallback(() => {
// Restore the section preset: ratingMin=1 (all rated), no max
setRatingMin(1)
setRatingMax(0)
setSelectedGroup(null)
}, [setRatingMin, setRatingMax])
const { activeIndex, gridRef } = useCardGridNav({
items: groups,
inDetail: selectedGroup !== null,
onEnter: enterDetail,
onExit: exitDetail,
})
if (selectedGroup) {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<button
onClick={exitDetail}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Back to ratings"
>
<ArrowLeft className="h-4 w-4" />
</button>
<h2 className="text-sm font-semibold text-amber-400">{selectedGroup.label}</h2>
</div>
<div className="flex-1 overflow-hidden">
<Timeline />
</div>
</div>
)
}
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-text-muted">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Loading ratings...
</div>
)
}
if (groups.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
<Star className="h-12 w-12 opacity-40" />
<p className="text-sm">No rated photos yet</p>
<p className="max-w-xs text-center text-xs opacity-70">
Rate photos with 15 stars and they will appear here grouped by
rating.
</p>
</div>
)
}
return (
<div className="h-full overflow-auto p-4">
<div className="mb-4 flex items-center gap-2 text-text-muted">
<Star className="h-4 w-4" />
<span className="text-sm font-medium">
{groups.length} rating {groups.length === 1 ? 'level' : 'levels'}
</span>
</div>
<div
ref={gridRef}
className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3"
>
{groups.map((group, i) => (
<div
key={group.rating}
className={clsx(
'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
i === activeIndex
? 'border-primary ring-1 ring-primary/30'
: 'border-border'
)}
onClick={() => enterDetail(group)}
>
<div className="relative aspect-square overflow-hidden bg-surface-2">
{group.representative ? (
<img
src={photosApi.getThumbnailUrl(group.representative.id, 'small')}
alt={group.label}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Star className="h-10 w-10 text-text-muted/30" />
</div>
)}
<span className="absolute bottom-1.5 right-1.5 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm">
{group.count}
</span>
</div>
<div className="px-2 py-1.5">
<p className="truncate text-xs font-medium text-amber-400">{group.label}</p>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,138 @@
import { useState, useMemo, useCallback } from 'react'
import { Tag as TagIcon, ArrowLeft, Loader2 } from 'lucide-react'
import clsx from 'clsx'
import { useTagsQuery } from '../../hooks/useTagsQuery'
import { photos as photosApi, type Tag } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import { useCardGridNav } from '../../hooks/useCardGridNav'
import { Timeline } from '../timeline/Timeline'
/**
* Tags view — two states:
* 1. Grid of tag cards (default) — arrow keys + Enter to browse
* 2. Detail view showing a tag's photos in the full Timeline — Esc to go back
*/
export function TagsView() {
const { data: allTags = [], isLoading } = useTagsQuery()
const setTagIds = useFilterStore((s) => s.setTagIds)
const [selectedTag, setSelectedTag] = useState<Tag | null>(null)
// Exclude face_cluster tags (those live in PeopleView)
const tags = useMemo(
() => allTags.filter((t) => t.kind !== 'face_cluster'),
[allTags]
)
const enterDetail = useCallback(
(tag: Tag) => {
setTagIds([tag.id])
setSelectedTag(tag)
},
[setTagIds]
)
const exitDetail = useCallback(() => {
setTagIds([])
setSelectedTag(null)
}, [setTagIds])
const { activeIndex, gridRef } = useCardGridNav({
items: tags,
inDetail: selectedTag !== null,
onEnter: enterDetail,
onExit: exitDetail,
})
if (selectedTag) {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<button
onClick={exitDetail}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Back to tags"
>
<ArrowLeft className="h-4 w-4" />
</button>
<h2 className="text-sm font-semibold text-text">{selectedTag.name}</h2>
</div>
<div className="flex-1 overflow-hidden">
<Timeline />
</div>
</div>
)
}
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-text-muted">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Loading tags...
</div>
)
}
if (tags.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-text-muted">
<TagIcon className="h-12 w-12 opacity-40" />
<p className="text-sm">No tags yet</p>
<p className="max-w-xs text-center text-xs opacity-70">
Tags will appear here once photos are tagged either manually or by
the auto-tagger.
</p>
</div>
)
}
return (
<div className="h-full overflow-auto p-4">
<div className="mb-4 flex items-center gap-2 text-text-muted">
<TagIcon className="h-4 w-4" />
<span className="text-sm font-medium">
{tags.length} {tags.length === 1 ? 'tag' : 'tags'}
</span>
</div>
<div
ref={gridRef}
className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3"
>
{tags.map((tag, i) => (
<div
key={tag.id}
className={clsx(
'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
i === activeIndex
? 'border-primary ring-1 ring-primary/30'
: 'border-border'
)}
onClick={() => enterDetail(tag)}
>
<div className="relative aspect-square overflow-hidden bg-surface-2">
{tag.representative_photo_id ? (
<img
src={photosApi.getThumbnailUrl(tag.representative_photo_id, 'small')}
alt={tag.name}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<TagIcon className="h-10 w-10 text-text-muted/30" />
</div>
)}
<span className="absolute bottom-1.5 right-1.5 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm">
{tag.photo_count}
</span>
</div>
<div className="px-2 py-1.5">
<p className="truncate text-xs font-medium text-text">{tag.name}</p>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -6,7 +6,6 @@ import { useFilterStore } from '../../store/filterStore'
import { PhotoThumbnail } from './PhotoThumbnail' import { PhotoThumbnail } from './PhotoThumbnail'
import { usePhotosQuery } from '../../hooks/usePhotosQuery' import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery' import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
import type { Photo } from '../../types/photo' import type { Photo } from '../../types/photo'
// Layout constants for the grid + grouped headers. // Layout constants for the grid + grouped headers.
@@ -27,22 +26,19 @@ type TimelineItem =
/** /**
* Build the flat header|row item array the virtualizer renders. * Build the flat header|row item array the virtualizer renders.
* *
* Five modes: * Two modes:
* - groupBy='tag': one bucket per unique tag (plus an "Untagged" bucket * - sortBy is a date field: month buckets.
* for photos with no tags). A photo with N tags appears in N buckets.
* - groupBy='rating': one bucket per star rating 5..1 (plus "Unrated"
* for rating 0). Each photo lands in exactly one bucket.
* - groupBy='color': one bucket per color label, in canonical order
* (plus an "Uncolored" bucket for photos with no label).
* - groupBy='date' AND sortBy is a date field: month buckets (existing).
* - otherwise: one un-headered stream. * - otherwise: one un-headered stream.
*
* Tag, rating, and color grouping now live in their own dedicated views
* (TagsView, RatedView, ColorsView) instead of being handled here.
*/ */
function buildItems( function buildItems(
photos: Photo[], photos: Photo[],
columns: number, columns: number,
rowHeight: number, rowHeight: number,
sortBy: string, sortBy: string,
groupBy: 'date' | 'tag' | 'rating' | 'color' groupBy: string,
): TimelineItem[] { ): TimelineItem[] {
if (photos.length === 0) return [] if (photos.length === 0) return []
@@ -61,155 +57,13 @@ function buildItems(
} }
} }
// ── Tag grouping ────────────────────────────────────────────────────── // Date grouping only applies when groupBy is explicitly 'date' and
if (groupBy === 'tag') { // the sort field is a date column. Other sections (tags, colors,
// Bucket by tag name. A photo with multiple tags lands in multiple // rated, people) reuse Timeline for their detail views and should
// buckets. Photos with no tags go into "Untagged". // render a flat grid without month headers.
const tagBuckets = new Map<string, PhotoCell[]>()
const untagged: PhotoCell[] = []
photos.forEach((photo, globalIndex) => {
const cell: PhotoCell = { photo, globalIndex }
const tags = photo.tags ?? []
if (tags.length === 0) {
untagged.push(cell)
} else {
for (const t of tags) {
const arr = tagBuckets.get(t.name) ?? []
arr.push(cell)
tagBuckets.set(t.name, arr)
}
}
})
// Sort tag groups alphabetically; Untagged goes at the end.
const sortedTagNames = Array.from(tagBuckets.keys()).sort((a, b) =>
a.localeCompare(b)
)
let bucketIndex = 0
for (const name of sortedTagNames) {
items.push({
type: 'header',
key: `tag::${bucketIndex}::${name}`,
label: name,
height: HEADER_HEIGHT,
})
pushRowsForGroup(`tag::${bucketIndex}::${name}`, tagBuckets.get(name)!)
bucketIndex++
}
if (untagged.length > 0) {
items.push({
type: 'header',
key: `tag::${bucketIndex}::__untagged`,
label: 'Untagged',
height: HEADER_HEIGHT,
})
pushRowsForGroup(`tag::${bucketIndex}::untagged`, untagged)
}
return items
}
// ── Rating grouping ───────────────────────────────────────────────────
if (groupBy === 'rating') {
// Bucket by star rating. Each photo lands in exactly one bucket;
// rating 0 goes into "Unrated".
const ratingBuckets = new Map<number, PhotoCell[]>()
const unrated: PhotoCell[] = []
photos.forEach((photo, globalIndex) => {
const cell: PhotoCell = { photo, globalIndex }
if (photo.rating > 0) {
const arr = ratingBuckets.get(photo.rating) ?? []
arr.push(cell)
ratingBuckets.set(photo.rating, arr)
} else {
unrated.push(cell)
}
})
// Highest rating first; Unrated goes at the end.
const sortedRatings = Array.from(ratingBuckets.keys()).sort((a, b) => b - a)
let bucketIndex = 0
for (const rating of sortedRatings) {
items.push({
type: 'header',
key: `rating::${bucketIndex}::${rating}`,
label: '★'.repeat(rating),
height: HEADER_HEIGHT,
})
pushRowsForGroup(
`rating::${bucketIndex}::${rating}`,
ratingBuckets.get(rating)!
)
bucketIndex++
}
if (unrated.length > 0) {
items.push({
type: 'header',
key: `rating::${bucketIndex}::__unrated`,
label: 'Unrated',
height: HEADER_HEIGHT,
})
pushRowsForGroup(`rating::${bucketIndex}::unrated`, unrated)
}
return items
}
// ── Color label grouping ──────────────────────────────────────────────
if (groupBy === 'color') {
// Bucket by color_label. Each photo lands in exactly one bucket;
// photos with no label go into "Uncolored".
const colorBuckets = new Map<string, PhotoCell[]>()
const uncolored: PhotoCell[] = []
photos.forEach((photo, globalIndex) => {
const cell: PhotoCell = { photo, globalIndex }
const label = photo.color_label
if (label) {
const arr = colorBuckets.get(label) ?? []
arr.push(cell)
colorBuckets.set(label, arr)
} else {
uncolored.push(cell)
}
})
// Walk the canonical color order so headers always read R-O-Y-G-B-P,
// matching every other color UI in the app. Skip empty buckets and
// ignore any unexpected label values that aren't in the canonical
// list (they'd be invalid backend state).
let bucketIndex = 0
for (const { value } of COLOR_LABEL_OPTIONS) {
const cells = colorBuckets.get(value)
if (!cells || cells.length === 0) continue
const label = value.charAt(0).toUpperCase() + value.slice(1)
items.push({
type: 'header',
key: `color::${bucketIndex}::${value}`,
label,
height: HEADER_HEIGHT,
})
pushRowsForGroup(`color::${bucketIndex}::${value}`, cells)
bucketIndex++
}
if (uncolored.length > 0) {
items.push({
type: 'header',
key: `color::${bucketIndex}::__uncolored`,
label: 'Uncolored',
height: HEADER_HEIGHT,
})
pushRowsForGroup(`color::${bucketIndex}::uncolored`, uncolored)
}
return items
}
// ── Date grouping (existing) ──────────────────────────────────────────
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at' const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
if (!isDateSort) { if (!isDateSort || groupBy !== 'date') {
// No grouping — one row stream. // No grouping — one row stream.
const cells: PhotoCell[] = photos.map((photo, globalIndex) => ({ const cells: PhotoCell[] = photos.map((photo, globalIndex) => ({
photo, photo,
@@ -344,8 +198,7 @@ export function Timeline() {
const activeHeapName = activeHeap?.name ?? null const activeHeapName = activeHeap?.name ?? null
// Build the flat virtualizer items: a mix of group headers and rows of // Build the flat virtualizer items: a mix of group headers and rows of
// photos. Date headers appear when sorted by a date field; tag headers // photos. Date headers appear only in the main timeline (groupBy='date').
// appear when groupBy === 'tag' (overrides date grouping).
const items = useMemo( const items = useMemo(
() => buildItems(photos, columns, cellSize, sortBy, groupBy), () => buildItems(photos, columns, cellSize, sortBy, groupBy),
[photos, columns, cellSize, sortBy, groupBy] [photos, columns, cellSize, sortBy, groupBy]

View File

@@ -0,0 +1,115 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { usePhotoStore } from '../store/photoStore'
/**
* Keyboard navigation for card grids (tags, colors, ratings, people).
*
* Arrow keys move the active index through the grid (wrapping at row
* boundaries based on the actual CSS column count), Enter opens the
* selected card, and Escape / Backspace exits the detail view.
*
* In detail mode, Escape only exits back to the card grid when the
* preview is closed and no photos are selected — otherwise it defers
* to Timeline's own Escape handler (clear selection / close preview).
*
* The grid container ref is used to measure the rendered column count
* so up/down navigation stays column-aligned.
*/
export function useCardGridNav<T>(opts: {
items: T[]
/** True when the detail view is showing (disables grid nav, enables Esc) */
inDetail: boolean
onEnter: (item: T, index: number) => void
onExit: () => void
}) {
const { items, inDetail, onEnter, onExit } = opts
const [activeIndex, setActiveIndex] = useState(0)
const gridRef = useRef<HTMLDivElement>(null)
const viewMode = usePhotoStore((s) => s.viewMode)
const selectedPhotos = usePhotoStore((s) => s.selectedPhotos)
// Clamp active index when the item list shrinks
useEffect(() => {
if (items.length > 0 && activeIndex >= items.length) {
setActiveIndex(items.length - 1)
}
}, [items.length, activeIndex])
// Measure column count from the grid container
const getColumns = useCallback(() => {
const el = gridRef.current
if (!el) return 1
return getComputedStyle(el).gridTemplateColumns.split(' ').length
}, [])
// Scroll the active card into view
const scrollIntoView = useCallback((index: number) => {
const el = gridRef.current
if (!el) return
const card = el.children[index] as HTMLElement | undefined
card?.scrollIntoView({ block: 'nearest' })
}, [])
useEffect(() => {
if (items.length === 0) return
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return
// Detail view: Escape or Backspace exits back to card grid, but
// only when the preview is closed and no photos are selected —
// otherwise defer to Timeline's own Escape handler.
if (inDetail) {
if (e.key === 'Backspace') {
e.preventDefault()
onExit()
} else if (e.key === 'Escape' && viewMode === 'grid' && selectedPhotos.length === 0) {
e.preventDefault()
onExit()
}
return
}
// Card grid navigation
const cols = getColumns()
const count = items.length
let next = activeIndex
switch (e.key) {
case 'ArrowRight':
e.preventDefault()
next = Math.min(activeIndex + 1, count - 1)
break
case 'ArrowLeft':
e.preventDefault()
next = Math.max(activeIndex - 1, 0)
break
case 'ArrowDown':
e.preventDefault()
next = Math.min(activeIndex + cols, count - 1)
break
case 'ArrowUp':
e.preventDefault()
next = Math.max(activeIndex - cols, 0)
break
case 'Enter':
e.preventDefault()
if (items[activeIndex]) onEnter(items[activeIndex], activeIndex)
return
default:
return
}
if (next !== activeIndex) {
setActiveIndex(next)
scrollIntoView(next)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [items, activeIndex, inDetail, onEnter, onExit, getColumns, scrollIntoView, viewMode, selectedPhotos])
return { activeIndex, setActiveIndex, gridRef }
}

View File

@@ -60,6 +60,12 @@ function parseUrl(): HydratePayload {
if (Number.isFinite(n) && n >= 0 && n <= 5) out.ratingMin = n if (Number.isFinite(n) && n >= 0 && n <= 5) out.ratingMin = n
} }
const rx = sp.get('rating_max')
if (rx) {
const n = parseInt(rx, 10)
if (Number.isFinite(n) && n >= 0 && n <= 5) out.ratingMax = n
}
const cl = sp.get('color_label') const cl = sp.get('color_label')
if (cl && ALLOWED_COLORS.includes(cl as ColorLabel)) { if (cl && ALLOWED_COLORS.includes(cl as ColorLabel)) {
out.colorLabel = cl as ColorLabel out.colorLabel = cl as ColorLabel
@@ -110,6 +116,7 @@ function writeUrl(f: FilterState & { currentSection?: string }) {
if (f.dateTo) sp.set('date_to', f.dateTo) if (f.dateTo) sp.set('date_to', f.dateTo)
if (f.mediaTypes.length > 0) sp.set('media_type', f.mediaTypes.join(',')) if (f.mediaTypes.length > 0) sp.set('media_type', f.mediaTypes.join(','))
if (f.ratingMin > 0) sp.set('rating_min', String(f.ratingMin)) if (f.ratingMin > 0) sp.set('rating_min', String(f.ratingMin))
if (f.ratingMax > 0) sp.set('rating_max', String(f.ratingMax))
if (f.colorLabel) sp.set('color_label', f.colorLabel) if (f.colorLabel) sp.set('color_label', f.colorLabel)
if (f.flag !== 'any') sp.set('flag', f.flag) if (f.flag !== 'any') sp.set('flag', f.flag)
if (f.heapId) sp.set('heap_id', f.heapId) if (f.heapId) sp.set('heap_id', f.heapId)

View File

@@ -32,6 +32,7 @@ export function usePhotosQuery() {
const dateTo = useFilterStore((s) => s.dateTo) const dateTo = useFilterStore((s) => s.dateTo)
const mediaTypes = useFilterStore((s) => s.mediaTypes) const mediaTypes = useFilterStore((s) => s.mediaTypes)
const ratingMin = useFilterStore((s) => s.ratingMin) const ratingMin = useFilterStore((s) => s.ratingMin)
const ratingMax = useFilterStore((s) => s.ratingMax)
const colorLabel = useFilterStore((s) => s.colorLabel) const colorLabel = useFilterStore((s) => s.colorLabel)
const flag = useFilterStore((s) => s.flag) const flag = useFilterStore((s) => s.flag)
const heapId = useFilterStore((s) => s.heapId) const heapId = useFilterStore((s) => s.heapId)
@@ -50,6 +51,7 @@ export function usePhotosQuery() {
dateTo, dateTo,
mediaTypes, mediaTypes,
ratingMin, ratingMin,
ratingMax,
colorLabel, colorLabel,
flag, flag,
heapId, heapId,
@@ -60,7 +62,7 @@ export function usePhotosQuery() {
sortBy, sortBy,
sortOrder, sortOrder,
}), }),
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, tagIds, duplicates, groupBy, sortBy, sortOrder] [q, dateFrom, dateTo, mediaTypes, ratingMin, ratingMax, colorLabel, flag, heapId, folderId, tagIds, duplicates, groupBy, sortBy, sortOrder]
) )
const queryClient = useQueryClient() const queryClient = useQueryClient()

View File

@@ -0,0 +1,37 @@
import { useQuery } from '@tanstack/react-query'
import { search, type SearchResult } from '../services/api'
import { useFilterStore } from '../store/filterStore'
/**
* Hybrid search hook — fires POST /photos/search when the user has a
* non-empty search query. Returns results ranked by RRF (FTS + semantic).
*
* When `q` is empty, this hook is disabled and returns no data — the
* normal usePhotosQuery takes over for browse mode.
*/
export function useSearchQuery() {
const q = useFilterStore((s) => s.q)
const tagIds = useFilterStore((s) => s.tagIds)
const dateFrom = useFilterStore((s) => s.dateFrom)
const dateTo = useFilterStore((s) => s.dateTo)
const hasQuery = q.trim().length > 0
return useQuery<SearchResult[]>({
queryKey: ['search', q, tagIds, dateFrom, dateTo],
queryFn: async () => {
const resp = await search.query({
q: q.trim(),
filters: {
tag_ids: tagIds.length > 0 ? tagIds : undefined,
date_from: dateFrom ?? undefined,
date_to: dateTo ?? undefined,
},
limit: 200,
})
return resp.results
},
enabled: hasQuery,
staleTime: 30_000,
})
}

View File

@@ -1,12 +1,12 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { tags as tagsApi, type Tag } from '../services/api' import { tags as tagsApi, type Tag, type TagKind } from '../services/api'
export const TAGS_QUERY_KEY = ['tags'] as const export const TAGS_QUERY_KEY = ['tags'] as const
export function useTagsQuery() { export function useTagsQuery(kind?: TagKind) {
return useQuery<Tag[]>({ return useQuery<Tag[]>({
queryKey: TAGS_QUERY_KEY, queryKey: kind ? ['tags', kind] : TAGS_QUERY_KEY,
queryFn: tagsApi.list, queryFn: () => tagsApi.list(kind),
staleTime: 30_000, staleTime: 30_000,
}) })
} }

View File

@@ -519,21 +519,27 @@ export const heaps = {
} }
// Tags API // Tags API
export type TagKind = 'user' | 'object' | 'scene' | 'face_cluster'
export interface Tag { export interface Tag {
id: string id: string
name: string name: string
color: string | null color: string | null
kind: TagKind
source: string | null
representative_photo_id: string | null
photo_count: number photo_count: number
} }
export const tags = { export const tags = {
list: async (): Promise<Tag[]> => { list: async (kind?: TagKind): Promise<Tag[]> => {
const response = await api.get('/tags') const params = kind ? { kind } : undefined
const response = await api.get('/tags', { params })
return response.data return response.data
}, },
create: async (name: string, color?: string): Promise<Tag> => { create: async (name: string, color?: string, kind: TagKind = 'user'): Promise<Tag> => {
const response = await api.post('/tags', { name, color }) const response = await api.post('/tags', { name, color, kind })
return response.data return response.data
}, },
@@ -546,6 +552,11 @@ export const tags = {
await api.delete(`/tags/${tagId}`) await api.delete(`/tags/${tagId}`)
}, },
merge: async (sourceId: string, targetId: string): Promise<{ merged_into: string; target_name: string }> => {
const response = await api.post(`/tags/${sourceId}/merge`, { target_id: targetId })
return response.data
},
/** Add one or more tags to a photo. */ /** Add one or more tags to a photo. */
addToPhoto: async (photoId: string, tagIds: string[]) => { addToPhoto: async (photoId: string, tagIds: string[]) => {
const response = await api.post(`/photos/${photoId}/tags`, { tag_ids: tagIds }) const response = await api.post(`/photos/${photoId}/tags`, { tag_ids: tagIds })
@@ -558,6 +569,38 @@ export const tags = {
}, },
} }
// Search API — hybrid FTS + semantic search
export interface SearchResult {
id: string
filename: string
filepath: string
media_type: string
width: number
height: number
taken_at: string | null
rating: number
color_label: string | null
thumb_small: string
thumb_medium: string
score: number
}
export const search = {
query: async (params: {
q?: string
filters?: {
tag_ids?: string[]
date_from?: string
date_to?: string
}
limit?: number
offset?: number
}): Promise<{ results: SearchResult[]; total: number }> => {
const response = await api.post('/photos/search', params)
return response.data
},
}
// Discard API // Discard API
export const discard = { export const discard = {
list: async () => { list: async () => {

View File

@@ -19,6 +19,7 @@ export interface FilterState {
dateTo: string | null dateTo: string | null
mediaTypes: MediaType[] mediaTypes: MediaType[]
ratingMin: number // 0-5; 0 means no filter ratingMin: number // 0-5; 0 means no filter
ratingMax: number // 0-5; 0 means no filter
colorLabel: ColorLabel | null colorLabel: ColorLabel | null
flag: FlagFilter flag: FlagFilter
/** When set, restrict to photos in this heap. Independent of `activeHeapId` /** When set, restrict to photos in this heap. Independent of `activeHeapId`
@@ -59,6 +60,7 @@ interface FilterStore extends FilterState {
setDateTo: (date: string | null) => void setDateTo: (date: string | null) => void
toggleMediaType: (t: MediaType) => void toggleMediaType: (t: MediaType) => void
setRatingMin: (rating: number) => void setRatingMin: (rating: number) => void
setRatingMax: (rating: number) => void
setColorLabel: (label: ColorLabel | null) => void setColorLabel: (label: ColorLabel | null) => void
setFlag: (flag: FlagFilter) => void setFlag: (flag: FlagFilter) => void
setHeapId: (id: string | null) => void setHeapId: (id: string | null) => void
@@ -93,6 +95,7 @@ export const INITIAL_FILTERS: FilterState = {
dateTo: null, dateTo: null,
mediaTypes: [], mediaTypes: [],
ratingMin: 0, ratingMin: 0,
ratingMax: 0,
colorLabel: null, colorLabel: null,
flag: 'any', flag: 'any',
heapId: null, heapId: null,
@@ -114,6 +117,7 @@ function snapshotFilters(s: FilterState): FilterState {
dateTo: s.dateTo, dateTo: s.dateTo,
mediaTypes: [...s.mediaTypes], mediaTypes: [...s.mediaTypes],
ratingMin: s.ratingMin, ratingMin: s.ratingMin,
ratingMax: s.ratingMax,
colorLabel: s.colorLabel, colorLabel: s.colorLabel,
flag: s.flag, flag: s.flag,
heapId: s.heapId, heapId: s.heapId,
@@ -142,6 +146,7 @@ export const useFilterStore = create<FilterStore>((set) => ({
: [...s.mediaTypes, t], : [...s.mediaTypes, t],
})), })),
setRatingMin: (ratingMin) => set({ ratingMin }), setRatingMin: (ratingMin) => set({ ratingMin }),
setRatingMax: (ratingMax) => set({ ratingMax }),
setColorLabel: (colorLabel) => set({ colorLabel }), setColorLabel: (colorLabel) => set({ colorLabel }),
setFlag: (flag) => set({ flag }), setFlag: (flag) => set({ flag }),
setHeapId: (heapId) => set({ heapId }), setHeapId: (heapId) => set({ heapId }),
@@ -205,6 +210,7 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
if (f.dateTo) params.date_to = f.dateTo if (f.dateTo) params.date_to = f.dateTo
if (f.mediaTypes.length > 0) params.media_type = f.mediaTypes.join(',') if (f.mediaTypes.length > 0) params.media_type = f.mediaTypes.join(',')
if (f.ratingMin > 0) params.rating_min = f.ratingMin if (f.ratingMin > 0) params.rating_min = f.ratingMin
if (f.ratingMax > 0) params.rating_max = f.ratingMax
if (f.colorLabel) params.color_label = f.colorLabel if (f.colorLabel) params.color_label = f.colorLabel
if (f.flag === 'discarded') params.is_discarded = 'true' if (f.flag === 'discarded') params.is_discarded = 'true'
if (f.heapId) params.heap_id = f.heapId if (f.heapId) params.heap_id = f.heapId
@@ -224,6 +230,7 @@ export function hasActiveFilters(f: FilterState): boolean {
f.dateTo !== null || f.dateTo !== null ||
f.mediaTypes.length > 0 || f.mediaTypes.length > 0 ||
f.ratingMin > 0 || f.ratingMin > 0 ||
f.ratingMax > 0 ||
f.colorLabel !== null || f.colorLabel !== null ||
f.flag !== 'any' || f.flag !== 'any' ||
f.heapId !== null || f.heapId !== null ||

View File

@@ -22,3 +22,31 @@ performance:
cache_ttl: 3600 cache_ttl: 3600
db_pool_size: 20 db_pool_size: 20
db_pool_recycle: 3600 db_pool_recycle: 3600
# AI vision pipeline — embedding, OCR, object detection, face recognition.
# Runs on the dedicated `vision` Celery queue (PR4+). Set enabled: false
# to disable all vision processing.
vision:
enabled: true
backend: onnx # "onnx" (CPU) | "rocm" (future GPU)
models_dir: /data/models
embedder:
name: openclip_vitb32
batch_size: 8
ocr:
enabled: true
languages: [en]
min_confidence: 0.5
detector:
enabled: true
min_confidence: 0.35
max_detections: 50
faces:
enabled: true
min_face_size: 40
recognition_threshold: 0.65
cluster_eps: 0.5
classifier:
enabled: true
min_confidence: 0.3
worker_concurrency: 2