Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Smriti Manual

The full user guide and contributor reference for Smriti, the offline photo library.

User guide

  • Getting Started — first run, picking a library, where data lives.
  • Indexing Photos — what scanning does, what it skips, how to refresh.
  • Timeline — the main view, navigation, keyboard shortcuts.
  • People and Faces — face detection, naming, merging, reviewing.
  • Albums — manual albums and suggested trips / events.
  • Memories — “this day, N years ago” cards.
  • Map View — geographic exploration and tile caching.
  • Insights — heatmaps, top people, top locations.
  • Search — query syntax and examples.
  • Cleanup — duplicates, bursts, and the trash workflow.
  • Settings — every preference and what it changes.
  • Keyboard Shortcuts — full list.
  • FAQ — quick answers to common questions.
  • Troubleshooting — common issues and fixes.

For contributors

Privacy and data

Getting Started

This guide walks through the first ten minutes of using Smriti: choosing a library, what happens during the first scan, and where your data lives.

What Smriti is

Smriti is a desktop photo library — Linux, macOS, Windows. It points at a folder or drive containing your photos and indexes them in place. Your originals stay where they are; Smriti reads them, extracts metadata (dates, GPS, camera, faces), and writes a small SQLite database alongside the photos.

There’s no cloud upload, no account, no server. Open the app, browse, close it.

First run

  1. Launch Smriti. The Welcome screen invites you to pick or drop a folder.
  2. Pick a library. Drag a folder onto the window, or click Browse to pick one. Smriti remembers it and offers it again next time.
  3. (Optional) Install the asset pack. A first-run prompt may ask to download ~250 MB of ONNX face-recognition models + the GeoNames offline geocoding database. Declining is fine — face features and place names are the only things gated on this. The rest of the app works without it.
  4. Watch the scan. The timeline populates live as photos are indexed. The first scan on a 10K-photo library typically takes 5–15 minutes. Face detection then runs as a separate pass and can take longer — see People and Faces for how to speed that up with an optional remote GPU.

Where your data goes

  • Originals — untouched, in their folders.
  • Database — at <library>/.photovault/photovault.db, on the same drive as the photos. Unplug the drive, plug it into another machine, point Smriti there: same library, same faces, same albums.
  • Thumbnails — at <library>/.photovault/thumbnails/, three sizes, generated on demand.
  • Application settings — in your OS’s user config directory (Linux: ~/.config/smriti/, macOS: ~/Library/Application Support/smriti/, Windows: %APPDATA%\smriti\). Theme preference, sidebar state, recently opened libraries.

The library is fully portable. Backing it up = backing up the drive.

What to do next

  • Browse the Timeline — your main view.
  • Once face detection finishes, head to People to name a few faces.
  • Try Search: paris 2018, dad in tokyo, this month.
  • Open the Map view if your photos have GPS data.

Common first-run questions

Why is the scan so slow? The first scan walks every file under the library root, reads EXIF, hashes each file for dedup, and (with the asset pack installed) runs face detection. Modern 10K-photo libraries take 5–20 minutes total. Subsequent reindexes are incremental — only changed files are revisited. See Indexing.

Where do I add more folders? Smriti indexes one library at a time. Switch libraries via the Welcome screen or Settings → Open another library. Each library has its own .photovault/ database; switching back and forth is instant.

Can I move my library to another drive? Yes. Copy the folder including the hidden .photovault/ directory. Open Smriti on the new machine, point it at the new location. Everything resumes — thumbnails, face names, albums, search history.

Face recognition is disabled — what now? The asset pack wasn’t installed, or your hardware ran out of memory during model load. See Troubleshooting.

See also

  • Indexing — what scanning does, how reindex works
  • Settings — every preference and what it changes
  • Privacy — the exact network touchpoints

Indexing Photos

Smriti’s scanner walks your library folder, extracts metadata from each photo, and writes it to a SQLite database alongside the photos. This page covers what scanning does, what it skips, and how to refresh the index.

What a scan does

For every supported file under the library root:

  1. Checks if Smriti has already indexed it. Files identified by path + size + modification time are skipped on subsequent scans.
  2. Reads EXIF. Capture date, GPS coordinates, camera make/model, ISO, aperture, shutter, focal length, orientation.
  3. Hashes the file (SHA-256) for byte-level duplicate detection.
  4. Resolves a place name if GPS is present, using the local GeoNames database.
  5. Generates thumbnails (three sizes) lazily, the first time each photo is viewed.
  6. Queues face detection if the asset pack is installed.

Smriti never copies, moves, or modifies your originals.

What’s indexed

Image formats: JPEG, PNG, WebP, HEIC / HEIF (if libheif is compiled in), TIFF, BMP, GIF (first frame), plus the common camera RAW formats with embedded JPEG previews: NEF, CR2, ARW, DNG, ORF, RW2, PEF, SRW, RAF.

Skipped:

  • Files smaller than 10 KB (guards against icons and email thumbnails).
  • Files without a recognised extension.
  • Hidden folders (those starting with .) — unless Scan hidden folders is enabled in Settings.
  • .photovault/ itself, always.

Reindex actions

Smriti doesn’t run scans on a schedule. You re-trigger them from Settings → Maintenance:

  • Rescan Library — walks the entire library, picks up new files, files that changed, files that moved, and files that were deleted. Existing rows for unchanged files are left alone.
  • Check for Changes — like rescan but doesn’t re-process unchanged files. Faster when you only added or removed a few photos.
  • Refresh Capture Dates — re-reads capture dates for photos and videos. Smriti prefers embedded metadata, then strict filename dates, and uses file modified time only as a marked fallback.
  • Regenerate Thumbnails — discards all thumbnails and lazy-rebuilds on next view.

How long it takes

  • First scan of a 10,000-photo library on a modern laptop: 5–15 minutes for metadata, then 30 minutes – 3 hours for face detection on CPU (the slow step). A free Kaggle/Colab GPU bridge can cut the face pass to ~5 minutes — see the face GPU bridge doc.
  • Incremental rescan when a few hundred new photos appeared: seconds to minutes.
  • Thumbnails are lazy — generated when a view scrolls a photo into the viewport, not upfront. Scrolling the Timeline for the first time is when most thumbnail work happens.

The .photovault/ folder

This hidden directory at the library root contains everything Smriti needs:

<library>/
├── photo1.jpg
├── photo2.jpg
├── trip-2018/
│   └── ...
└── .photovault/
    ├── photovault.db        # SQLite — all metadata
    └── thumbnails/          # three-tier thumbnail cache

Keep this folder when you copy or back up the library. Delete it and Smriti will re-index from scratch on next open.

See also

Timeline

Timeline is the main view — a chronological grid of every photo in your library, grouped by day, with a sticky year header that follows the scroll.

Browsing

  • Scroll in either direction. Thumbnails load as they enter the viewport, so even 250K-photo libraries scroll smoothly.
  • Sticky year header at the top of the grid shows where you are.
  • Day separators label each day’s photos. Empty days are collapsed.
  • Hover a photo to see its filename and camera info as a quick tooltip.
  • Photo stacks collapse conservative burst and duplicate groups into one timeline tile. The tile shows the suggested best photo and a stacked badge with the number of photos in the group.

Keyboard

KeyAction
Move highlight between thumbnails
EnterOpen the highlighted photo in the viewer
SpaceToggle selection on the highlighted photo
Shift+clickRange-select
Ctrl/Cmd+clickAdd to selection
PageUp / PageDownScroll a viewport
Home / EndJump to start / end
/Focus the search bar

Selection actions

With one or more photos selected, the action bar at the bottom of the window offers:

  • Add to album — pick an existing album or create a new one.
  • Trash — soft-delete; restorable from the Trash view.
  • Open as slideshow — fullscreen with arrow-key navigation.

Photo viewer

Click any photo to open the viewer. Within it:

KeyAction
/ Previous / next photo
EscBack to gallery
IToggle the info panel (EXIF, location, people)
+ / Zoom in / out
0Fit to screen
1Actual size (1:1 pixel)
[ / ]Rotate left / right
FToggle fullscreen

The info panel (toggled with I) shows EXIF metadata, detected people, GPS location with a small map preview, and the file path on disk.

If the current photo belongs to a stack, the toolbar shows a small stack button. Opening it reveals the other photos in that stack without changing normal timeline navigation: and still move to the previous or next visible timeline photo. The stack tray lets you browse members, mark a different best photo, remove a member from the stack, unstack the group, or move all non-best members to Trash.

Thumbnail size

Three sizes, switched in Settings → Appearance → Thumbnail size:

  • Compact — denser grid, more photos per row.
  • Default — balanced.
  • Large — fewer per row, more detail per thumbnail.

See also

People and Faces

Smriti detects faces during scanning, groups them into clusters (one cluster per person), and lets you name, merge, and review those clusters in the People view.

Running face processing

Face processing runs automatically during the initial scan and on any rescan when new photos are added. You can also trigger it manually from Settings.

Detection uses SCRFD (face localization) and GLinTR-100 (identity embedding), both running locally via ONNX Runtime.

Clustering pipeline

Clustering happens in two stages:

  1. Gallery retrieval. Each new face is compared against the gallery of existing clusters using k-nearest-neighbor matching with confidence bands. High-confidence matches auto-assign. Ambiguous matches are queued for review. Low-confidence faces drop to stage 2.
  2. Complete-link agglomerative. The remaining unresolved faces are clustered pairwise. If the number of unresolved faces exceeds 2,000 in a single pass, stage 2 is skipped for performance and those faces are routed to the ambiguous-review queue or matched against galleries by the rescue pass instead. The cap will be lifted in a future release once HNSW-based approximate clustering replaces complete-link.

After clustering, a post-pass unifies clusters that look like the same person split by lighting variance, and a rescue pass matches any remaining orphans against existing galleries with looser thresholds.

What you can do in People

  • Rename a cluster by clicking its title.
  • Open a person to see all photos they appear in.
  • Merge two clusters into one (e.g. when the system split the same person across several clusters).
  • Review ambiguous matches — the review deck walks you through faces the system wasn’t sure about, one at a time.

Tuning

  • Face confidence in Settings controls the minimum detection score for a face to be considered valid.
  • Clustering threshold in Settings controls how tolerant the system is about grouping visually similar faces. Lower values mean tighter clusters (more but smaller groups); higher values mean looser clusters (fewer but larger groups). The default works for most libraries.

Privacy

Face embeddings and detection crops never leave the device. See PRIVACY.md for the full data-flow description.

Albums

Albums let you group photos into named collections. Smriti supports both manual albums (you create them) and suggested albums (Smriti detects trips and events automatically and offers them for your review).

Manual albums

Create an album from the Albums view, or directly from a Timeline selection:

  1. Select one or more photos in any view.
  2. Click Add to album in the action bar.
  3. Pick an existing album or type a name to create a new one.

Albums support:

  • Inline rename — click the title in the album header.
  • Cover photo — right-click any photo in the album → Set as cover, or let Smriti pick automatically (landscape with faces scores higher).
  • Reordering — drag photos within an album to change the order.
  • Bulk remove — select photos, Remove from album in the action bar.

Removing a photo from an album doesn’t delete the photo — it stays in the Timeline.

Suggested albums

Smriti analyses your library for two kinds of clusters and proposes them as drafts you can accept, ignore, or dismiss:

  • Trips — runs of photos taken away from your home city for at least a day. Photos must clear a few gates (duration, photo count, distance from home, rarity) before they’re suggested. Detected trips are titled something like “Trip to Tokyo · March 2019”.
  • Events — bursts of activity within your home area: a birthday, a wedding, a concert. Detected events lean on photo density, duration, and face overlap with people you’ve already named.

Open the Albums view to see pending suggestions. Each card shows:

  • A cover photo,
  • The detected title,
  • A photo count,
  • Accept / Dismiss buttons.

