refactor: strip AI pipeline to binary photo/other classifier

Drops face recognition, OCR, object detection, and semantic embeddings.
The sole remaining vision task is a CLIP-based binary classifier
(photography vs other); photos in "other" get needs_review=true so
screenshots, documents, memes and scans can be triaged from a new
filter pill in the UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 22:27:17 +02:00
parent 5c531f11da
commit 574d71371f
50 changed files with 700 additions and 3068 deletions

View File

@@ -6,9 +6,6 @@ from app.models.photos import Photo
from app.models.folders import Folder, SourceRoot
from app.models.tags import Tag, PhotoTag
from app.models.heaps import Heap, HeapPhoto
from app.models.embeddings import Embedding
from app.models.ocr_text import OCRText
from app.models.face_embedding import FaceEmbedding
from app.models.sharing import HeapShare, FolderShare
__all__ = [
@@ -20,9 +17,6 @@ __all__ = [
'PhotoTag',
'Heap',
'HeapPhoto',
'Embedding',
'OCRText',
'FaceEmbedding',
'HeapShare',
'FolderShare',
]
]

View File

@@ -1,24 +0,0 @@
"""
Embedding model — stores CLIP/SigLIP image embeddings via pgvector.
Composite PK (photo_id, model) allows re-embedding with newer models
without clobbering old vectors.
Vector dimension is 768 to support SigLIP2 ViT-B/16 (the default).
OpenCLIP ViT-B/32 (512-d) embeddings are zero-padded on insert so
both models coexist in the same column. The padding is invisible to
cosine similarity (zeros don't affect the angle).
"""
from sqlalchemy import Column, String, ForeignKey, DateTime, func
from pgvector.sqlalchemy import Vector
from app.database import Base
class Embedding(Base):
__tablename__ = 'embeddings'
photo_id = Column(String, ForeignKey('photos.id', ondelete='CASCADE'), primary_key=True)
model = Column(String(64), primary_key=True) # e.g. 'siglip2_vitb16'
vector = Column(Vector(512)) # OpenCLIP ViT-B/32 → 512-d
created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -1,24 +0,0 @@
"""
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

@@ -1,20 +0,0 @@
"""
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

@@ -50,6 +50,11 @@ class Photo(Base):
# rows, and POST /folders/{id}/hide recomputes it on toggle.
is_hidden = Column(Boolean, nullable=False, default=False, server_default='false', index=True)
# "Needs review" — set by the content classifier when a photo is
# classified as 'other' (screenshot, document, meme, scan, etc.) so
# the user can page through non-photographs in the UI and triage them.
needs_review = Column(Boolean, nullable=False, default=False, server_default='false', index=True)
# "Capture date is probably wrong" — denormalized from the folder/filename
# date-guesser. Set at scan time and recomputed on every taken_at edit so
# the filter bar can query it directly. See services/date_guess.py for
@@ -118,4 +123,5 @@ class Photo(Base):
Index('ix_photos_media_type', 'media_type'),
Index('ix_photos_processing_status', 'processing_status'),
Index('ix_photos_lat_lon', 'latitude', 'longitude'),
Index('ix_photos_needs_review', 'needs_review'),
)

View File

@@ -6,7 +6,7 @@ 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, Float, ForeignKey, Table, Index, UniqueConstraint
from sqlalchemy import Column, String, Float, ForeignKey, Table, Index, UniqueConstraint # noqa: F401
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import JSONB
import uuid
@@ -22,7 +22,7 @@ photo_tags = Table(
# 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"
Column('source', String, nullable=True), # e.g. "vision:clip_classifier"
Index('ix_photo_tags_photo_id', 'photo_id'),
Index('ix_photo_tags_tag_id', 'tag_id'),
)
@@ -42,16 +42,11 @@ class Tag(Base):
# Tag classification
kind = Column(String, nullable=False, default='user', index=True)
# kind values: 'user' | 'object' | 'scene' | 'face_cluster'
# kind values: 'user' | 'content_type'
# 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
)
# e.g. "vision:clip_classifier", null
# Relationships
photos = relationship("Photo", secondary=photo_tags, backref="tags")