Skip to content
Open
16 changes: 6 additions & 10 deletions backend/app/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,9 @@ def _get_env_int(
# bare-noun queries score low in absolute terms; 0.02 measured to cut true positives.
SIGLIP2_MATCH_THRESHOLD = _get_env_float("SIGLIP2_MATCH_THRESHOLD", 0.01, min_value=0.0)

# Curated-vocabulary pre-scoring. Ensembled label vectors score ~2 orders of
# magnitude below live queries, so these floors are NOT comparable to
# SIGLIP2_MATCH_THRESHOLD. Calibrated against a 151-image stratified eval set;
# see backend/scripts/vocabulary/calibration_report.json.
# Ensembled label vectors score ~2 orders of magnitude below live queries, so
# these floors are NOT comparable to SIGLIP2_MATCH_THRESHOLD. Calibrated against
# a 151-image eval set; see backend/scripts/vocabulary/calibration_report.json.
SEMANTIC_SCORE_TOP_K = _get_env_int("SEMANTIC_SCORE_TOP_K", 15, min_value=1)
# stricter cut for tag chips / tag lists; stored rows keep the full top-K
SEMANTIC_DISPLAY_TOP_K = _get_env_int("SEMANTIC_DISPLAY_TOP_K", 5, min_value=1)
Expand All @@ -180,12 +179,9 @@ def _get_env_int(
}
SEMANTIC_DEFAULT_THRESHOLD = 5e-05

# Video keyframe sampling. One frame every N seconds instead of per-frame
# inference (30fps = 1800 forward passes per minute of video). The interval is
# stretched when a video would exceed the cap, so cost per video is bounded.
# The bounds are the single source of truth for both the sampler default here
# and the user-preferences API schema, so the API can never accept an interval
# the sampler would reject.
# One frame every N seconds instead of per-frame inference (30fps = 1800 passes
# per video-minute), stretched to keep cost per video bounded. These bounds also
# back the user-preferences schema, so the API cannot accept what the sampler rejects.
VIDEO_FRAME_INTERVAL_MIN = 0.5
VIDEO_FRAME_INTERVAL_MAX = 300.0
VIDEO_FRAME_INTERVAL_SECONDS = _get_env_float(
Expand Down
11 changes: 3 additions & 8 deletions backend/app/database/albums.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,9 @@ def db_create_albums_table() -> None:
if "cover_image_path" not in columns:
cursor.execute("ALTER TABLE albums ADD COLUMN cover_image_path TEXT")
if "created_at" not in columns:
# No default: SQLite rejects a non-constant one on ALTER TABLE, and
# stamping every existing album with the upgrade time would be a
# date that never happened. They stay NULL and read as oldest,
# which their insertion order already reflects.
# No default: SQLite rejects a non-constant one here, and the upgrade
# time is a date that never happened. NULL reads as oldest, which
# insertion order already reflects.
cursor.execute("ALTER TABLE albums ADD COLUMN created_at DATETIME")
if "updated_at" not in columns:
cursor.execute("ALTER TABLE albums ADD COLUMN updated_at DATETIME")
Expand Down Expand Up @@ -244,7 +243,6 @@ def db_update_album(
cursor = conn.cursor()
try:
if password is not None:
# Update with new password
password_hash = bcrypt.hashpw(
password.encode("utf-8"), bcrypt.gensalt()
).decode("utf-8")
Expand All @@ -258,7 +256,6 @@ def db_update_album(
(album_name, description, int(is_locked), password_hash, album_id),
)
else:
# Update without changing password
cursor.execute(
"""
UPDATE albums
Expand Down Expand Up @@ -368,7 +365,6 @@ def db_add_images_to_album(album_id: str, image_ids: list[str]):
with get_db_connection() as conn:
cursor = conn.cursor()

# Generate placeholders safely based on list length
placeholders = ",".join(["?"] * len(sanitized_ids))
query = f"SELECT id FROM images WHERE id IN ({placeholders})"
cursor.execute(query, sanitized_ids) # Pass string IDs directly
Expand All @@ -377,7 +373,6 @@ def db_add_images_to_album(album_id: str, image_ids: list[str]):
if not valid_images:
raise ValueError("None of the provided image IDs exist in the database.")

# Insert into album_images using executemany
cursor.executemany(
"INSERT OR IGNORE INTO album_images (album_id, image_id) VALUES (?, ?)",
[(album_id, img_id) for img_id in valid_images],
Expand Down
9 changes: 0 additions & 9 deletions backend/app/database/faces.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ def db_insert_face_embeddings(
try:
embeddings_json = json.dumps([emb.tolist() for emb in embeddings])

# Convert bbox to JSON string if provided
bbox_json = json.dumps(bbox) if bbox is not None else None

cursor.execute(
Expand Down Expand Up @@ -119,7 +118,6 @@ def db_insert_face_embeddings_by_image_id(
cluster_id: Cluster ID(s) for the face(s) (optional)
"""

# Handle multiple faces in one image
if (
isinstance(embeddings, list)
and len(embeddings) > 0
Expand Down Expand Up @@ -205,18 +203,15 @@ def get_all_face_embeddings():
"tags": [],
}

# Add tag if it exists
if tag_name:
images_dict[image_id]["tags"].append(tag_name)

# Convert to list and set tags to None if empty
images = []
for image_data in images_dict.values():
if not image_data["tags"]:
image_data["tags"] = None
images.append(image_data)

# Sort by path
images.sort(key=lambda x: x["path"])
return images
finally:
Expand All @@ -243,7 +238,6 @@ def db_get_faces_unassigned_clusters() -> List[Dict[str, Union[FaceId, FaceEmbed
faces = []
for row in rows:
face_id, image_id, embeddings_json = row
# Convert JSON string back to numpy array
embeddings = np.array(json.loads(embeddings_json))
faces.append(
{"face_id": face_id, "image_id": image_id, "embeddings": embeddings}
Expand Down Expand Up @@ -281,7 +275,6 @@ def db_get_all_faces_with_cluster_names() -> (
faces = []
for row in rows:
face_id, image_id, embeddings_json, cluster_name = row
# Convert JSON string back to numpy array
embeddings = np.array(json.loads(embeddings_json))
faces.append(
{
Expand Down Expand Up @@ -403,14 +396,12 @@ def db_get_cluster_mean_embeddings() -> List[Dict[str, Union[str, FaceEmbedding]
cluster_embeddings = {}
for row in rows:
cluster_id, embeddings_json = row
# Convert JSON string back to numpy array
embeddings = np.array(json.loads(embeddings_json))

if cluster_id not in cluster_embeddings:
cluster_embeddings[cluster_id] = []
cluster_embeddings[cluster_id].append(embeddings)

# Calculate mean embeddings for each cluster
cluster_means = []
for cluster_id, embeddings_list in cluster_embeddings.items():
# Stack all embeddings for this cluster and calculate mean
Expand Down
6 changes: 0 additions & 6 deletions backend/app/database/folders.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from app.config.settings import DATABASE_PATH
from app.logging.setup_logging import get_logger

# Initialize logger
logger = get_logger(__name__)

# Type definitions
Expand Down Expand Up @@ -193,7 +192,6 @@ def db_delete_folders_batch(folder_ids: List[FolderId]) -> int:
cursor.execute("PRAGMA foreign_keys = ON;")
conn.commit()

# Create placeholders for the IN clause
placeholders = ",".join("?" * len(folder_ids))

cursor.execute(
Expand Down Expand Up @@ -323,7 +321,6 @@ def db_update_ai_tagging_batch(
cursor = conn.cursor()

try:
# Create placeholders for the IN clause
placeholders = ",".join("?" * len(folder_ids))

cursor.execute(
Expand Down Expand Up @@ -399,10 +396,8 @@ def db_get_folder_ids_by_paths(
cursor = conn.cursor()

try:
# Convert all paths to absolute paths
abs_paths = [os.path.abspath(path) for path in folder_paths]

# Create placeholders for the IN clause
placeholders = ",".join("?" * len(abs_paths))

cursor.execute(
Expand All @@ -412,7 +407,6 @@ def db_get_folder_ids_by_paths(

results = cursor.fetchall()

# Create a mapping from folder_path to folder_id
path_to_id = {folder_path: folder_id for folder_path, folder_id in results}

return path_to_id
Expand Down
1 change: 0 additions & 1 deletion backend/app/database/image_embeddings.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ def db_upsert_image_embeddings(rows: List[Tuple[str, str, np.ndarray]]):
conn = _connect()
cursor = conn.cursor()

# Convert each embedding
db_rows = [
(
image_id,
Expand Down
8 changes: 0 additions & 8 deletions backend/app/database/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
)
from app.logging.setup_logging import get_logger

# Initialize logger
logger = get_logger(__name__)

# Type definitions
Expand Down Expand Up @@ -95,7 +94,6 @@ def db_create_images_table() -> None:
"""
)

# Create indexes for Memories feature queries
cursor.execute("CREATE INDEX IF NOT EXISTS ix_images_latitude ON images(latitude)")
cursor.execute(
"CREATE INDEX IF NOT EXISTS ix_images_longitude ON images(longitude)"
Expand All @@ -107,7 +105,6 @@ def db_create_images_table() -> None:
"CREATE INDEX IF NOT EXISTS ix_images_favourite_captured_at ON images(isFavourite, captured_at)"
)

# Create new image_classes junction table
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS image_classes (
Expand Down Expand Up @@ -191,7 +188,6 @@ def db_get_all_images(tagged: Union[bool, None] = None) -> List[dict]:
cursor = conn.cursor()

try:
# Build the query with optional WHERE clause
query = """
SELECT
i.id,
Expand Down Expand Up @@ -262,14 +258,12 @@ def db_get_all_images(tagged: Union[bool, None] = None) -> List[dict]:
if tag_name and tag_name not in images_dict[image_id]["tags"]:
images_dict[image_id]["tags"].append(tag_name)

# Convert to list and set tags to None if empty
images = []
for image_data in images_dict.values():
if not image_data["tags"]:
image_data["tags"] = None
images.append(image_data)

# Sort by path
images.sort(key=lambda x: x["path"])

return images
Expand Down Expand Up @@ -457,7 +451,6 @@ def db_get_images_by_folder_ids(
cursor = conn.cursor()

try:
# Create placeholders for the IN clause
placeholders = ",".join("?" for _ in folder_ids)
cursor.execute(
f"""
Expand Down Expand Up @@ -559,7 +552,6 @@ def db_delete_images_by_ids(image_ids: List[ImageId]) -> bool:
cursor = conn.cursor()

try:
# Create placeholders for the IN clause
placeholders = ",".join("?" for _ in image_ids)
cursor.execute(
f"DELETE FROM images WHERE id IN ({placeholders})",
Expand Down
12 changes: 4 additions & 8 deletions backend/app/database/memories.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,8 @@ def db_create_memories_table() -> None:
"""
)

# Videos get their own table rather than a media_type column on
# memory_images: the real cascade is the reason that table exists, and
# one id column cannot reference two parents. sort_order is shared
# across both, so a story still reads in one chronological sequence.
# Its own table rather than a media_type column: one id column cannot
# reference two parents. sort_order is shared, so a story stays in sequence.
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS memory_videos (
Expand Down Expand Up @@ -1444,10 +1442,8 @@ def db_get_images_in_period(
conn = _connect()
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# Compared through datetime() rather than as raw strings: stored
# timestamps use a space separator while ISO bounds use "T", and "T"
# sorts after every digit, so a direct comparison silently matches
# nothing.
# Through datetime(), not raw strings: stored timestamps use a space where
# ISO bounds use "T", which sorts after every digit and matches nothing.
cursor.execute(
"""
SELECT id, path, thumbnailPath, captured_at, latitude, longitude,
Expand Down
2 changes: 0 additions & 2 deletions backend/app/database/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ def db_create_metadata_table() -> None:
"""
)

# Insert initial row if table is empty
cursor.execute("SELECT COUNT(*) FROM metadata")
if cursor.fetchone()[0] == 0:
cursor.execute("INSERT INTO metadata (metadata) VALUES (?)", ("{}",))
Expand Down Expand Up @@ -89,7 +88,6 @@ def db_update_metadata(
conn = sqlite3.connect(DATABASE_PATH)
cursor = conn.cursor()

# Delete all existing rows and insert new one
cursor.execute("DELETE FROM metadata")
cursor.execute("INSERT INTO metadata (metadata) VALUES (?)", (metadata_json,))

Expand Down
22 changes: 8 additions & 14 deletions backend/app/database/semantic_labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,17 @@ def db_create_semantic_labels_table():
conn = _connect()
cursor = conn.cursor()

# Migrate the pre-vocabulary shell schema. It shipped with no writer,
# so the table is guaranteed empty: drop-and-recreate instead of
# ALTER. image_semantic_labels (also never written) is superseded by
# image_classes rows + image_classes.score.
# The pre-vocabulary schema shipped with no writer, so it is guaranteed
# empty: drop and recreate rather than ALTER.
cursor.execute("PRAGMA table_info(semantic_labels)")
columns = {row[1] for row in cursor.fetchall()}
if columns and "descriptions" not in columns:
cursor.execute("DROP TABLE semantic_labels")
cursor.execute("DROP TABLE IF EXISTS image_semantic_labels")

# Definition + cache table for the curated vocabulary. class_id is
# shared with mappings so tag consumers (image_classes joins) treat
# semantic labels exactly like YOLO classes. descriptions (JSON
# array) are the source of truth; label_embedding caches their
# renormalized mean for embedding_model_version.
# class_id is shared with mappings so tag consumers treat semantic labels
# exactly like YOLO classes. descriptions are the source of truth;
# label_embedding just caches their renormalized mean.
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS semantic_labels (
Expand All @@ -48,11 +44,9 @@ def db_create_semantic_labels_table():
"""
)

# Display cut: tag-list queries join this view instead of
# image_classes, so chips show all YOLO tags but only the
# top-SEMANTIC_DISPLAY_TOP_K semantic tags per image. Search
# matching still uses the full table (stored top-K). Recreated at
# startup so setting changes apply without re-scoring.
# Chips join this view so they show all YOLO tags but only the top few
# semantic ones; search still uses the full table. Recreated at startup so
# a setting change applies without re-scoring.
from app.config.settings import SEMANTIC_DISPLAY_TOP_K

cursor.execute("DROP VIEW IF EXISTS image_classes_display")
Expand Down
7 changes: 2 additions & 5 deletions backend/app/database/videos.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
)
from app.logging.setup_logging import get_logger

# Initialize logger
logger = get_logger(__name__)

# Type definitions
Expand Down Expand Up @@ -44,10 +43,8 @@ def db_create_videos_table() -> None:
conn = _connect()
cursor = conn.cursor()

# Videos are kept separate from images by design; isTagged tracks the
# keyframe sampling pass (see video_frames). thumbnailPath is nullable:
# videos whose codec OpenCV cannot decode are still indexed (frontend
# shows a placeholder).
# isTagged tracks the keyframe sampling pass (see video_frames). thumbnailPath
# is nullable: a codec OpenCV cannot decode is still indexed, with a placeholder.
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS videos (
Expand Down
Loading
Loading