Accept promotes the suggestion to a real album that you can edit like any manual one. Dismiss marks it as not-an-album so Smriti doesn’t re-suggest it the next time it scans.

Settings that affect suggestions

  • Home city override in Settings tells trip detection where you live. By default Smriti infers home from the city where most of your photos are taken. Override when you’ve moved or the inference is wrong.
  • Geocoding — trip detection needs your photos to have place names. If GPS data is present but place names are missing, run Settings → Fill in place names.

Album cover photo selection

When you don’t manually set a cover, Smriti picks one with a small heuristic: prefer landscape orientation, prefer photos with faces, break ties with recency. You can always override.

See also

  • Memories — related “on this day” surfacing
  • Map — pivot from a place to all photos there
  • Settings → Map — home-city override

Memories

Memories surfaces “this day, N years ago” style cards when you open Smriti. They’re generated locally, on demand, when you visit the Memories view (or read the banner on the Timeline). Nothing is pre-rendered, no server, no schedule.

How memories are generated

Smriti runs four generators against today’s date, then ranks the results and picks the best ~20 to surface:

  • On this day — photos from the exact same calendar day in any previous year.
  • Fallback window — when “on this day” finds nothing, widens to a ±7-day window so a sparse library still sees something.
  • Seasonal recap — gathers a whole month or season from a past year if there’s enough density.
  • Year recap — ultimate fallback: at least one card from a year with any history at all.

A hero photo is chosen for each memory: landscape orientation preferred, photos with faces ranked higher, recency breaks ties.

When memories appear

  • Library age — Memories only surface once your oldest photo is at least three months old. New libraries see nothing for the first quarter.
  • Photo age — Photos newer than three months don’t appear in memories. The point is nostalgia, not yesterday.
  • Daily refresh — Each day surfaces a new set; yesterday’s cards are recomputed.

What you can do

  • Open a memory card to see all the photos behind it as a filmstrip.
  • Slideshow — autoplay through the photos with arrow-key navigation. Pause with Space.
  • Dismiss a memory if you don’t want to see it again — useful for trips or events you’d rather forget. Dismissed memories don’t resurface in future months.

Privacy

Memories are computed entirely on-device. No server-side generation, no curated highlight reel, no notification that you “should look at this.” The Memories view is something you visit on purpose.

Disabling memories

Settings → Memories → Enable memories toggles the feature entirely. When off, the Timeline banner and the Memories view both hide.

See also

Map View

The Map view plots every geotagged photo in your library on an interactive world map. Pan, zoom, and click clusters to drill into specific places.

What you see

  • Clusters — at zoomed-out levels, nearby photos are grouped into circular badges showing the photo count.
  • Pins — individual photos at high zoom.
  • Filmstrip — clicking a cluster or pin opens a side panel with the photos for that location and a scrollable filmstrip.

Map tiles

Smriti uses OpenStreetMap tiles via MapLibre GL. Tiles are downloaded on demand when you pan to a region for the first time, then cached locally on your drive. Subsequent visits to the same region serve from cache — no further network requests.

Cache location: OS user cache directory (Linux: ~/.cache/smriti/map-tiles/, macOS: ~/Library/Caches/smriti/map-tiles/, Windows: %LOCALAPPDATA%\smriti\Cache\map-tiles\).

Cache size cap: configurable in Settings → Map → Tile cache size. The default 500 MB is enough for the regions you regularly visit; older tiles are evicted LRU-style.

To disable map tile loading entirely: simply don’t open the Map view. Tiles only load while it’s visible.

Working offline

Once you’ve panned over a region in Map view, those tiles are permanently cached and work without internet. Useful before a trip where you know you’ll want to browse without connectivity.

To clear the cache (e.g. before donating the drive), use Settings → Map → Clear tile cache.

Photos without GPS

Photos that lack EXIF GPS tags don’t appear on the map. You can still find them in:

  • The Timeline (everything’s there)
  • A search for the place name (if the place was filled in manually)

Place names

When GPS is present, Smriti resolves it to a city + country via the local GeoNames database (part of the asset pack). Place names feed into Search and album-suggestion detection. If your photos have GPS but no place names yet, run Settings → Fill in place names.

The GeoNames data ships with the asset pack — it’s offline. No request to a geocoding API.

See also

  • Search — find photos by city or country
  • Albums — trip suggestions use geographic clusters
  • Privacy — map tile requests are the one always-on network call

Insights

Insights gives you a one-page summary of how, when, and where you shoot. Heatmap, top people, top locations, monthly breakdown, camera usage. Click anything to pivot into search.

What’s on the page

Heatmap

A 53-week × 7-day grid of your photo activity for the selected year. Each cell is a day; colour intensity scales with the photo count for that day. Hover for the exact count and date. Click a cell to open a search for that specific date.

The default year is the current calendar year; switch via the year selector at the top of the page.

Monthly bars

A bar chart of photo counts per month for the selected year. Hover for the exact count.

Top people

The 10 people who appear in the most photos this year (or this library if no year is filtered). Each row shows:

  • Person’s avatar (their hero face),
  • Name,
  • Photo count.

Click any person to open their detail view — every photo they appear in, across years.

If you have more than 10 people, Show all expands the list.

Top locations

Country and city breakdowns:

  • By country — every country your photos came from, with totals.
  • By city — every city, with totals.

Click any location to search for it.

Camera usage

How often each camera (and lens, when present) appears in your library. Useful for “which lens did I actually use last year?” type questions.

Year filter

The year selector at the top controls every panel on the page. All years shows your entire library; specific years scope everything to that year.

The heatmap defaults to its own year (last year with significant photo activity) regardless of the page filter — heatmaps for empty years are pointless.

Most clickable elements on the Insights page open a search:

  • Heatmap cell → photos from that day
  • Top-people row → that person’s detail
  • Top-location row → photos from that place

This makes Insights the fastest navigation surface for “where was I in 2019” or “who did I shoot the most last summer” type questions.

See also

  • Search — the destination Insights pivots into
  • People — top-people clicks go here
  • Map — geographic counterpart to top-locations

Search

Smriti’s search is one box for the library metadata it already has: named people, dates, places, albums, favourites, media type, filenames, camera names, and optional local visual meaning. It does not use cloud AI.

What Search Understands

QueryMeaning
Goa 2023Photos from Goa taken in 2023
Dad 2024Photos containing Dad from 2024
Dad and MomPhotos containing both Dad and Mom
only DadPhotos where every detected face is Dad
only Dad and MomPhotos where every detected face is Dad or Mom, and both are present
favourites DadFavourite photos containing Dad
videos Goa 2024Videos from Goa taken in 2024
album Goa TripPhotos in the matching album
Nikon GoaFilename/camera/location fallback for unmatched words
beach sunsetVisually similar photos, when visual search is installed and indexed

The interpreted filters appear as chips above the results so you can see what Smriti understood.

People

People search uses names from the People view. Matching is case-insensitive and supports combinations:

Dad
Dad and Mom
Dad with Mom

Multiple people are treated as an intersection: every returned photo must contain every requested person.

Strict only

only is intentionally strict.

only Dad
only Dad and Mom

A photo matches only Dad only when:

  • face processing has completed for the photo
  • Dad appears in the photo
  • no detected face is assigned to another person
  • no detected face is unassigned or unknown

If a photo has an unrecognised face, it is excluded. This avoids showing group photos when you asked for one person only.

Dates

Search recognises:

  • years: 2024
  • month + year: March 2019, Mar 2019, 2019 March
  • exact dates: 2024-03-15
  • relative dates: today, yesterday, this week, last month
  • seasons: spring 2020, fall 2021

Date filters combine with every other filter.

Places

Places are resolved from your library’s stored city/country metadata. There is no hardcoded city list in smart search. If GPS reverse-geocoding has not been run, place search has less to work with.

Albums, Favourites, And Media

Use album <name> or in album <name> to filter by album.

Use favourites, favorites, or starred to filter to favourite items.

Use videos, video, photos, or photo to filter media type.

Visual Meaning

Visual search is optional. Install the visual model from Settings -> Assets -> Visual search and install the runtime pack with Download assets, then run Index visual search. Smriti stores the model outside the app binary and stores per-library vectors under .photovault/semantic.

When visual search is ready, natural queries such as:

beach sunset
food photos 2024
videos mountains

are matched against local image/video-poster embeddings. Structured filters still apply: food photos 2024 is semantic food plus photo media type plus the 2024 date range, not a separate loose search path.

What It Does Not Do Yet

  • No cloud lookup.
  • No OCR dependency in the main search path.
  • No regex or wildcard syntax.
  • Trashed photos are excluded.

Pivots From Elsewhere

Several views open Search with a pre-filled query:

  • Insights -> click a heatmap cell, person, or location
  • Map -> click a cluster or place
  • People -> click a person card

See Also

  • Timeline - full chronological view
  • People - naming faces makes them searchable
  • Map - geographic counterpart

Cleanup

Cleanup brings the three tools for thinning out a library into one place: Duplicates, Bursts, and Trash. Each surfaces a different kind of clutter; all of them keep your originals safe until you confirm a deletion.

Duplicates

Smriti detects two kinds of duplicates during indexing:

  • Exact duplicates — files with identical SHA-256 hashes. These are byte-for-byte copies, often the result of importing the same card twice or copying photos between folders.
  • Perceptual near-duplicates — files that look the same to the eye but aren’t byte-identical. Detected via a DCT perceptual hash (pHash) with a tight threshold (~94% bit agreement). This catches re-encoded copies, JPEG-from-RAW exports, watermarked variants, and rescaled exports of the same source.

The Duplicates view groups suspected matches together. For each group:

  • Smriti suggests a keeper (typically the largest / highest- resolution version with the most-original-looking filename).
  • The rest are flagged for trash.
  • Override the suggestion with a click; Apply moves the marked copies to trash.

Why the threshold is tight

Perceptual hashing is fuzzy by design. A loose threshold flags visually similar but compositionally different photos as “duplicates” — same wall, same lighting, but different subjects. Smriti uses a strict threshold so you don’t have to second-guess every group. Photos that look similar but were taken seconds apart belong to the burst detector, not duplicates.

Bursts

Bursts are short rapid-fire sequences — your phone’s burst mode, or a photographer firing off frames at a moving subject. Smriti detects them by:

  • Time gap — consecutive photos within 10 seconds of each other.
  • Burst length — 2+ photos.
  • Similarity — ≥ 65% visual similarity between consecutive shots.

The Bursts view shows each detected burst as a card; opening it reveals the full sequence. Smriti picks a “best” frame as the keeper (sharpest, with eyes-open if faces are present); the rest can be trashed in bulk.

Bursts are detected on demand the first time you visit the view, then cached. New photos trigger a re-scan.

Tuning burst detection

  • Burst window in Settings (default 10s) widens or tightens the time gap used.
  • Detection ignores folder boundaries by default — modern phones group photos by month, not by burst session, so requiring same- folder grouping misses real bursts.

Trash

Trashing a photo doesn’t delete it. It moves the row’s is_trashed flag to true; the photo is hidden from Timeline, Search, Memories, and every other view. The file on disk is untouched until you permanently delete it.

The Trash view lists trashed photos. From it you can:

  • Restore — return a photo to the active library.
  • Delete permanently — remove the file from disk and the database row. There’s no undo for this.
  • Empty trash — permanent-delete everything in trash.

Auto-delete

Settings → Trash → Auto-delete after (default 30 days) permanently deletes photos that have been in the trash longer than the configured window. Set to 0 to disable auto-delete (you’ll have to empty trash manually).

Workflow

A typical cleanup pass:

  1. Open Duplicates → review the suggested keeps → trash the redundant copies.
  2. Open Bursts → review each burst → keep the best, trash the rest.
  3. Browse the Timeline for anything obviously bad you missed.
  4. Open Trash → verify the list → empty trash, or wait for auto-delete.

