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
- Build from Source — toolchain setup per OS.
- Architecture Overview — the three-crate split: engine, Tauri shell, frontend.
- Database — schema and migration model.
- Services — domain workflows.
- ML Pipeline — face detection + embedding + clustering.
- Face GPU Bridge — opt-in remote GPU acceleration.
- Testing — the test pyramid and what’s enforced in CI.
- Future Scope — open items, complexity, skill tags.
Privacy and data
- Privacy policy — every outbound HTTP request, enumerated.
- Security — private vulnerability disclosure.
- Third-party licenses — full attribution list.
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
- Launch Smriti. The Welcome screen invites you to pick or drop a folder.
- Pick a library. Drag a folder onto the window, or click Browse to pick one. Smriti remembers it and offers it again next time.
- (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.
- 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:
- Checks if Smriti has already indexed it. Files identified by path + size + modification time are skipped on subsequent scans.
- Reads EXIF. Capture date, GPS coordinates, camera make/model, ISO, aperture, shutter, focal length, orientation.
- Hashes the file (SHA-256) for byte-level duplicate detection.
- Resolves a place name if GPS is present, using the local GeoNames database.
- Generates thumbnails (three sizes) lazily, the first time each photo is viewed.
- 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
- Settings → Indexing — the scan options
- People and Faces — the face-detection pass
- Cleanup — finding and removing duplicates
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
| Key | Action |
|---|---|
| ↑ ↓ ← → | Move highlight between thumbnails |
| Enter | Open the highlighted photo in the viewer |
| Space | Toggle selection on the highlighted photo |
| Shift+click | Range-select |
| Ctrl/Cmd+click | Add to selection |
| PageUp / PageDown | Scroll a viewport |
| Home / End | Jump 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:
| Key | Action |
|---|---|
| ← / → | Previous / next photo |
| Esc | Back to gallery |
| I | Toggle the info panel (EXIF, location, people) |
| + / − | Zoom in / out |
| 0 | Fit to screen |
| 1 | Actual size (1:1 pixel) |
| [ / ] | Rotate left / right |
| F | Toggle 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
- Search — jump to specific dates, people, or places
- Memories — the “N years ago today” banner
- Keyboard shortcuts — full list
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:
- 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.
- 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:
- Select one or more photos in any view.
- Click Add to album in the action bar.
- 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
- Timeline — the daily Memories banner lives here
- People — face data feeds the memory hero-photo picker
- Settings → Memories
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.
Pivot to search
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
| Query | Meaning |
|---|---|
Goa 2023 | Photos from Goa taken in 2023 |
Dad 2024 | Photos containing Dad from 2024 |
Dad and Mom | Photos containing both Dad and Mom |
only Dad | Photos where every detected face is Dad |
only Dad and Mom | Photos where every detected face is Dad or Mom, and both are present |
favourites Dad | Favourite photos containing Dad |
videos Goa 2024 | Videos from Goa taken in 2024 |
album Goa Trip | Photos in the matching album |
Nikon Goa | Filename/camera/location fallback for unmatched words |
beach sunset | Visually 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:
- Open Duplicates → review the suggested keeps → trash the redundant copies.
- Open Bursts → review each burst → keep the best, trash the rest.
- Browse the Timeline for anything obviously bad you missed.
- Open Trash → verify the list → empty trash, or wait for auto-delete.
See also
- Settings → Trash — auto-delete window
- Settings → Burst detection — burst window
- Indexing — when these detectors run
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.
Visual search
- Download visual model — installs the optional local
ViT-B-32 SigLIP2 256semantic 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.comat 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
.appinto/Applicationsas 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
| Key | Action |
|---|---|
| ? | Toggle the keyboard-shortcuts overlay |
| / | Focus the search bar |
| Esc | Back · close overlay · cancel selection |
| Ctrl/Cmd+, | Open Settings |
| Ctrl/Cmd+1 … 9 | Jump to sidebar section 1–9 |
| Tab · Shift+Tab | Move focus between cards / controls |
Timeline (and any grid view)
| Key | Action |
|---|---|
| ↑ ↓ ← → | Move highlight |
| Enter | Open the highlighted photo in the viewer |
| Space | Toggle selection on the highlighted photo |
| Shift+click | Range-select |
| Ctrl/Cmd+click | Toggle a single photo’s selection |
| Ctrl/Cmd+A | Select all photos currently in view |
| PageUp · PageDown | Scroll a viewport |
| Home · End | Jump to first / last |
| Delete / Backspace | Move selection to Trash |
Photo viewer
| Key | Action |
|---|---|
| ← · → | Previous · next photo |
| Esc | Back to gallery |
| I | Toggle the info panel |
| + · − | Zoom in · out |
| 0 | Fit to screen |
| 1 | Actual size (1:1 pixel) |
| [ · ] | Rotate left · right |
| F | Toggle fullscreen |
| Space | Toggle selection on this photo |
Slideshow
| Key | Action |
|---|---|
| Space | Play · pause |
| ← · → | Previous · next |
| Esc | Exit slideshow |
| F | Toggle fullscreen |
People & Person Detail
| Key | Action |
|---|---|
| Enter | Open the highlighted person |
| M | Open the merge-person dialog (in Person Detail) |
| R | Open the rename dialog (in Person Detail) |
Face review
When stepping through ambiguous face assignments:
| Key | Action |
|---|---|
| Y | Confirm — yes, this is the suggested person |
| N | Reject — no, queue for a different person |
| Space | Skip — decide later |
| ← | Previous face |
| → | Next face |
Map
| Key | Action |
|---|---|
| + · − | Zoom in · out |
| ↑ ↓ ← → | Pan |
| Esc | Close pin popover |
See also
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
.appinto/Applicationsas 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:
- 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.
- Asset download was interrupted. Re-run Reinstall Assets — it’s idempotent and resumes from where it stopped.
- Disk space. The pack is ~250 MB. Free up space and retry.
- 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:
- 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.
- 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:
- No internet. Map tiles come from
tile.openstreetmap.orgon first view. Without internet, only previously-cached regions render. - 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.
- 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:
- Open the Person view that has the wrong photo.
- Click the photo’s face thumbnail in the person’s face strip.
- 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:
- Check the photo’s EXIF GPS — it may be wrong at the source.
- 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 info → Run 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 → Open → Open 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:
- The update check is opt-in and off by default. Enable Automatically check for updates in Settings first.
- 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 --releaseinstead. - If you installed via a system package manager (apt, brew, flatpak, winget), the update banner shows the upgrade command instead.
See also
- FAQ — quick answers to common questions
- Settings — the maintenance actions
- GitHub Issues — bug reports
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 buildand 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
Asset setup (optional but recommended)
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.exeandlight.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.ymlworkflow - 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:
PHOTOVAULT_ASSET_PACK_PATH(local zip path override)PHOTOVAULT_ASSET_PACK_URL(custom URL override)- latest published release URL (
.../releases/latest/download/Smriti-Assets.zip) - 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_closeare thin wrappers:state.library.write().await; *guard = None;. Testing through Tauri’s IPC layer needsmock_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)
cargo fmt --all --checkpasses.cargo clippy --all-targets -D warningspasses.cargo testpasses (engine + Tauri integration tests).npm run checkpasses (Svelte + TypeScript type-checking).npm run testpasses (Vitest).- DTO snapshot changes are intentional. When
dto_snapshots.rsfails, you must either revert the unintended change or runcargo insta reviewto 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::TempDirfor the filesystem; in-memory SQLite or per-test tempdir for the DB. - No
thread::sleepfor 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 fromgit blame. - **TS: prefer
expect(...).toEqual(...)with descriptivedescribeittext** 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>.rsis 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
- 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.
- Run
scripts/ci_local.sh cilocally. This mirrors the core build/test path from GitHub Actions. The hosted workflow also runs the supply-chain audit job. - 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 testsin Rust, sibling.test.tsfor TS). - DTO snapshot — for a new or changed
From<EngineType> for SomethingDto. Add tosrc-tauri/tests/dto_snapshots.rsand runcargo insta reviewto 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>.rsif 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 insideasync fn). - TypeScript / Svelte: follow svelte-check; type things
precisely. Prefer
$state/$derivedover 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)
- CI is green? If no, the PR is blocked until it goes green.
- PR template ticked? Confirm each ticked box is true by
looking at the diff:
- “Unit test added” → there’s a new
#[test]or a newit(...)in a.test.ts. - “DTO snapshot added” → there’s a new
.snapfile undersrc-tauri/tests/snapshots/. - “Workflow test” → there’s a new file or function under
tests/workflow_*.rs.
- “Unit test added” → there’s a new
- 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.
- Snapshot diffs in
*.snapfiles: read them as wire-format contracts. A field renamed in a snapshot is a frontend break waiting to happen — make suresrc-ui/src/lib/api/types.tsupdated to match. - 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.debSmriti-x86_64.AppImage
Published by tag-driven CI workflow:
.github/workflows/release.yml
CI also runs package smoke checks on tag releases:
.debinstall + 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.debSmriti-x86_64.AppImageSHA256SUMS
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_DIRis set in AppImage AppRun - verify runtime path resolution handles
/usr/lib/smriti(and the legacy/usr/lib/photovaultfallback for upgrading users)
Architecture Overview
Smriti is a desktop app split across three crates / workspaces:
src/— pure Rust engine, published as thesmritilibrary.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]fromsrc-tauri/src/commands/. The contract is indocs/COMMAND_SURFACE.md. DTOs live insrc-tauri/src/dto.rsand translate one-way from engine types.src-ui/— Vite + Svelte 5 frontend. Routes call into the typed Tauri client atsrc-ui/src/lib/api/; state lives in runes-based stores undersrc-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
| Table | What it stores |
|---|---|
photos | The canonical photo record: path, hash, EXIF, GPS, dates, dimensions. |
albums | Manual + accepted-suggestion albums. |
album_photos | Join table; ordered for manual album reordering. |
album_suggestions | Detected-but-not-accepted trip / event suggestions. |
faces | Per-photo detected faces: bbox, embedding, quality score. |
clusters (a.k.a. persons) | Named identity groups; one row per person. |
face_assignments | Which face belongs to which cluster, with confidence. |
duplicates | Exact and perceptual duplicate groups. |
bursts | Detected burst groups with timestamps. |
photo_stacks / photo_stack_members | Timeline stack suggestions derived from duplicate and burst groups, with cover photo and per-member quality scores. |
memory_blocks | User dismissals for surfaced memories. |
trash (via photos.is_trashed) | Soft-deleted photos. |
search_history | Recent search queries, capped. |
Conventions
- All tables use
INTEGER PRIMARY KEYrow IDs (SQLite’s implicitrowidaliases). - Timestamps are stored as RFC-3339 strings in UTC.
- File paths are stored as
relative_pathfrom the library root, so moving the library to a new mount point doesn’t break references. - Soft-delete is the default —
is_trashed = TRUEonphotosrather thanDELETE 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
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
| Service | Role |
|---|---|
scanner | Walks the library, dispatches per-photo work. Async, cancellable. |
metadata_processor | EXIF + GPS + file-hash extraction per photo. |
exif_extractor | Pulls and normalises EXIF tags. |
thumbnail | Three-tier thumbnail generation, lazy + cached. |
image_io | Decodes JPEG/PNG/WebP/HEIC + RAW preview extraction. |
raw_preview | Extracts embedded JPEG previews from TIFF-based RAWs. |
geocoding | Resolves GPS → city/country via local GeoNames DB. |
face_processor | Detection + embedding + clustering orchestration. |
search | Translates SearchQuery → SQL → result rows. |
insights | Aggregates per-day / per-month / per-person counts. |
memories | “On this day” / fallback / seasonal generators + ranker. |
album_suggestions | Trip and event detection from geographic + temporal clusters. |
burst_detector | Time-window-based grouping with similarity gate. |
duplicate_detector | SHA-256 exact + DCT-pHash perceptual dedup. |
photo_stacks | Conservative timeline stack generation from duplicate/burst results, including best-photo scoring. |
trash | Soft-delete, restore, permanent-delete, auto-delete. |
tile_cache | LRU-managed cache for OpenStreetMap tiles. |
update_checker | Opt-in version check against the GitHub releases API. |
self_replace | Atomic binary swap for AppImage / portable Windows. |
library_health | Asset-pack presence, DB integrity, model availability. |
path_util | Cross-platform path normalisation. |
reindexer | Detects moves, deletions, and changed files. |
camera_names | Normalises EXIF make/model into human-readable names. |
image_utils | Misc image-processing helpers (rotation, resize). |
map_math | Cluster math for the map view. |
drive_detector | Identifies the indexed drive’s filesystem ID. |
install_method | Detects how Smriti was installed (apt, brew, msi, …). |
document_detector | Heuristic detection of scanned-document photos. |
ocr_processor | (Reserved) OCR pipeline scaffolding. |
Boundary discipline
- Services accept primitive types or
&rusqlite::Connection, not TauriAppState. 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:
- 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.
- 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
- Face GPU Bridge — opt-in remote acceleration.
- People and Faces user guide — user- facing perspective.
- Face Recognition Improvements — historical notes on tuning decisions.
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)
- Open kaggle.com, create an account (free).
- Create a new Notebook. Choose GPU T4×2 as the accelerator.
- Upload
notebooks/face_bridge.ipynbfrom the Smriti repo. - Sign up for ngrok (free). Copy your auth token from the dashboard.
- Paste your ngrok token in the notebook’s cell 5.
- Run all cells. The last cell prints a public URL — copy it.
Colab (free with limits / Pro $10/mo for A100)
- Open colab.research.google.com.
- File → Upload notebook →
notebooks/face_bridge.ipynb. - Runtime → Change runtime type → T4 GPU (or A100 on Pro).
- Set up ngrok as above (or use the cloudflared alternative commented out).
- Run all cells. Copy the printed URL.
Local LAN GPU server (no internet)
- Run
notebooks/face_bridge.ipynbon a local machine with a GPU. - Skip the tunnel cells. The server binds to
0.0.0.0:8000. - In Smriti Settings, enter
http://<lan-ip>:8000as the bridge URL.
How to connect Smriti
- Open Smriti → Settings → Cloud face acceleration (advanced).
- Toggle “Use a remote GPU for face embedding” on.
- Paste the bridge URL (e.g.
https://abc.ngrok.io). - Click Test connection → should show “✓ CUDAExecutionProvider @ XXms”.
- Run face detection. Embedding goes remote; detection stays local.
ngrok auth token (60-second setup)
- Sign up at ngrok.com (free, no credit card).
- Copy your authtoken from dashboard.ngrok.com/get-started/your-authtoken.
- 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 size | Local (i7-7567U) | T4 GPU | Speed-up |
|---|---|---|---|
| 10k photos (~30k faces) | ~5 min | ~30 s | 10× |
| 90k photos (~200k faces) | ~3 hrs | ~2.5 min | 70× |
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 @usernamewhen 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:
| Device | Per-face today | Per-face batched (N=8) | Overall pipeline speedup |
|---|---|---|---|
| CPU | ~30-80 ms | ~18-45 ms | 1.5-2× |
| GPU (DirectML/CUDA) | ~3-10 ms | ~1-3 ms | 3-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
src/ml/face_embedder.rs— add a new batch method.src/services/face_processor.rs— change the per-face embed loop to accumulate a batch, then callembed_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:
-
Early exit if empty: if
aligned_faces.is_empty(), returnVec::new(). -
Validate each face is 112×112. If any face has wrong dims, log a warning and mark its slot as
Nonein the output. Track avalid_indices: Vec<usize>mapping from batch-tensor position back to the original input index — not every input slot necessarily ends up in the tensor. -
Allocate one flat input buffer of size
3 * 112 * 112 * valid_countasVec<f32>. Stride layout is the same NCHW as the single-face path — for batch indexb, face plane offset isb * (3 * 112 * 112). -
Preprocess each valid face into its batch slot. Reuse the existing normalization formula:
(pixel - 127.5) / 127.5per channel. Loop overb, c, y, xfillinginput[b*hw3 + c*hw + y*112 + x]. -
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(), ))?; } -
Run inference once:
self.session.run(ort::inputs![input_tensor])?. -
Extract output: the output tensor shape will be
[valid_count, 512]. Iterate0..valid_count, slice out each 512-d vector, L2-normalize (reuse the privatenormalizemethod — promote it to take&[f32]if needed), and wrap inSome(FaceEmbedding::new(...)). -
Reconstruct the result vec with
Nonein the originally-invalid slots andSome(emb)in the valid ones, using thevalid_indicesmap.
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:
-
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&RgbImageis the aligned crop. - Pass B: call
embed_batchon the aligned images, iterate results back with the detection index to build FaceInserts.
- Pass A: filter detections through quality checks, collect
-
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()], } }); } -
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
cargo test --lib— 49 tests must still pass.- Add a unit test in
face_embedder.rs::tests:- Create a fake 112×112 RGB image full of one color.
- Call
embed(single) andembed_batchwith the same image × 4. - Assert all 4 batch results equal the single result within 1e-5.
- Manual: scan a library with group photos (≥3 faces per photo).
Log-count
faces_foundshould match pre-B2. Wall-clock time should drop. - 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 type | Typical today | Typical 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
cargo test --libpasses.- 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.
- Time a 100-photo library; B3 should be ~1.6-2.0× slower without GPU, ~1.1-1.3× slower with GPU + B1.
- 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 type | Typical today | After 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
src/ml/face_detector.rs— add a newdetect_multi_orientationmethod.src/services/face_processor.rs— calldetect_multi_orientationinstead ofdetect_adaptivewhen the new config flag is on.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
- Unit test the
unrotate_facemath on synthetic bboxes. cargo test --libpasses.- 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%+.
- 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:
- B2 first. Biggest speedup, smallest risk, no behavioral changes.
- B3 second. Uses the speed budget that B2 bought back for detection recall.
- 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.