Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3c165d8
docs: strip comment slop and dead commented-out code
VanshajPoonia Aug 9, 2026
ce368e1
docs: restructure remaining comments to one or two lines
VanshajPoonia Aug 9, 2026
330a676
docs: collapse the last banner comments and tunnel module header
VanshajPoonia Aug 9, 2026
f6f35ef
fix: require traversal as well as read permission on an added folder
VanshajPoonia Aug 9, 2026
7575fb9
Merge remote-tracking branch 'upstream/main' into cleanup/1458-restru…
VanshajPoonia Aug 11, 2026
79aef67
fix: correct the reason given for returning a data URL from the picker
VanshajPoonia Aug 11, 2026
f7d2b3b
docs: correct comments that stated the wrong mechanism
VanshajPoonia Aug 11, 2026
f715c78
Merge upstream/main into cleanup/1458-restructure-comments
VanshajPoonia Aug 19, 2026
7807e66
Merge main into cleanup/1458-restructure-comments
VanshajPoonia Sep 14, 2026
b3008a3
docs: trim a five-line comment added on main since the audit
VanshajPoonia Sep 14, 2026
41a3e0f
Merge upstream/dev into cleanup/1458-restructure-comments
VanshajPoonia Oct 8, 2026
9d79aca
docs: leave files that fail the changed-file MyPy check to a follow-up
VanshajPoonia Oct 8, 2026
418d784
docs: leave two lines to the open PRs that already change them
VanshajPoonia Oct 8, 2026
72245b6
docs: keep the tagging condition in the video progress comment
VanshajPoonia Oct 8, 2026
508da71
Merge upstream/dev into cleanup/1458-restructure-comments
VanshajPoonia Oct 10, 2026
8445618
Merge upstream/dev into cleanup/1458-restructure-comments
VanshajPoonia Oct 11, 2026
6c9472a
docs: trim the image deletion comments to the comment budget
VanshajPoonia Oct 11, 2026
09181ff
docs: trim the metadata export comments to the comment budget
VanshajPoonia Oct 11, 2026
7220df0
docs: cut restating comments and tighten the rest in the backend
VanshajPoonia Oct 12, 2026
0b497b6
fix: stop printing the working directory on every startup
VanshajPoonia Oct 12, 2026
1a6afda
docs: cut restating comments and tighten the rest in the frontend
VanshajPoonia Oct 12, 2026
28df970
docs: restore reasons that earlier comment trims dropped
VanshajPoonia Oct 12, 2026
d1dd5d6
docs: clean FaceDetector and ObjectClassifier comments
VanshajPoonia Oct 12, 2026
1846701
Merge remote-tracking branch 'upstream/dev' into cleanup/1458-restruc…
VanshajPoonia Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 9 additions & 14 deletions backend/app/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,10 @@
os.path.join(os.path.dirname(__file__), "..", "models", "ONNX_Exports")
)

# Microservice URLs
SYNC_MICROSERVICE_URL = "http://localhost:52124"

# The album share listener. Unlike the two services above this one binds
# 0.0.0.0, so it is reachable by anything that can route to this machine.
# The album share listener. Unlike the backend and sync service it binds 0.0.0.0,
# so anything that can route to this machine can reach it.
SHARE_SERVER_PORT = 52125