See also

Settings

Settings control theme, indexing behavior, face thresholds, burst windows, map cache, updates, and maintenance actions.

Changes are persisted locally in user config storage.

Appearance

  • Theme — dark, light, or follow the system theme.
  • Thumbnail size — grid density. Smaller thumbnails fit more photos per row at the cost of per-photo detail.
  • Show photo stacks in timeline — collapse conservative burst and duplicate groups into one timeline tile. Turn this off to show every photo individually.

Indexing

  • Scan hidden folders — include directories starting with .. Off by default because hidden folders often contain metadata, not photos.
  • Date format — how dates render in day headers and insights.

Assets

  • Asset inventory — shows local runtimes, models, and offline data Smriti can use, including their status, size, and on-disk location.
  • Asset locations — each row shows the exact file path Smriti is using. In development checkouts and portable installs, assets may be resolved from different search roots.

Assets live outside the main app binary so the installer stays small. Timeline browsing still works when optional assets are missing; local face recognition, place lookup, and local visual search depend on the matching assets being installed.

  • Download visual model — installs the optional local ViT-B-32 SigLIP2 256 semantic search model. The download is large and is stored in the user asset directory, not bundled into Smriti.
  • Index visual search — creates per-library image vectors under <library>/.photovault/semantic. It also requires the ONNX Runtime installed by Download assets. It is resumable; rerun it after adding new files.
  • Recheck visual search — refreshes the status counts shown in Settings.

Once installed and indexed, Search can match text such as beach sunset against image meaning, and Photo Detail can show visually similar photos.

Face Recognition

  • Face confidence — minimum detection score for a face to be stored. Lower values catch more faces but also more false positives.
  • Clustering threshold — how tolerant the system is when grouping visually similar faces. Lower means tighter clusters (more groups, cleaner), higher means looser (fewer groups, more mixing). Default works for most libraries.

See the People and Faces guide for the full pipeline.

Burst detection

  • Burst window — time window (in seconds) used to group burst shots. Default 3 seconds matches most camera apps.

Photo stacks reuse burst and duplicate detection results. After either detector runs, Smriti refreshes stack suggestions and chooses a best photo using the detector’s suggestion plus quality signals such as resolution, file size, faces, sharpness, and exposure.

Trash

  • Auto-delete after — days before trashed photos are permanently removed. Default 30.

Map

  • Tile cache size — how much disk space tile caching can use. Clearing the cache forces a redownload on next view.
  • Home city override — used for trip-detection and “photos from this trip” grouping.

Memories

  • Enable memories — toggle the “on this day” cards in the Timeline banner and the dedicated Memories view.

Updates

  • Automatically check for updates — when enabled, Smriti queries api.github.com at most once every 24 hours to see if a new release has been published. This is opt-in and off by default. See PRIVACY.md for the full disclosure.
  • Check for updates now — run the check immediately, regardless of the automatic-check setting.

When an update is available, a banner appears at the top of the app with a Download button. What the button does depends on how you installed Smriti:

  • AppImage, portable Windows zip — downloads the new artifact, verifies its SHA256 against the published SHA256SUMS, and atomically replaces the running binary. You’ll be prompted to relaunch.
  • Windows MSI — downloads the new MSI, triggers the Windows installer with a UAC prompt. Smriti exits; relaunch from the Start menu when the install completes.
  • macOS .dmg — downloads the new .dmg and opens it. Drag the new .app into /Applications as you did for the first install.
  • System package manager (apt, brew, flatpak, winget) — the banner shows the matching upgrade command instead of self-replacing. Smriti won’t interfere with your package manager.
  • Source build — the banner suggests git pull && cargo build --release.

Maintenance actions

These are one-shot operations — Smriti doesn’t run them on a schedule.

  • Rescan Library — re-walk the drive and pick up newly added, moved, or deleted files.
  • Rebuild Faces (Full) — re-run face detection + clustering from scratch. Useful after a face-model update.
  • Check for Changes — scan for moves and deletions without re-indexing unchanged files.
  • Regenerate Thumbnails — rebuild all three thumbnail tiers.
  • Refresh Capture Dates — re-read capture dates for photos and videos. Smriti prefers embedded metadata, then strict filename dates, and uses file modified time only as a marked fallback.
  • Asset Inventory — inspect which local model/runtime/data files are present and which exact paths Smriti resolved.
  • Download Assets — downloads the optional Smriti asset pack from the GitHub release and installs it into the app’s managed asset folder.
  • Fix Rotated Photos — regenerate cached data for photos whose EXIF orientation wasn’t previously applied.

Keyboard shortcuts

See the Keyboard shortcuts guide for the full list. Settings also renders an inline reference at the bottom.

Keyboard Shortcuts

Full reference of every keyboard shortcut in Smriti. The in-app overlay (press ? anywhere) shows a condensed version of this list.

Anywhere

KeyAction
?Toggle the keyboard-shortcuts overlay
/Focus the search bar
EscBack · close overlay · cancel selection
Ctrl/Cmd+,Open Settings
Ctrl/Cmd+19Jump to sidebar section 1–9
Tab · Shift+TabMove focus between cards / controls

Timeline (and any grid view)

KeyAction
Move highlight
EnterOpen the highlighted photo in the viewer
SpaceToggle selection on the highlighted photo
Shift+clickRange-select
Ctrl/Cmd+clickToggle a single photo’s selection
Ctrl/Cmd+ASelect all photos currently in view
PageUp · PageDownScroll a viewport
Home · EndJump to first / last
Delete / BackspaceMove selection to Trash

Photo viewer

KeyAction
· Previous · next photo
EscBack to gallery
IToggle the info panel
+ · Zoom in · out
0Fit to screen
1Actual size (1:1 pixel)
[ · ]Rotate left · right
FToggle fullscreen
SpaceToggle selection on this photo

Slideshow

KeyAction
SpacePlay · pause
· Previous · next
EscExit slideshow
FToggle fullscreen

People & Person Detail

KeyAction
EnterOpen the highlighted person
MOpen the merge-person dialog (in Person Detail)
ROpen the rename dialog (in Person Detail)

Face review

When stepping through ambiguous face assignments:

KeyAction
YConfirm — yes, this is the suggested person
NReject — no, queue for a different person
SpaceSkip — decide later
Previous face
Next face

Map

KeyAction
+ · Zoom in · out
Pan
EscClose pin popover

See also

  • Timeline — where most shortcuts apply
  • Settings — preferences that affect navigation

FAQ

Does Smriti upload my photos?

No. Smriti is offline-first and processes media locally. The app never sends your photos, thumbnails, EXIF data, face embeddings, or anything derived from them to a server. The only network touchpoints are:

  • Map tiles from OpenStreetMap when the Map view is open.
  • Optional asset-pack download (ONNX runtime + ML models + GeoNames) from the project’s GitHub releases, only if you accept the first-run installer.
  • Optional update check against api.github.com, only if you opt in from the first-run prompt or Settings.

See PRIVACY.md for the full disclosure.

Where is my database stored?

On the indexed drive inside .photovault/photovault.db. That keeps the library fully portable — unplug the drive, plug it into another machine, and Smriti can resume.

Can I use Smriti without internet?

Yes, after the optional assets are installed. Once the asset pack is in place, everything — scanning, face recognition, clustering, search, map (for previously-cached tiles), insights — works offline.

How do updates work?

Smriti can check for new releases automatically, but the check is opt-in — disabled by default. On first run a prompt asks whether you want it; you can change the answer later in Settings → Advanced.

When an update is available and you click Download in the banner, what happens depends on how you installed Smriti:

  • AppImage or portable Windows zip — Smriti downloads the new artifact, verifies its SHA256 checksum, and atomically replaces the running binary. You’ll be prompted to relaunch.
  • Windows MSI — triggers the Windows installer with a UAC prompt; relaunch from the Start menu when it completes.
  • macOS .dmg — opens the downloaded .dmg; drag the new .app into /Applications as you did for the first install.
  • Package manager (apt, brew, flatpak, winget) — the banner shows the matching upgrade command instead of self-replacing. Smriti won’t interfere with your package manager.

No photo data or telemetry leaves your machine during the check — only a standard User-Agent and your IP, which are implicit in any HTTP request. See PRIVACY.md.

Why does my face-recognition pass take a long time?

Face detection runs ONNX models on the CPU by default. A 10K-photo library typically takes 5-20 minutes on a modern laptop. Progress is shown in the People view. The pass is resumable — close and re-open the app, it picks up where it left off.

Why don’t I see results for very large libraries (>250K photos)?

The v1.0 release ships with a load cap of 250,000 photos per drive to keep memory use bounded. Libraries larger than that are on the post-v1.0 roadmap (full cursor-based streaming from the database); for now, you can split the library across multiple indexed drives to stay under the cap.

I built from source — is auto-update still safe?

Smriti detects source builds by looking at the executable path (anything under a target/debug or target/release directory). For source builds, the update banner won’t try to self-replace the binary; it just suggests git pull && cargo build --release.

Something’s broken. Where do I report it?

Use the issue tracker with the bug-report template, which asks for the info that helps most (OS, version, install method, logs). For questions or feature ideas, prefer Discussions.

Security vulnerabilities go through GitHub Security Advisories privately — see SECURITY.md.

Troubleshooting

Common issues and how to resolve them. If something here doesn’t match what you’re seeing, open a GitHub Discussion with your OS, Smriti version, and the symptom.

Face features are disabled

Symptom: The People view says face recognition is unavailable, or the asset-health banner on the Welcome screen flags missing face models.

Causes & fixes:

  1. Asset pack not installed. Run Settings → Reinstall Assets. This downloads the ONNX runtime, face detection + embedding models, and the GeoNames database. Requires internet for the one-time download.
  2. Asset download was interrupted. Re-run Reinstall Assets — it’s idempotent and resumes from where it stopped.
  3. Disk space. The pack is ~250 MB. Free up space and retry.
  4. Source build without assets. Run ./scripts/setup_assets.sh (Linux/macOS) or .\scripts\setup_assets.ps1 (Windows) at the repo root.

The scan is stuck

Symptom: Progress bar hasn’t moved in several minutes.

Causes & fixes:

  1. Face detection is the slow step. Once metadata extraction completes, face detection runs as a separate pass that can take 30 minutes to several hours on CPU for a large library. The progress reads “0 / N faces” while it’s working its way through. Don’t close the app — close-and-resume works but the pass starts over for the current photo. See the GPU bridge doc for a 10–70× speedup via free Kaggle/Colab.
  2. A single huge file is hanging the decoder. If progress restarts on a specific file every time, that file may be corrupt or unsupported. Move it out of the library and rescan.

Map tiles aren’t loading

Symptom: The Map view shows a grey background or “Failed to load tile” placeholders.

Causes & fixes:

  1. No internet. Map tiles come from tile.openstreetmap.org on first view. Without internet, only previously-cached regions render.
  2. Cache size cap reached. Settings → Map → Tile cache size limits cache size; older tiles are evicted. If you’ve heavily panned, the most-distant tiles got dropped. Increase the cap or re-pan to reload.
  3. OSM rate-limiting. Excessive panning in a short window may trigger temporary throttling. Wait a few minutes.

Photos aren’t showing up after I added them

Symptom: New photos copied into the library folder don’t appear in the Timeline.

Fix: Smriti doesn’t watch the filesystem for new files. Run Settings → Maintenance → Rescan Library (full rescan) or Check for Changes (faster incremental).

A face is in the wrong cluster

Fix:

  1. Open the Person view that has the wrong photo.
  2. Click the photo’s face thumbnail in the person’s face strip.
  3. Choose Reassign → pick the correct person, or Remove from this person to send it back to the unknown queue.

For “the same person is split across multiple clusters,” open one of the clusters and click Merge person to combine them.

The wrong city shows for a photo

Fix: Smriti resolves GPS → city/country via the local GeoNames database. If the resolved city looks wrong:

  1. Check the photo’s EXIF GPS — it may be wrong at the source.
  2. If GPS is correct but the city is wrong, the GeoNames data may not have the specific village/town. Smriti falls back to the nearest admin-seat city by population. Open an issue if the fallback is unhelpful.

Build fails on Linux due to missing system libraries

Install the GUI toolkit headers:

sudo apt-get install libxkbcommon-dev libwayland-dev \
                     libxcb-shape0-dev libxcb-xfixes0-dev

See BUILD.md for the full list.

Windows SmartScreen warned me before launching Smriti

Smriti’s installer isn’t code-signed (code-signing certs are paid). SmartScreen warns on any unsigned executable.

Workaround: Click More infoRun anyway. The SHA256SUMS file alongside the release verifies the download is genuine.

macOS Gatekeeper warned me before launching Smriti

Smriti isn’t notarized (Apple Developer ID costs $99/year, currently deferred). Gatekeeper warns on any non-notarized app.

Workaround: Right-click the app → OpenOpen anyway. Or in System Settings → Privacy & Security, click Open Anyway after the first launch attempt.

My library is huge — Smriti only shows 250K photos

Symptom: Smriti caps display at 250,000 photos per library.

Workaround for now: Split the library across multiple indexed drives, each under the cap. Cursor-based streaming for arbitrarily large libraries is on the post-1.0 roadmap.

Updates aren’t checking

Symptom: Settings → Check for updates now does nothing visible.

Causes:

  1. The update check is opt-in and off by default. Enable Automatically check for updates in Settings first.
  2. If you’re on a source build, Smriti detects this from the executable path and won’t try to self-replace. The update banner suggests git pull && cargo build --release instead.
  3. If you installed via a system package manager (apt, brew, flatpak, winget), the update banner shows the upgrade command instead.

See also

Build from Source

Smriti builds natively on Linux, Windows, and macOS.

Prerequisites

Linux

  • Rust toolchain (stable, MSRV 1.88+) via rustup
  • Node 20+ and npm (the frontend is Vite + Svelte 5)
  • Tauri build deps. On Ubuntu/Debian:
sudo apt-get update
sudo apt-get install -y \
  libwebkit2gtk-4.1-dev \
  libgtk-3-dev \
  libayatana-appindicator3-dev \
  librsvg2-dev \
  libsoup-3.0-dev \
  libjavascriptcoregtk-4.1-dev \
  pkg-config \
  libheif-dev          # only if you want HEIC decoding

Windows

  • Rust toolchain via rustup
  • Node 20+ via the official installer or winget install OpenJS.NodeJS
  • Visual Studio Build Tools (Desktop development with C++) — provides the MSVC linker that cargo build and Tauri’s bundler both need.
  • WebView2 Runtime — preinstalled on Windows 11; auto-downloaded by Tauri on Windows 10 if missing.

macOS

  • Xcode command line tools (xcode-select --install)
  • Rust toolchain via rustup
  • Node 20+ via Homebrew (brew install node) or the official installer

One-time tooling

cargo install tauri-cli --version "^2" --locked

Smriti now ships a small core application. Face recognition and offline geocoding use an optional asset pack (ONNX runtime + models + geonames DB) that can be installed in-app with one click.

Manual setup scripts are still available:

Linux/macOS:

./scripts/setup_assets.sh

Windows PowerShell:

powershell -ExecutionPolicy Bypass -File scripts\setup_assets.ps1

Both scripts are idempotent and useful for local development/testing.

Build and run

Smriti is a Tauri 2 app: the Rust engine + Tauri shell live in src/ and src-tauri/, the Svelte 5 frontend lives in src-ui/. The cargo tauri CLI orchestrates both halves.

Dev mode (hot reload via Vite, opens a native window):

cd src-ui && npm install && cd ..   # one-time
cargo tauri dev

Production bundle (.deb + AppImage on Linux, .msi on Windows, .dmg / tar.gz on macOS):

cargo tauri build

Engine-only build (useful when iterating on src/ without a window):

cargo build -p smriti -p smriti-tauri

Test and lint

cargo fmt --check
cargo clippy --all-targets -p smriti -p smriti-tauri -- -D warnings
cargo test -p smriti -p smriti-tauri
(cd src-ui && npm run check && npm run build)

Linux packaging (.deb + AppImage)

The official path is cargo tauri build — it produces both .deb and AppImage in src-tauri/target/release/bundle/. Tag-driven CI (.github/workflows/release.yml) runs the same command on a fresh Ubuntu runner for every v* tag.

For a hand-built .deb outside Tauri’s bundler:

cargo install cargo-deb --locked
cargo deb