CONFIDENCE_PERCENT = 0.6
Expand Down Expand Up @@ -180,13 +179,12 @@ def _get_env_int(
SIGLIP2_TEXT_MAX_LENGTH = 64
SIGLIP2_TOKENIZER_PAD_ID = 0
SIGLIP2_TOKENIZER_PAD_TOKEN = "<pad>"
# bare-noun queries score low in absolute terms; 0.02 measured to cut true positives.
# Bare-noun queries score low in absolute terms; 0.02 measurably 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 @@ -198,12 +196,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
17 changes: 5 additions & 12 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 @@ -351,14 +348,12 @@ def db_add_images_to_album(album_id: str, image_ids: list[str]):
Safely adds images to an album using parameterized queries.
Maintains UUID support and uses efficient single queries.
"""
# Validate input type
if not isinstance(image_ids, list):
raise ValueError("image_ids must be a list of IDs")

# Remove integer conversion - keep IDs as strings for UUID support
# IDs are UUID strings, so they are never cast to int.
sanitized_ids = []
for img_id in image_ids:
# Basic validation - ensure it's a non-empty string
if isinstance(img_id, str) and img_id.strip():
sanitized_ids.append(img_id.strip())

Expand All @@ -368,16 +363,14 @@ 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
cursor.execute(query, sanitized_ids)
valid_images = [row[0] for row in cursor.fetchall()]

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
24 changes: 6 additions & 18 deletions backend/app/database/faces.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

logger = get_logger(__name__)

# Type definitions
FaceId = int
ImageId = str
FrameId = str
Expand Down Expand Up @@ -73,10 +72,9 @@ def db_create_faces_table() -> None:
)
"""
)
# Legacy databases only: they predate the column above, and
# CREATE IF NOT EXISTS will not add it to a table that already exists.
# No CHECK enforcing the exclusive arc -- SQLite cannot ALTER one in, so
# fresh and migrated databases would end up with different schemas.
# Legacy databases predate frame_id, and CREATE IF NOT EXISTS won't add
# it. No CHECK for the exclusive arc: SQLite can't ALTER one in, so fresh
# and migrated schemas would differ.
cursor.execute("PRAGMA table_info(faces)")
if "frame_id" not in {row[1] for row in cursor.fetchall()}:
cursor.execute(
Expand Down Expand Up @@ -181,7 +179,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 @@ -220,7 +217,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 All @@ -247,7 +243,6 @@ def db_insert_face_embeddings_by_image_id(
face_ids.append(face_id)
return face_ids
else:
# Single face
return db_insert_face_embeddings(
image_id,
cast(FaceEmbedding, embeddings),
Expand Down Expand Up @@ -365,7 +360,6 @@ def db_get_faces_unassigned_clusters() -> (
faces = []
for row in rows:
face_id, image_id, frame_id, embeddings_json = row
# Convert JSON string back to numpy array
embeddings = np.array(json.loads(embeddings_json))
faces.append(
{
Expand Down Expand Up @@ -410,7 +404,6 @@ def db_get_all_faces_with_cluster_names() -> (
faces = []
for row in rows:
face_id, image_id, frame_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 @@ -455,9 +448,8 @@ def db_update_face_cluster_ids_batch(
cursor = conn.cursor()
assert cursor is not None

# 1. Prepare update data outside the DB transaction.
# mapping.get() calls could raise AttributeError if mappings are malformed;
# these are not sqlite3 errors and must be caught before the transaction.
# Build rows before the transaction: a malformed mapping raises AttributeError,
# which the sqlite3.Error handler below would not catch.
try:
update_data = [
(mapping.get("cluster_id"), mapping.get("face_id"))
Expand All @@ -469,7 +461,7 @@ def db_update_face_cluster_ids_batch(
logger.error(f"Failed to prepare face cluster update data: {e}")
raise

# 2. Database transaction — only pure DB operations here, so sqlite3.Error is safe.
# Only DB calls in here, so catching sqlite3.Error is enough.
try:
cursor.executemany(
"""
Expand Down Expand Up @@ -536,21 +528,17 @@ def db_get_cluster_mean_embeddings() -> List[Dict[str, Union[str, FaceEmbedding]
if not rows:
return []

# Group embeddings by cluster_id
cluster_embeddings: Dict[ClusterId, List[FaceEmbedding]] = {}
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
stacked_embeddings = np.stack(embeddings_list)
mean_embedding = np.mean(stacked_embeddings, axis=0)

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
16 changes: 6 additions & 10 deletions backend/app/database/memories.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# This DB module stores curated memories: persisted, scored collections of
# images surfaced proactively rather than browsed.
# Curated memories: persisted, scored collections of images that PictoPy
# surfaces on its own rather than waiting to be browsed.
import json
import sqlite3
from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple, TypedDict
Expand Down 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 @@ -1448,10 +1446,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
23 changes: 9 additions & 14 deletions backend/app/database/semantic_labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,18 @@ 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. image_semantic_labels, also
# never written, is superseded by image_classes.score.
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 +45,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
10 changes: 2 additions & 8 deletions backend/app/database/videos.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
# Standard library imports
import sqlite3
from typing import Any, List, Mapping, Optional, Tuple, TypedDict, Union
import json

from datetime import datetime

# App-specific imports
from app.config.settings import (
DATABASE_PATH,
)
from app.logging.setup_logging import get_logger

# Initialize logger
logger = get_logger(__name__)

# Type definitions
VideoId = str
VideoPath = str
FolderId = str
Expand Down Expand Up @@ -46,10 +42,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
7 changes: 3 additions & 4 deletions backend/app/database/xmp_export_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,9 @@ def dim(row: str, key: str) -> str:
)


# Every write that can change what export would put in an image. Triggers only
# ever UPDATE existing state rows: inserting one while an image is being
# cascade-deleted would violate its foreign key. "UPDATE OF" fires even when
# the value is unchanged, so each column trigger also checks for a real change.
# Every write that can change an image's exported data. Triggers only UPDATE state
# rows, since an INSERT mid cascade-delete would break the foreign key. "UPDATE OF"
# fires even on unchanged values, so each column trigger checks for a real change.
_TRIGGERS: List[Tuple[str, str]] = [
(
"xmp_images_insert",
Expand Down
11 changes: 3 additions & 8 deletions backend/app/database/yolo_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,6 @@


def db_create_YOLO_classes_table():
# print current directory:
import os

print(os.getcwd())
conn = None
try:
conn = sqlite3.connect(DATABASE_PATH)
Expand All @@ -21,10 +17,9 @@ def db_create_YOLO_classes_table():
"""
)
for class_id, name in enumerate(class_names):
# Upsert, not INSERT OR REPLACE: a replace deletes the row first,
# which cascades to every image_classes row once foreign keys are
# on. Writing only on a real change also keeps startup from
# marking every tagged image for metadata export.
# Upsert, not INSERT OR REPLACE: a replace deletes the row first, which
# cascades to image_classes once foreign keys are on. Writing only on a
# real change keeps startup from marking every tagged image for export.
cursor.execute(
"INSERT INTO mappings (class_id, name) VALUES (?, ?) "
"ON CONFLICT(class_id) DO UPDATE SET name = excluded.name "
Expand Down
Loading
Loading