Output:

  • target/debian/*.deb

Install test:

sudo dpkg -i target/debian/*.deb
smriti

Uninstall test:

sudo apt remove smriti

Linux AppImage

AppImage is built in CI from release tags (v*) via .github/workflows/release.yml. Current output name:

  • Smriti-x86_64.AppImage

Run:

chmod +x Smriti-x86_64.AppImage
./Smriti-x86_64.AppImage

Windows packaging (MSI + ZIP)

MSI packaging should run on native Windows with MSVC + WiX installed.

Prerequisites

  • WiX Toolset v3 (for candle.exe and light.exe)
  • cargo-wix:
cargo install cargo-wix --locked

Build portable ZIP payload (core app)

cargo build --release --target x86_64-pc-windows-msvc

Build MSI installer

From repository root:

cargo wix --target x86_64-pc-windows-msvc --output target\wix\Smriti-Setup-x64.msi

Output:

  • target\wix\Smriti-Setup-x64.msi

MSI installs the core app. Optional assets are installed separately.

Troubleshooting

  • Optional assets not detected: install via startup prompt, or run setup script and restart app.
  • GUI linking errors on Linux: install missing Wayland/X11 dev packages listed above.
  • Slow Windows builds on UNC path: expected; primary development should happen in WSL.

Local installer verification scripts

Linux/macOS shell:

./scripts/verify_installers_local.sh

Windows PowerShell:

powershell -ExecutionPolicy Bypass -File scripts\release_local.ps1 -Mode full

These scripts summarize installer packaging status locally before you create release tags.

Mandatory push gate before any git push:

cargo fmt --all --check
cargo clippy --all-targets
cargo test --no-run

Automated release orchestration (draft-only publish flow)

From Windows PowerShell, once local checks are green:

powershell -ExecutionPolicy Bypass -File scripts\release_publish.ps1 -Version v0.1.0-rc3 -RunLocalChecks -Wait

Behavior:

  • validates repo state and branch sync
  • optionally runs local verification first
  • creates and pushes the release tag
  • waits for release.yml workflow
  • verifies expected assets in the GitHub draft release
  • prints draft release URL for manual publish

By design, this script does not auto-publish the release.

Asset installer URL behavior

In-app optional asset installation resolves in this order:

  1. PHOTOVAULT_ASSET_PACK_PATH (local zip path override)
  2. PHOTOVAULT_ASSET_PACK_URL (custom URL override)
  3. latest published release URL (.../releases/latest/download/Smriti-Assets.zip)
  4. version-pinned fallback (.../releases/download/v<app-version>/Smriti-Assets.zip)

If you are testing locally before publishing a release, set PHOTOVAULT_ASSET_PACK_PATH.

Testing in Smriti

This document explains how Smriti is tested and how you should test the code you add. Tests are the contract: if your PR’s tests pass, your code lands; if they fail, the failure is the conversation.

Pyramid

                ┌──────────────────────────────┐
   Level 4      │  Frontend unit (Vitest)      │   per util / per store
                └──────────────────────────────┘
                ┌──────────────────────────────┐
   Level 3      │  Workflow integration (Rust) │   one per critical user journey
                └──────────────────────────────┘
                ┌──────────────────────────────┐
   Level 2      │  DTO snapshots + service     │   wire-format + business logic
                │  integration (Rust)          │
                └──────────────────────────────┘
                ┌──────────────────────────────┐
   Level 1      │  Unit tests (Rust + TS)      │   per pure function
                └──────────────────────────────┘

All four levels run on every PR. CI blocks merge if any test fails.

What we don’t test (and why)

  • The Tauri runtime itself. Commands like library_close are thin wrappers: state.library.write().await; *guard = None;. Testing through Tauri’s IPC layer needs mock_app(), which on some platforms (Windows specifically) hits Tauri runtime initialization bugs in the test binary. Instead, we test:

    • The engine service the command wraps (Level 3 workflow tests)
    • The DTO it returns (Level 2 snapshot tests)
    • The args struct it takes (free via serde tests if anything funky)

    This means commands should be thin: the body is one call to a service. If you find yourself writing branching logic inside a #[tauri::command], push that logic down into a service and call the service from the command. The service tests cover it.

  • End-to-end browser flows. No tauri-driver / WebDriverIO yet. The pyramid above is robust enough that runtime bugs are caught at Level 3 before they hit the UI. We may add browser tests later for the most-trafficked workflows, but they’re not a contributor obligation today.

Where tests live

tests/                                # Engine integration tests
├── common/mod.rs                     # Shared helpers (image gen, library factory)
├── common_smoke.rs                   # Smoke tests for the helpers themselves
├── db_integration.rs                 # DB schema, photo repo, queries
├── face_clustering_integration.rs    # Face pipeline w/ synthetic embeddings
├── thumbnail_pipeline.rs             # Thumbnail gen across formats
├── timeline_scale.rs                 # 50k-photo SLA test (ignored by default)
└── workflow_*.rs                     # One file per critical user journey

src-tauri/tests/
└── dto_snapshots.rs                  # Wire-format contract — every From<E> for Dto

src-ui/src/lib/
├── slideshowQueue.test.ts            # Vitest — pure logic
└── thumbnailQueue.test.ts            # Vitest — pure logic

Conventions

Hard rules (CI-enforced)

  1. cargo fmt --all --check passes.
  2. cargo clippy --all-targets -D warnings passes.
  3. cargo test passes (engine + Tauri integration tests).
  4. npm run check passes (Svelte + TypeScript type-checking).
  5. npm run test passes (Vitest).
  6. DTO snapshot changes are intentional. When dto_snapshots.rs fails, you must either revert the unintended change or run cargo insta review to accept the new wire format. The diff must be reviewed by a maintainer.

Soft rules (reviewers apply)

  • Test names follow <subject>_<scenario>_<expected>. E.g. geocoding_rural_photo_returns_none, slideshow_next_after_last_when_loop_off_pauses. A reviewer reading test names alone should understand what’s being checked.
  • Arrange / Act / Assert with comments separating the three. Long setup gets its own helper in tests/common/mod.rs.
  • Hermetic. No shared mutable state. No tests that depend on test order. tempfile::TempDir for the filesystem; in-memory SQLite or per-test tempdir for the DB.
  • No thread::sleep for synchronisation. Use channel sends, atomic flags, tokio::time::timeout. A test that sleeps will flake under CI load.
  • Rust: prefer assert!(... , "<why>") — the message shows up in the failure output and saves a reviewer from git blame.
  • **TS: prefer expect(...).toEqual(...) with descriptive describe
    • it text** that reads like a sentence.

Decision tree: “what kind of test do I write?”

Did you add a pure function?
├── yes → unit test next to it (same file's `#[cfg(test)] mod tests`
│         for Rust, sibling `.test.ts` for TypeScript).
└── no  → continue
Did you add a `#[tauri::command]`?
├── yes → make sure the body is a single call to a service. Test
│         the service in `tests/workflow_*.rs` or via direct
│         service-level unit test. If the command returns a new DTO
│         shape, add a snapshot in `src-tauri/tests/dto_snapshots.rs`.
└── no  → continue
Did you add a new service or change an existing service's contract?
├── yes → integration test in `tests/` that constructs a tempdir
│         library, exercises the service, asserts DB / file state.
└── no  → continue
Did you add or rename a `From<EngineType> for SomethingDto`?
├── yes → add a snapshot test in dto_snapshots.rs. Construct the
│         engine type with deterministic values, convert, snapshot.
└── no  → continue
Did you fix a bug?
└── yes → add a regression test that fails *before* the fix and
          passes after. Reviewer will check this is true by reverting
          your fix locally and re-running the test.

Running tests

# Everything CI runs:
scripts/ci_local.sh ci

# Just the Rust suite (engine + tauri):
cargo test -p smriti -p smriti-tauri

# Just the engine:
cargo test -p smriti

# A single test file:
cargo test -p smriti --test workflow_scan_pipeline

# Just one test by name:
cargo test -p smriti scanner_picks_up_supported_extensions

# Vitest (frontend):
cd src-ui && npm run test            # one-shot
cd src-ui && npm run test:watch      # repl

# When a snapshot intentionally changes:
cargo insta review                   # interactive, per-snapshot
# OR:
INSTA_UPDATE=always cargo test       # accept all

Fixtures

  • Synthetic images are built in-memory by helpers in tests/common/mod.rs. The test runner generates a valid JPEG / PNG / TIFF in a tempdir. No license issues, no large files in git.
  • No real photo fixtures yet. If you need to test a real-RAW edge case, prefer a synthetic NEF (via common::write_nef) over shipping a 30 MB binary in the repo. We may add a small set of CC0-licensed reference photos later if synthetic generators don’t cover a real scenario.

When tests are slow

If your test takes longer than ~1 second, ask:

  • Is it doing actual image decoding? Try a 16×16 image, not 4000×3000.
  • Is it spawning subprocesses? Don’t.
  • Is it sleeping? See “no thread::sleep” above.
  • Is it the only test in its target? Consider grouping related tests into one integration file — each tests/<name>.rs is a separate binary that links the whole engine, and linking is expensive.

The 50k-photo timeline_scale.rs test is marked #[ignore] so it doesn’t run by default. Heavyweight benchmarks live in benches/ and run only nightly.

Snapshot tests in detail

src-tauri/tests/dto_snapshots.rs contains one test per From<EngineType> for SomethingDto impl in src-tauri/src/dto.rs. The first time you run it, insta writes .snap.new files into src-tauri/tests/snapshots/. Run cargo insta review to accept them; the accepted files get committed.

When you change a DTO field (rename, type change, addition, removal), the test for that DTO fails with a unified diff showing old vs new. The contributor reviews their own change, runs cargo insta review if intentional, and commits the updated .snap file alongside the source change. The reviewer reads the .snap diff in the PR to sanity-check the wire-format implications at a glance.

If a DTO change isn’t intentional (a typo, an accidental field-rename during a refactor), the failing test catches it before the frontend breaks at runtime.

Contributing to Smriti

Welcome — and thank you for considering a contribution. Smriti’s contribution flow is built around tests as the contract. If your PR’s tests pass, your code lands. If they don’t, the failure is the conversation, not a code-style debate.

Before you open a PR

  1. Read TESTING.md. It tells you what kind of test to write for what kind of change. The decision tree is the thing you should optimise for.
  2. Run scripts/ci_local.sh ci locally. This mirrors the core build/test path from GitHub Actions. The hosted workflow also runs the supply-chain audit job.
  3. Set up the dev workflow: see agents.md for one-time toolchain installation (Rust, Node, WSL on Windows).

Repository structure quick-tour

src/                 # The engine crate (`smriti`). Pure Rust, no Tauri.
src-tauri/           # The Tauri shell crate (`smriti-tauri`). Thin
                     # wrappers around services — every command is
                     # just `let r = service::do_x(...); Ok(r.into())`.
src-ui/              # Svelte 5 frontend. Talks to Rust via Tauri IPC.
docs/                # CONTRIBUTING.md, TESTING.md (you are here).
tests/               # Engine integration tests + helpers.
benches/             # Criterion benchmarks. Not run on PRs.

What CI runs on every PR

1. cargo fmt --check
2. cargo clippy --all-targets -D warnings
3. cargo test -p smriti -p smriti-tauri
4. npm run check --prefix src-ui     # svelte-check + typescript
5. npm run test  --prefix src-ui     # vitest
6. cargo audit + cargo deny           # supply-chain, GitHub CI only

Each is a hard gate. Total wall time on a runner: ~10-15 minutes.

What “tests” you owe

The PR template asks you to tick which tests you added. Pick from:

  • Unit test — for a new pure function. Lives next to the code (#[cfg(test)] mod tests in Rust, sibling .test.ts for TS).
  • DTO snapshot — for a new or changed From<EngineType> for SomethingDto. Add to src-tauri/tests/dto_snapshots.rs and run cargo insta review to accept the new snapshot.
  • Service / workflow integration — for new service logic or changes that affect a user journey (scan, geocode, face detect, search, trash, etc.). Add to tests/workflow_<journey>.rs if it doesn’t already exist.
  • Regression test — for a bug fix. The test must fail before your fix and pass after. Reviewer verifies by reverting your fix locally.

If you can’t tick any of these, the PR template asks you to explain why. Common valid reasons: “docs-only”, “renamed an internal symbol with no public effect”. Suspicious reasons: “tests were too hard to write” — that usually means the design is too tangled and the fix is to detangle, not skip tests.

Style: low-level

  • Rust: cargo fmt. Clippy is configured to deny warnings. Match the surrounding code’s idiom — comments explain why, not what. Match the existing async/await pattern (tokio, no blocking calls inside async fn).
  • TypeScript / Svelte: follow svelte-check; type things precisely. Prefer $state/$derived over manual subscription.
  • Commit messages: imperative, summary line < 70 chars. Body wrapped at 72. Explain the why in the body.

Style: high-level

  • Add only what the task requires. No premature abstractions, no half-finished alternatives. If you find yourself adding scaffolding “for the future feature”, the future feature can add its own scaffolding when it lands.
  • Tauri commands are thin. If you write branching logic inside a #[tauri::command], push it down into a service. Commands exist for IPC plumbing; services exist for behaviour. Tests live with services.
  • Don’t add dependencies casually. Each new dep is a long-term maintenance burden + supply-chain surface. A standalone Rust module in src/ is cheaper than a crate that does 90% of what you need but pulls in 30 transitive deps.

Reviewer’s playbook (for the maintainer)

  1. CI is green? If no, the PR is blocked until it goes green.
  2. PR template ticked? Confirm each ticked box is true by looking at the diff:
    • “Unit test added” → there’s a new #[test] or a new it(...) in a .test.ts.
    • “DTO snapshot added” → there’s a new .snap file under src-tauri/tests/snapshots/.
    • “Workflow test” → there’s a new file or function under tests/workflow_*.rs.
  3. Read the diff in the test files first. Tests describe intent; if you can’t tell what the production code does after reading its tests, that’s a review comment.
  4. Snapshot diffs in *.snap files: read them as wire-format contracts. A field renamed in a snapshot is a frontend break waiting to happen — make sure src-ui/src/lib/api/types.ts updated to match.
  5. Merge when the diff is small enough that you can hold it in your head + all tests are green. If it’s not, ask for a split.

Getting help

  • Open an issue with the bug-report template (which asks for a failing test snippet — see .github/ISSUE_TEMPLATE/).
  • For larger design questions, open a discussion first; PR after rough alignment.

Linux Release Guide (Ubuntu + AppImage)

This is the canonical maintainer flow for Linux releases.

Scope

Supported Linux artifacts:

  • Smriti-ubuntu-amd64.deb
  • Smriti-x86_64.AppImage

Published by tag-driven CI workflow:

  • .github/workflows/release.yml

CI also runs package smoke checks on tag releases:

  • .deb install + launch smoke test
  • AppImage extract/layout check + launch smoke test

1) Pre-release checks

Run from repo root:

cargo fmt --all --check
cargo check
cargo test

Optional:

cargo clippy --all-targets

2) Local Linux packaging smoke tests

2.1 Build Ubuntu/Debian package

./scripts/release_local.sh ubuntu

Expected output:

  • target/debian/*.deb

Install test:

sudo dpkg -i target/debian/*.deb
smriti

2.2 Build AppImage

./scripts/release_local.sh linux-appimage

Expected output:

  • Smriti-x86_64.AppImage

Run test:

chmod +x Smriti-x86_64.AppImage
./Smriti-x86_64.AppImage

3) Publish Linux artifacts via CI

Create and push a release tag:

git checkout master
git pull origin master
git tag -a vX.Y.Z -m "Smriti vX.Y.Z"
git push origin vX.Y.Z

CI will create a draft release with assets.

4) Verify release assets

Ensure draft release contains at least:

  • Smriti-ubuntu-amd64.deb
  • Smriti-x86_64.AppImage
  • SHA256SUMS

Note: Draft release creation is blocked unless Linux smoke-test jobs pass.

Verify checksums:

sha256sum -c SHA256SUMS

5) Publish release

Open draft release, review notes, publish.

6) Post-release checks

Validate website links:

  • Ubuntu .deb: /releases/latest/download/Smriti-ubuntu-amd64.deb
  • AppImage: /releases/latest/download/Smriti-x86_64.AppImage

7) Troubleshooting

If app starts but models/GeoNames are not found:

  • verify assets are inside package/AppImage
  • verify SMRITI_ASSET_DIR is set in AppImage AppRun
  • verify runtime path resolution handles /usr/lib/smriti (and the legacy /usr/lib/photovault fallback for upgrading users)

Architecture Overview

Smriti is a desktop app split across three crates / workspaces:

  • src/ — pure Rust engine, published as the smriti library.
    • src/services/ — scanner, thumbnailer, face pipeline, duplicates, bursts, geocoding, update checker.
    • src/db/ — SQLite repositories, schema, migrations. The on-drive database lives at <drive>/.photovault/photovault.db (kept under that name for backwards compatibility with libraries indexed before the rename).
    • src/ml/ — ONNX Runtime wrapper, face detector, embedder.
    • src/config/, src/models/, src/scoring/, src/search/ — config, domain types, image-quality scoring, query parsing.
    • src/bootstrap.rs — runtime asset checks (ONNX runtime, models, GeoNames DB).
  • src-tauri/ — Tauri 2 shell. One bin crate that mounts every #[tauri::command] from src-tauri/src/commands/. The contract is in docs/COMMAND_SURFACE.md. DTOs live in src-tauri/src/dto.rs and translate one-way from engine types.
  • src-ui/ — Vite + Svelte 5 frontend. Routes call into the typed Tauri client at src-ui/src/lib/api/; state lives in runes-based stores under src-ui/src/lib/stores/.

The engine has no UI dependencies and is independently testable. The Tauri shell is a thin adapter; anything more than ~15 lines of logic in a handler is a service-layer change, not a handler change.

Database

Smriti uses a single SQLite database per library, stored at <library>/.photovault/photovault.db. Access goes through rusqlite with a small Database wrapper and per-table repository modules in src/db/.

Where it lives

The DB sits on the same drive as the photos. This is deliberate: the library is portable — unplug the drive, plug it elsewhere, open Smriti, point it at the new mount path, and everything resumes. Faces, albums, tags, search history all travel with the photos.

(The folder is named .photovault/ for backwards compatibility with libraries indexed under the project’s earlier name.)

Migrations

Schema migrations live in src/db/migrations/. Each migration is an SQL file; Database::open applies any unapplied migrations in order. There’s no “rollback” — migrations are forward-only and must remain idempotent across versions.

Core tables

TableWhat it stores
photosThe canonical photo record: path, hash, EXIF, GPS, dates, dimensions.
albumsManual + accepted-suggestion albums.
album_photosJoin table; ordered for manual album reordering.
album_suggestionsDetected-but-not-accepted trip / event suggestions.
facesPer-photo detected faces: bbox, embedding, quality score.
clusters (a.k.a. persons)Named identity groups; one row per person.
face_assignmentsWhich face belongs to which cluster, with confidence.
duplicatesExact and perceptual duplicate groups.
burstsDetected burst groups with timestamps.
photo_stacks / photo_stack_membersTimeline stack suggestions derived from duplicate and burst groups, with cover photo and per-member quality scores.
memory_blocksUser dismissals for surfaced memories.
trash (via photos.is_trashed)Soft-deleted photos.
search_historyRecent search queries, capped.

Conventions

  • All tables use INTEGER PRIMARY KEY row IDs (SQLite’s implicit rowid aliases).
  • Timestamps are stored as RFC-3339 strings in UTC.
  • File paths are stored as relative_path from the library root, so moving the library to a new mount point doesn’t break references.
  • Soft-delete is the default — is_trashed = TRUE on photos rather than DELETE FROM photos. Permanent deletion is a separate explicit action.

Repositories

src/db/<entity>_repo/ modules provide typed access to each table. Repos are split into read.rs (queries) and write.rs (mutations) so the read side can be borrowed independently from the write side when needed.

Engine code uses repos directly; the Tauri shell never touches the DB directly — it goes through the engine services.

Concurrency

SQLite is opened with WAL mode for concurrent read-while-write performance. Smriti spawns one writer task and many reader tasks. The async scanner and frontend reads coexist through the WAL.

Backup

The library folder including .photovault/ is the entire database. Standard file-level backup works. SQLite is robust to crash — WAL ensures the DB stays consistent across power loss.

See also

  • Indexing — what populates these tables.
  • Services — the layer that reads / writes them.

Services Layer

Services live in src/services/ and encapsulate domain workflows. Each service is a pure Rust module — no UI dependencies, no Tauri dependencies, no async runtime baked in unless the workflow demands it. Services are the unit of integration testing in tests/.

Service responsibilities

ServiceRole
scannerWalks the library, dispatches per-photo work. Async, cancellable.
metadata_processorEXIF + GPS + file-hash extraction per photo.
exif_extractorPulls and normalises EXIF tags.
thumbnailThree-tier thumbnail generation, lazy + cached.
image_ioDecodes JPEG/PNG/WebP/HEIC + RAW preview extraction.
raw_previewExtracts embedded JPEG previews from TIFF-based RAWs.
geocodingResolves GPS → city/country via local GeoNames DB.
face_processorDetection + embedding + clustering orchestration.
searchTranslates SearchQuery → SQL → result rows.
insightsAggregates per-day / per-month / per-person counts.
memories“On this day” / fallback / seasonal generators + ranker.
album_suggestionsTrip and event detection from geographic + temporal clusters.
burst_detectorTime-window-based grouping with similarity gate.
duplicate_detectorSHA-256 exact + DCT-pHash perceptual dedup.
photo_stacksConservative timeline stack generation from duplicate/burst results, including best-photo scoring.
trashSoft-delete, restore, permanent-delete, auto-delete.
tile_cacheLRU-managed cache for OpenStreetMap tiles.
update_checkerOpt-in version check against the GitHub releases API.
self_replaceAtomic binary swap for AppImage / portable Windows.
library_healthAsset-pack presence, DB integrity, model availability.
path_utilCross-platform path normalisation.
reindexerDetects moves, deletions, and changed files.
camera_namesNormalises EXIF make/model into human-readable names.
image_utilsMisc image-processing helpers (rotation, resize).
map_mathCluster math for the map view.
drive_detectorIdentifies the indexed drive’s filesystem ID.
install_methodDetects how Smriti was installed (apt, brew, msi, …).
document_detectorHeuristic detection of scanned-document photos.
ocr_processor(Reserved) OCR pipeline scaffolding.

Boundary discipline

  • Services accept primitive types or &rusqlite::Connection, not Tauri AppState. This keeps them testable without launching a runtime.
  • Tauri command handlers in src-tauri/src/commands/ are thin wrappers: deserialize args, call the service, serialize the result. Anything over ~15 lines in a handler is a sign the logic should move down to a service.
  • Engine services never call back into Tauri. The frontend invokes commands; commands invoke services.

Testing

Each service has a #[cfg(test)] mod tests block alongside it for unit tests, plus integration tests in tests/workflow_*.rs that exercise multi-service workflows (scan → metadata → thumbnail → faces, trash lifecycle, etc.). See Testing.

ML Pipeline

Smriti’s face-recognition pipeline runs entirely on-device by default, with an opt-in remote-GPU bridge for the embedding step. All models ship as ONNX and are loaded via the ONNX Runtime crate.

Pipeline stages

photo  ──►  detection  ──►  alignment  ──►  embedding  ──►  clustering  ──►  identity
           (SCRFD)          (112×112)        (AdaFace /         (gallery +     (per-person
                                              GLinTR-100)        agglom.)       clusters)

1. Detection

SCRFD locates faces in each photo and produces:

  • Bounding box,
  • 5-point landmark set (eyes, nose tip, mouth corners),
  • Detection score.

Only faces above the face-confidence threshold (configurable in Settings) are retained.

2. Alignment

Each detected face is rotated and scaled to a canonical 112×112 crop using the landmarks. The crop is what the embedder sees — the embedder doesn’t see the original photo.

3. Embedding

A face-recognition model produces a 512-dimensional unit vector that captures identity:

  • Default: AdaFace IR-101 trained on WebFace12M.
  • Alternative: GLinTR-100 — selectable in Settings.

Embeddings from different models are not interchangeable. Smriti refuses to mix them in the same library.

Embedding is the compute-heavy step. The optional GPU bridge offloads this to a free Kaggle/Colab notebook for a 10–70× speedup on the one-time pass.

4. Clustering

A two-stage process:

  1. Gallery retrieval: each new face is compared against the gallery of existing per-person clusters via k-nearest-neighbor match with confidence bands. High-confidence matches auto-assign; ambiguous matches are queued for review; low-confidence faces drop to stage 2.
  2. Agglomerative complete-link clusters the remaining unresolved faces pairwise. Bounded at 2,000 faces per pass for performance; beyond that, faces route to the ambiguous queue or wait for the next rescue pass. The cap will lift when HNSW-based approximate clustering replaces the current complete-link implementation.

After clustering:

  • A unify pass merges clusters that look like the same person split by lighting variance.
  • A rescue pass matches orphan clusters against the existing gallery with looser thresholds.

5. Identity

Each cluster becomes a “person” record with:

  • A hero face (highest-scoring detection),
  • An aggregated embedding centroid,
  • A confidence score per assigned face.

The user names the cluster from the People view. Naming is purely a display label — the embedding is what identifies the cluster.

Tuning

  • Face confidence (Settings) — minimum detection score to retain a face. Lower catches more faces, including more false positives.
  • Clustering threshold (Settings) — controls how aggressively faces are grouped. Lower → tighter clusters (more, smaller groups); higher → looser (fewer, larger).

Defaults work for most libraries. Adjust only if you see consistent over- or under-clustering after running a full pass.

Privacy

  • Models run locally by default. Nothing leaves your machine unless you explicitly enable the GPU bridge.
  • When the bridge is enabled, only the 112×112 aligned face crop is sent — never the original photo, EXIF, or filename.
  • Embeddings and detection metadata are stored only in your library’s local SQLite database.

Code map

  • src/ml/ — ONNX runtime wrapper, model loaders, inference glue.
  • src/services/face_processor.rs — orchestration (detection → embedding → clustering).
  • src/services/face_processor/ — submodules per stage.
  • src/db/face_repo/ — persistence of faces and clusters.

See also

Cloud GPU face acceleration (opt-in)

Smriti can offload face embedding to a free GPU notebook you own. Default behaviour is unchanged — local ONNX Runtime stays primary. The bridge is strictly additive and never required.

What it sends

Only 112×112 aligned face JPEG crops (~5 KB each, quality 85). No photos, no metadata, no telemetry. You control the endpoint URL.

Model must match

The notebook’s /health advertises which face-embedding model it has loaded. Smriti refuses to send work when the bridge’s model differs from the one configured in Settings — embeddings from different models live in incompatible metric spaces, and mixing them would corrupt clustering.

Smriti uses AdaFace (adaface_ir101_webface12m) as its face recognizer. The notebook downloads the same ONNX model as scripts/setup_assets.*; nothing to change.

Setup paths

Kaggle (free, 30 hrs GPU/week, T4×2)

  1. Open kaggle.com, create an account (free).
  2. Create a new Notebook. Choose GPU T4×2 as the accelerator.
  3. Upload notebooks/face_bridge.ipynb from the Smriti repo.
  4. Sign up for ngrok (free). Copy your auth token from the dashboard.
  5. Paste your ngrok token in the notebook’s cell 5.
  6. Run all cells. The last cell prints a public URL — copy it.

Colab (free with limits / Pro $10/mo for A100)

  1. Open colab.research.google.com.
  2. File → Upload notebook → notebooks/face_bridge.ipynb.
  3. Runtime → Change runtime type → T4 GPU (or A100 on Pro).
  4. Set up ngrok as above (or use the cloudflared alternative commented out).
  5. Run all cells. Copy the printed URL.

Local LAN GPU server (no internet)

  1. Run notebooks/face_bridge.ipynb on a local machine with a GPU.
  2. Skip the tunnel cells. The server binds to 0.0.0.0:8000.
  3. In Smriti Settings, enter http://<lan-ip>:8000 as the bridge URL.

How to connect Smriti

  1. Open Smriti → Settings → Cloud face acceleration (advanced).
  2. Toggle “Use a remote GPU for face embedding” on.
  3. Paste the bridge URL (e.g. https://abc.ngrok.io).
  4. Click Test connection → should show “✓ CUDAExecutionProvider @ XXms”.
  5. Run face detection. Embedding goes remote; detection stays local.

ngrok auth token (60-second setup)

  1. Sign up at ngrok.com (free, no credit card).
  2. Copy your authtoken from dashboard.ngrok.com/get-started/your-authtoken.
  3. Paste it in the notebook’s ngrok.set_auth_token("YOUR_TOKEN_HERE") cell.

cloudflared alternative (no account needed)

The notebook includes a commented-out cloudflared section. It doesn’t need an account but requires fetching the binary and parsing the tunnel URL from its stdout.

Automatic fallback

If the bridge is unreachable or returns errors on 3 consecutive batches, Smriti falls back to local CPU embedding for the remainder of the job. The job completes normally — just slower.

Expected speed

Library sizeLocal (i7-7567U)T4 GPUSpeed-up
10k photos (~30k faces)~5 min~30 s10×
90k photos (~200k faces)~3 hrs~2.5 min70×

Troubleshooting

Test connection says “Unreachable”

  • Check that the notebook is still running. Colab/Kaggle sessions time out after 30–90 min of inactivity.
  • Verify the ngrok URL hasn’t changed (free ngrok URLs rotate on restart).

Embedding is slow — still on CPU

  • Check the Smriti logs (Help → Open logs folder). You should see either “Remote GPU bridge at unhealthy or wrong model” or “Remote bridge model mismatch” if the bridge failed.
  • The most common cause is a model mismatch — see the Model must match section above.
  • Restart the notebook. Get a fresh ngrok URL. Update in Settings.

ngrok free plan limits

  • 1 online tunnel at a time, 40 connections/min, 1 GB/month bandwidth. Face crops at 5 KB each × 200k faces = ~1 GB. For a 90k-photo library, this fits within the free plan but is near the limit.
  • If you hit the limit, use cloudflared (no limits) or Colab Pro ($10/mo).

Privacy notes

  • Smriti sends only 112×112 face crops, not the original photos.
  • No metadata (location, filenames, EXIF) is sent.
  • No telemetry or analytics from Smriti itself.
  • The bridge URL is set by you; Smriti never phones home.
  • The notebook runs on your Kaggle/Colab account, not a shared service.

Cost

All paths are free or nominal:

  • Kaggle: Free (30 hrs/week GPU quota, resets weekly)
  • Colab free: Free (limits apply; sessions may throttle)
  • Colab Pro: $10/mo (A100 GPU, unmetered)
  • ngrok: Free (1 online tunnel, 1 GB/month)
  • cloudflared: Free (no account needed)

Future Scope

Contributor-facing roadmap for Tier 2/3/4 features from the old roadmap.

Tier 2 - Content understanding

Scene / object classification

Status: open · seeking contributors · complexity: M · skills: Rust, ML

What: Add a small ONNX classifier (10-20 MB) to tag photos with scene/object labels like food, beach, dog, indoor.

Why: Enables content search that is currently impossible without manual tags.

Technical approach:

  • Add new model wrapper in src/ml/
  • Run classification in indexing pipeline
  • Persist top labels and confidence in a new table
  • Extend search filters and result ranking

Similar photo suggestions

Status: open · seeking contributors · complexity: M · skills: Rust, retrieval systems

What: “More like this” suggestions in photo detail.

Why: Users can browse related moments beyond exact duplicates/bursts.

Technical approach:

  • Reuse perceptual or embedding vectors
  • Build nearest-neighbor lookup with exclusion rules
  • Add UI strip in photo detail

Quality-based highlights

Status: open · seeking contributors · complexity: L · skills: Rust, ML

What: “Top N of year” based on quality and relevance.

Why: Faster curation for large libraries.

Technical approach:

  • Add aesthetic scoring model (or weighted heuristic)
  • Combine with blur/sharpness + face signals
  • Expose year-based smart collections

Auto-archive suggestions

Status: open · seeking contributors · complexity: M · skills: Rust, product UX

What: Suggest likely throwaways (blur, near-duplicate, accidental shots).

Why: Reduces manual cleanup effort.

Technical approach:

  • Score candidates during indexing
  • Store reasons/confidence
  • Add review queue UI with one-click actions

Tier 3 - Quality of life

Timeline zoom levels

Status: open · seeking contributors · complexity: M · skills: Rust, UI

What: Day/month/year zoom in timeline.

Why: Improves navigation for very large libraries.

Jump-to-date scrubber

Status: open · seeking contributors · complexity: S · skills: Rust, UI

What: Right-edge date scrubber and keyboard jump shortcuts.

Why: Faster random access in long histories.

OS share integration

Status: open · seeking contributors · complexity: M · skills: Rust, platform APIs

What: Native file-sharing flows via OS integrations.

Why: Better desktop-native UX without cloud features.

Export / batch copy

Status: open · seeking contributors · complexity: M · skills: Rust, filesystem

What: Export selected photos to folder with metadata preserved.

Why: Practical handoff workflows.

Video + RAW support

Status: open · seeking contributors · complexity: L · skills: Rust, ffmpeg/libraw

What: Add thumbnails and indexing for video and RAW formats.

Why: Completeness for modern photo workflows.

Rich timeline cards

Status: open · seeking contributors · complexity: L · skills: Rust, UI

What: Panorama/live-photo/burst card experiences in timeline.

Why: More expressive browsing and richer context.

Tier 4 - Editing (deferred)

In-viewer editing

Status: open · deferred until explicit request · complexity: L · skills: Rust, image processing, UI

What: Crop/rotate/exposure/filters in photo viewer.

Why: Full workflow in one app, if product direction chooses editing.

Technical approach:

  • Non-destructive edits (save-as-copy)
  • Sidecar/DB metadata for edit history
  • GPU-friendly preview pipeline

Claiming work

  • Open or comment on a GitHub issue for the feature.
  • Mark status as claimed by @username when in progress.

Face Recognition — Future Improvement Playbook

Status today: the face pipeline is functional and accurate enough for most personal libraries. Detection → 5-point affine alignment → quality filter → GLinTR-100 embedding → gallery k-NN retrieval with confidence bands → Stage B agglomerative fallback → post-pass cluster merge → orphan rescue → interactive Review UI with sticky user-confirmed gallery members → cannot-merge constraints → co-occurrence + temporal context re-ranking → GPU execution providers with CPU fallback.

Three increments remain that would push accuracy and speed further. They are independent — each can be implemented on its own and each delivers measurable improvement without needing the others. They’re listed in the order recommended for impact-per-effort.

Throughout this document, line numbers assume the current state of main as of the last merge on this branch. Re-grep for the exact anchors before editing — refactors between now and future work may have shifted them.


B2 — Batched ONNX Inference

What problem this solves

Today face_embedder.rs::embed runs one ONNX session.run() call per face. For a photo with 4 faces, we do 4 separate GPU/CPU dispatches, 4 tensor allocations, 4 result extractions. GPU-based inference is especially hurt by this: the kernel launch overhead dominates when the workload per call is tiny.

Batching collects N aligned-face images into a single [N, 3, 112, 112] input tensor and runs session.run() once, returning N embeddings in one call.

Expected speedup:

DevicePer-face todayPer-face batched (N=8)Overall pipeline speedup
CPU~30-80 ms~18-45 ms1.5-2×
GPU (DirectML/CUDA)~3-10 ms~1-3 ms3-5×

The CPU gain is real but modest (cache-locality effect). The GPU gain is dramatic because batching amortizes kernel-launch cost.

Files to touch

  1. src/ml/face_embedder.rs — add a new batch method.
  2. src/services/face_processor.rs — change the per-face embed loop to accumulate a batch, then call embed_batch.

Nothing else needs to change. The output type is Vec<Option<FaceEmbedding>> per batch; downstream code already handles None per-face failures.

Step 1 — Add embed_batch on FaceEmbedder

Open src/ml/face_embedder.rs. Find the existing embed function (starts around line 92). Add a new sibling method embed_batch that accepts a slice of references to aligned RGB images and returns a Vec of optional embeddings in the same order.

Signature:

#![allow(unused)]
fn main() {
pub fn embed_batch(&mut self, aligned_faces: &[&RgbImage]) -> Vec<Option<FaceEmbedding>>
}

The implementation follows the existing embed closely, differing only in tensor shape and result extraction:

  1. Early exit if empty: if aligned_faces.is_empty(), return Vec::new().

  2. Validate each face is 112×112. If any face has wrong dims, log a warning and mark its slot as None in the output. Track a valid_indices: Vec<usize> mapping from batch-tensor position back to the original input index — not every input slot necessarily ends up in the tensor.

  3. Allocate one flat input buffer of size 3 * 112 * 112 * valid_count as Vec<f32>. Stride layout is the same NCHW as the single-face path — for batch index b, face plane offset is b * (3 * 112 * 112).

  4. Preprocess each valid face into its batch slot. Reuse the existing normalization formula: (pixel - 127.5) / 127.5 per channel. Loop over b, c, y, x filling input[b*hw3 + c*hw + y*112 + x].

  5. Build the input tensor with shape [valid_count, 3, 112, 112]:

    #![allow(unused)]
    fn main() {
    let input_tensor = TensorRef::<f32>::from_array_view((
        vec![valid_count as i64, 3, 112, 112],
        input_data.as_slice(),
    ))?;
    }
  6. Run inference once: self.session.run(ort::inputs![input_tensor])?.

  7. Extract output: the output tensor shape will be [valid_count, 512]. Iterate 0..valid_count, slice out each 512-d vector, L2-normalize (reuse the private normalize method — promote it to take &[f32] if needed), and wrap in Some(FaceEmbedding::new(...)).

  8. Reconstruct the result vec with None in the originally-invalid slots and Some(emb) in the valid ones, using the valid_indices map.

Step 2 — Batch in face_processor

Open src/services/face_processor.rs. Find the per-face embed loop. Currently it looks like (simplified):

#![allow(unused)]
fn main() {
for face in &detected {
    // ...quality checks...
    let embedding = EMBEDDER.with(|e| {
        let mut borrow = e.borrow_mut();
        borrow.as_mut().and_then(|emb| emb.embed(aligned))
    });
    if let Some(embedding) = embedding {
        face_inserts.push(FaceInsert { ... });
    }
}
}

Change it to collect qualifying faces first, then call embed_batch once per photo:

  1. Split the loop in two passes:

    • Pass A: filter detections through quality checks, collect Vec<(usize, &RgbImage)> where usize is the original detection index and &RgbImage is the aligned crop.
    • Pass B: call embed_batch on the aligned images, iterate results back with the detection index to build FaceInserts.
  2. Call embed_batch:

    #![allow(unused)]
    fn main() {
    let alignments: Vec<&RgbImage> = qualified.iter().map(|(_, img)| *img).collect();
    let embeddings = EMBEDDER.with(|e| {
        let mut borrow = e.borrow_mut();
        match borrow.as_mut() {
            Some(emb) => emb.embed_batch(&alignments),
            None => vec![None; alignments.len()],
        }
    });
    }
  3. Zip results back into FaceInserts:

    #![allow(unused)]
    fn main() {
    for ((det_idx, aligned), emb_opt) in qualified.iter().zip(embeddings.into_iter()) {
        if let Some(embedding) = emb_opt {
            let face = &detected[*det_idx];
            face_inserts.push(FaceInsert {
                bbox_normalized: face.bbox_normalized,
                confidence: face.confidence,
                embedding,
                aligned_face: (*aligned).clone(),
            });
        }
    }
    }

Caution: Don’t batch across photos. The thread-local EMBEDDER instance is per-photo anyway (each worker processes photos sequentially). Cross-photo batching complicates the worker model for minimal additional gain. Keep it within one photo’s N faces.

Step 3 — Tune the batch cap

Huge batches can hit memory issues. Cap at const MAX_EMBED_BATCH: usize = 16; in face_processor.rs. If a photo has >16 faces (rare but possible in crowd shots), split into chunks of 16.

Verification

  1. cargo test --lib — 49 tests must still pass.
  2. Add a unit test in face_embedder.rs::tests:
    • Create a fake 112×112 RGB image full of one color.
    • Call embed (single) and embed_batch with the same image × 4.
    • Assert all 4 batch results equal the single result within 1e-5.
  3. Manual: scan a library with group photos (≥3 faces per photo). Log-count faces_found should match pre-B2. Wall-clock time should drop.
  4. Bench: time a 100-photo library pre/post. Record numbers in the commit message.

Rollback

Pure-addition change: new method + rewired loop. If B2 causes trouble, revert the face_processor loop to the single-face path; keep embed_batch available (unused but harmless). No DB schema changes, no model changes.


B3 — Always-On Multi-Scale Detection

What problem this solves

FaceDetector::detect runs SCRFD at a fixed 640×640 input. For a 4000×3000 photo, this means a 6.25× downscale — any face smaller than ~30 px in the original effectively disappears. detect_adaptive has a tile-based fallback (src/ml/face_detector.rs:129-194), but it only triggers when zero faces are found at the primary pass. Group photos, distant subjects, and crowd shots lose small faces silently.

B3 makes the second scale unconditional for large images: always run the primary pass at 640×640 and tile-detect at 1600×1600 with 1200-px stride, then NMS-pool the union. Small faces get a proper chance.

Expected recall gain:

Photo typeTypical todayTypical after B3
Portrait / selfie~100%~100%
Family group (3-5 people)~90%~95-98%
Wedding / event crowd~60-75%~85-92%
Tiny background faces (<30 px)~20-30%~70-80%

Cost: detection time roughly doubles for large images (small images are unchanged). On CPU that hurts. On GPU with B1 + B2 live it barely registers.

Files to touch

Only src/ml/face_detector.rs. No changes to embedder, processor, or DB.

Step 1 — Refactor detect_adaptive to always tile

Find the function (line ~129). Currently:

#![allow(unused)]
fn main() {
pub fn detect_adaptive(&mut self, image: &DynamicImage) -> Vec<DetectedFace> {
    let first_pass = self.detect(image);
    if !first_pass.is_empty() {
        return first_pass;
    }
    // ... tile fallback only runs on zero detections ...
}
}

Rewrite to always combine:

#![allow(unused)]
fn main() {
pub fn detect_adaptive(&mut self, image: &DynamicImage) -> Vec<DetectedFace> {
    let primary = self.detect(image);

    let (orig_w, orig_h) = image.dimensions();
    if orig_w.max(orig_h) <= 2048 {
        // Image small enough that downscale-to-640 doesn't lose small faces.
        return primary;
    }

    let tiled = self.tile_detect(image, orig_w, orig_h);

    if tiled.is_empty() {
        return primary;
    }
    if primary.is_empty() {
        return tiled;
    }

    // Merge + NMS across the combined set.
    let mut all = primary;
    all.extend(tiled);
    self.non_max_suppression(all)
}
}

Step 2 — Extract the tile loop into a private method

Move the existing tile body (the while y <= max_y { while x <= max_x { ... block currently inside detect_adaptive) into a new private method:

#![allow(unused)]
fn main() {
fn tile_detect(&mut self, image: &DynamicImage, orig_w: u32, orig_h: u32) -> Vec<DetectedFace> {
    let tile = 1600u32;
    let step = 1200u32;
    let mut all_faces = Vec::new();

    let max_x = orig_w.saturating_sub(1);
    let max_y = orig_h.saturating_sub(1);

    let mut y = 0u32;
    while y <= max_y {
        let mut x = 0u32;
        while x <= max_x {
            let crop_w = tile.min(orig_w.saturating_sub(x)).max(1);
            let crop_h = tile.min(orig_h.saturating_sub(y)).max(1);
            let crop = image.crop_imm(x, y, crop_w, crop_h);

            let mut local = self.detect(&crop);
            // Translate bbox/landmarks back to full-frame coords.
            for face in &mut local {
                let (bx, by, bw, bh) = face.bbox;
                let gx = bx + x as f32;
                let gy = by + y as f32;
                face.bbox = (gx, gy, bw, bh);
                face.bbox_normalized = (
                    gx / orig_w as f32,
                    gy / orig_h as f32,
                    bw / orig_w as f32,
                    bh / orig_h as f32,
                );
                for lm in &mut face.landmarks {
                    lm.0 += x as f32;
                    lm.1 += y as f32;
                }
            }
            all_faces.extend(local);

            if x + step >= orig_w {
                break;
            }
            x += step;
        }
        if y + step >= orig_h {
            break;
        }
        y += step;
    }

    all_faces
}
}

This is the same tile logic that’s already there, just extracted.

Step 3 — NMS handles the cross-scale dedupe

non_max_suppression already exists and uses IoU thresholding. It’ll correctly suppress duplicate detections of the same face from primary + tile passes. No changes needed to NMS.

However: primary-scale detections tend to have fuzzier bounding boxes (resized-down input means coarser localization), tile-scale ones are more precise. Prefer tile-scale when both are present. Change NMS tie-break: when two faces have IoU > 0.5, keep the one with higher confidence and smaller bbox.2 * bbox.3 in the original frame (tile-scale bboxes are tighter). This is a tiny change in non_max_suppression.

Step 4 — Add a toggle config option

Large-image owners might accept the slower scan in exchange for recall. Small-library users might prefer speed. Add to config/mod.rs:

#![allow(unused)]
fn main() {
#[serde(default = "default_multiscale_detection")]
pub multiscale_detection: bool,
}

Default true (recall matters more for most users). Wire the value through FaceProcessor::process_photos and into a new method detect_with_mode(&self, image, multiscale: bool) that either calls detect_adaptive (multiscale) or just detect (single-scale).

Verification

  1. cargo test --lib passes.
  2. Hand-pick 10 photos known to have small/distant faces. Before B3: count detections. After B3: count detections. Ratio should be 1.2-1.5× on photos with >3 faces.
  3. Time a 100-photo library; B3 should be ~1.6-2.0× slower without GPU, ~1.1-1.3× slower with GPU + B1.
  4. Check no regression on small images (<2048 px): detection count identical to pre-B3 and time roughly the same.

Rollback

Pure refactor with additive changes to detect_adaptive. Revert the single function body if it misbehaves.


B4 — Rotation-Invariant Detection

What problem this solves

SCRFD is trained predominantly on upright faces. Anything >~30° rotated (e.g., head tilted to the shoulder, phone held sideways, candid profile shots) gets missed entirely. B4 detects at multiple orientations and pools.

Expected recall gain:

Photo typeTypical todayAfter B4
Standard portrait~100%~100%
Tilted / profile (~30-60°)~30-50%~85-95%
Upside-down phone accident~0%~95%
Sideways landscape (camera rotated)~0%~95%

Cost: detection time multiplied by rotation count (4 rotations = 4× detect time). Worth it only after GPU + B2 are landed, unless the user explicitly accepts slower scans.

Files to touch

  1. src/ml/face_detector.rs — add a new detect_multi_orientation method.
  2. src/services/face_processor.rs — call detect_multi_orientation instead of detect_adaptive when the new config flag is on.
  3. src/config/mod.rs — add the config flag.

Step 1 — Add rotation helper functions

In face_detector.rs, add two private helpers:

#![allow(unused)]
fn main() {
/// Rotate the image 90/180/270 clockwise for multi-orientation detection.
fn rotate_image(image: &DynamicImage, degrees: u32) -> DynamicImage {
    match degrees {
        0 => image.clone(),
        90 => image.rotate90(),
        180 => image.rotate180(),
        270 => image.rotate270(),
        _ => image.clone(),
    }
}

/// Given a face detected in a rotated image, compute its bbox/landmarks
/// in the ORIGINAL (non-rotated) image coordinates.
fn unrotate_face(face: &mut DetectedFace, orig_w: u32, orig_h: u32, degrees: u32) {
    let (fx, fy, fw, fh) = face.bbox;
    let (ox, oy, ow_box, oh_box) = match degrees {
        0 => (fx, fy, fw, fh),
        90 => (fy, orig_w as f32 - (fx + fw), fh, fw),        // 90 CW: rotate back 90 CCW
        180 => (orig_w as f32 - (fx + fw), orig_h as f32 - (fy + fh), fw, fh),
        270 => (orig_h as f32 - (fy + fh), fx, fh, fw),       // 270 CW: rotate back 270 CCW
        _ => (fx, fy, fw, fh),
    };
    face.bbox = (ox, oy, ow_box, oh_box);
    face.bbox_normalized = (
        ox / orig_w as f32,
        oy / orig_h as f32,
        ow_box / orig_w as f32,
        oh_box / orig_h as f32,
    );

    // Landmarks: each (x, y) rotates with the same mapping.
    for lm in &mut face.landmarks {
        let (lx, ly) = *lm;
        let (nx, ny) = match degrees {
            0 => (lx, ly),
            90 => (ly, orig_w as f32 - lx),
            180 => (orig_w as f32 - lx, orig_h as f32 - ly),
            270 => (orig_h as f32 - ly, lx),
            _ => (lx, ly),
        };
        *lm = (nx, ny);
    }
}
}

Double-check these transforms with a test: set up a 100×50 image, pretend a face is detected at (10, 20, 30, 10), rotate 90 CW, unrotate the bbox, assert you get back (10, 20, 30, 10). This is fiddly sign-flipping arithmetic; writing the test first will save hours of “why is the bbox on the wrong shoulder” debugging.

Step 2 — New detect_multi_orientation method

#![allow(unused)]
fn main() {
pub fn detect_multi_orientation(&mut self, image: &DynamicImage) -> Vec<DetectedFace> {
    let (orig_w, orig_h) = image.dimensions();

    let mut all: Vec<DetectedFace> = Vec::new();

    for degrees in [0u32, 90, 180, 270] {
        let rotated = Self::rotate_image(image, degrees);
        let mut faces = if degrees == 0 {
            // At 0°, we want the full pipeline (multi-scale if B3 is live).
            self.detect_adaptive(&rotated)
        } else {
            // At other angles, single-scale is usually sufficient — the
            // "is it a face?" question doesn't need tile-precision since
            // we'll realign later. Skip the extra cost.
            self.detect(&rotated)
        };

        if degrees != 0 {
            for face in &mut faces {
                Self::unrotate_face(face, orig_w, orig_h, degrees);
            }
        }

        all.extend(faces);
    }

    // NMS across all rotations — same face detected at two rotations will
    // have high IoU in original-frame coords, suppressed to one.
    self.non_max_suppression(all)
}
}

Step 3 — Alignment for tilted faces

A face detected at 90° rotation has landmarks that describe the face as if the image were rotated. After un-rotation, the landmarks are in original coords but the head is still physically tilted in the original frame. The existing alignment code (src/ml/alignment.rs) already handles this correctly because the similarity transform is invariant to rotation — it maps the 5 landmarks onto the canonical template regardless of their orientation in the input. So no changes needed on the alignment side.

Verify this: for a tilted test face, the aligned 112×112 output should show eyes horizontal. If it doesn’t, the similarity transform is not fully handling the rotation — in that case add an explicit rotate step before alignment based on the eye-angle.

Step 4 — Config + wiring

In config/mod.rs:

#![allow(unused)]
fn main() {
#[serde(default = "default_rotation_invariant")]
pub rotation_invariant_detection: bool,
}

Default to false because of the 4× cost. Users with tilted-photo libraries opt in via Settings.

In face_processor.rs, swap the detection call based on config:

#![allow(unused)]
fn main() {
let detected = if rotation_invariant {
    DETECTOR.with(|d| d.borrow_mut().as_mut().map(|det| det.detect_multi_orientation(&image)).unwrap_or_default())
} else {
    DETECTOR.with(|d| d.borrow_mut().as_mut().map(|det| det.detect_adaptive(&image)).unwrap_or_default())
};
}

Thread the rotation_invariant boolean through process_photos’s signature, sourcing from config at the handler layer (same pattern as the existing resolver_weights parameter).

Step 5 — UX consideration

In Settings, expose the toggle with a clear tooltip: “Detect faces at all orientations (catches tilted / profile photos). ~4× slower face scanning. Recommended for mixed-orientation libraries.”

Verification

  1. Unit test the unrotate_face math on synthetic bboxes.
  2. cargo test --lib passes.
  3. Curate 10 photos with intentionally tilted faces (phone-rotated selfies, someone lying down, sideways landscape shots). Without B4: detection rate ~20-40%. With B4: ~90%+.
  4. Time cost: 4× on detection, but detection is only ~15-25% of total face-processing time (embedding dominates). Expected overall slowdown: 1.4-1.7× without GPU.

Rollback

Default-off config. If the math is wrong, faces will land in bizarre positions in People — noticed immediately. Toggle off in Settings; no DB changes.


Ordering & combination

If implementing all three, the recommended order is:

  1. B2 first. Biggest speedup, smallest risk, no behavioral changes.
  2. B3 second. Uses the speed budget that B2 bought back for detection recall.
  3. B4 last, opt-in. Highest cost, narrowest user benefit (only helps tilted-face libraries).

Each is an independent commit. No shared files beyond face_detector.rs, face_processor.rs, and config/mod.rs — and even those changes don’t conflict.

What’s intentionally out of scope

  • Model swaps (buffalo_l R50, AdaFace, TransFace). No clear accuracy gain on clean faces. Speed gain (R50) is inferior to B1+B2 GPU batching. Revisit only if the review-UI feedback loop plateaus below 95% perceived accuracy and a specific user complaint points at model limits.
  • Age-chain recognition. Needs a persistent temporal_face_links table and a graph-walk in the resolver. Multi-week effort. Defer until a user reports “can’t recognize me across >10 years” with evidence the current temporal signal isn’t catching it.
  • Cloud-offload inference. Privacy cost is existential to the product. Not on the roadmap.