v3.2.0

locallens@community:~$ cat pipeline.md

How a photo moves through the pipeline

A desktop photo organiser that sorts by date, GPS location and recognised faces without a network call. Tauri and React on the front, FastAPI and dlib behind it. Enumeration prunes whole subtrees rather than filtering paths; face detection climbs a five-pass ladder and stops at the first hit; recognition finishes in parallel before a single file is written, so abort never has to undo anything; and copies share disk blocks instead of duplicating them. The rest of this page is why each of those is the way it is.

19image formats
128-Dface encoding
0.55match tolerance
~0.20sper photo, balanced
0outbound photo bytes
New here?Basic Understanding of LocalLens →the same pipeline, no engineering
01

Architecture

A Tauri shell hosts a React UI and spawns a bundled FastAPI server bound to 127.0.0.1, guarded by a per-install X-Local-Token header. The UI never touches the filesystem itself: it posts a job config and then watches an SSE stream for progress. Exactly one job runs at a time — a second request is rejected rather than queued, because both would be mutating the same tree.

SSE /api/stream-logs — progress %, log lines, analyticsReact 18 · Tauri 2FastAPIorganizer_logicdesktop shell127.0.0.1 · X-Local-Tokenone job at a timePOST /api/start-sortingBackgroundTasksPillow · pillow-heifrawpy / Wand for RAWdecodes 19 formatsreverse_geocoderbundled k-d treeoffline, no lookupface_engine → dlibHOG + MMOD CNNmultiprocessing.Poolmetadata_storeSQLite: face_cache+ photo_metadata
The bottom row is called in-process by organizer_logic; none of it is a service. The only socket the app opens to the outside world is the updater's version check.
02

Enumeration and the ignore set

File discovery is os.walk wrapped in walk_ignoring(). Two details make it correct rather than merely working.

Pruning, not filtering. Ignored subtrees are removed by slice-assigning dirnames[:], so the walk never descends. A naive if dirpath in ignore_set: continue excludes nothing useful here — sorted output always nests, so an ignored folder holds zero direct files while its children's paths are absent from the set. Rebinding the name instead of slice-assigning is silently a no-op, since os.walk reads back that same list object.

The self-ingest guard. effective_ignore_set() adds the destination to the ignore set whenever it nests strictly inside the source. Without it, sorting into a subfolder of the source makes the next run read its own output. It is deliberately not added when dest == source, which would exclude everything.

A · SUBTREE PRUNINGPhotos/2023/2024/Screenshots/Work/Memes/dirnames[:] drops the branch —os.walk never descends, so thechildren are never even listed.B · THE SELF-INGEST GUARDwalk_ignoring()Photos/← sourceSorted/← destinationreadsoutput re-read as inputeffective_ignore_set() adds dest to the set when dest ⊂ source,so the precount and the copy loop can never disagree on scope.
Both halves route through one helper, so the file precount shown in the UI and the set the job actually processes come from the same rule. The deletion path uses os.path.commonpath, not startswith — the latter matches /Photos/Trip2 against /Photos/Trip, and this code path deletes.
03

Format admission and decode

Admission is a suffix test against one canonical tuple — f.lower().endswith(SUPPORTED_EXTENSIONS), where the tuple form lets endswith test all 19 at once. It lives in face_engine so the sorter and the enroller cannot drift apart; when they did, enrolment accepted only JPEG and PNG, so a user enrolling HEIC selfies got an empty gallery and no explanation.

Decode is a separate concern one rung later, and it is layered: Pillow for the standard formats, pillow-heif registered as an opener for HEIC/HEIF, and rawpy with a Wand/ImageMagick fallback for RAW. Every loader is imported lazily and degrades to a logged skip, so a missing optional dependency disables one format rather than the backend.

Non-obvious

Pillow does not decide what is a photo, and it does not write the output file. It sits strictly in the middle: decode and EXIF read. Admission is string matching; the write is shutil.

Orientation is applied before anything looks at the pixels

Image.open() does not apply the EXIF Orientation tag, so a portrait JPEG arrives at the detector on its side. ImageOps.exif_transpose() runs first, before convert('RGB') — the order matters, because transpose reads from image.info. Measured on 35 real enrolment photos: 33 of 35 faces detected upright, 1 of 35 with the same photos rotated 90°.

04

Metadata extraction

EXIF comes from Image._getexif(), with PIL.ExifTags.TAGS and GPSTAGS mapping numeric IDs to names. Byte-valued tags are decoded with errors='replace' and stripped of NULs rather than being allowed to raise.

WantSourceHowMiss
Date takenDateTimeOriginalFalls back to DateTimeDigitized then DateTime. Never the filesystem mtime — a restore rewrites that.Unknown_Date/
LocationGPSInfoDMS → signed decimal degrees, np.isfinite guard, then reverse_geocoder.search(mode=1) — an offline k-d tree over a bundled city set. Yields cc/admin1/name.Unknown_Location/
CameraModelCaptured passively into photo_metadata; never affects the sort.null

Location scanning is gated behind the sort method: loading the geocoder unconditionally was measured taking file locks that affected unrelated sort types. The country segment is dropped from the output path when every photo resolves to a single country — Uttar-Pradesh/Lucknow/ rather than a pointless IN/ root.

05

The detection ladder

This is the piece most worth understanding. Face detection is not one operation with a quality dial — it is an ordered list of increasingly expensive attempts, and each photo exits at the first pass that returns an encoding. An ordinary photo pays for pass 1 only; the escalation cost falls entirely on the photos that would otherwise have been filed as faceless. The three modes are prefixes of the same list, not different algorithms.

Passes run in order. The first that returns an encoding ends the scan for that photo — the rest never execute.PASS 1hog · 1024 · up0~0.16s · most exit herePASS 2hog · 1600 · up1~0.66s · small / distantPASS 3hog · 1024 · rot +90bad orientation tagPASS 4hog · 1024 · rot −90bad orientation tagPASS 5cnn · 1200 (MMOD)≤ 2 workers · memory-boundfastbalanced · defaultaccurate
Arrays are memoised by long edge, so the two rotation passes reuse pass 1's decode rather than re-scaling. A sixth pass at 2000px with upsample 2 was measured and dropped: it found exactly what pass 2 found, for five times the time.
Non-obvious

Balanced does not blend HOG and CNN, and nothing inspects the machine to choose a mode. Balanced is HOG-only — the fast ladder plus two rotation passes. The sole hardware input in the engine is os.cpu_count() inside safe_worker_count(); there is no RAM probe and no GPU detection. Against the previous design, which paid upsample=2 unconditionally at ~0.97s per photo, the ladder averages ~0.20s at equal or better recall.

Where the concurrency ceiling comes from

def safe_worker_count(mode, available=None):
    if available is None:
        available = max(1, (os.cpu_count() or 2) - 1)
    if mode_uses_cnn(mode):
        return max(1, min(available, MAX_CNN_WORKERS))   # 2
    return max(1, available)

dlib's CNN allocates per-image buffers proportional to pixel count and offers no way to cap that from Python, so concurrency is the only available lever. Parallel CNN detections were measured exhausting an 8 GB machine, and an OOM kill loses the entire job — strictly worse than the job being slow. The cap is deliberately a fixed number rather than a memory heuristic, and is marked in the source as a known ceiling: a 64 GB box and an 8 GB box both land on 2.

06

dlib, and what the C++ is for

face_recognition is a thin Python convenience layer. The work is dlib, a C++ computer-vision library exposed through pybind11 — which is why installation compiles from source when no wheel matches, and why the heavy paths are not bound by the GIL the way pure-Python code would be.

  • HOG — Histogram of Oriented Gradients plus a linear SVM over a sliding window (Dalal & Triggs, 2005). Gradient orientations are binned per cell into a descriptor robust to lighting but not to rotation. Cheap, CPU-only, and the reason passes 3 and 4 exist.
  • CNN — dlib's MMOD (Max-Margin Object Detection) detector. Substantially better on small, angled and poorly-lit faces; expects CUDA, and its memory scales with input pixels.
  • The encoding — dlib crops and aligns the detected face to a 150×150 chip and runs a ResNet that emits 128 floats. Same identity ⇒ small Euclidean distance. Recognition reduces to nearest-neighbour search in a 128-dimensional space.

Matching is one vectorised operation per photo rather than per face:

distances = np.linalg.norm(gallery_matrix - encoding, axis=1)
best = int(np.argmin(distances))
found.add(gallery_names[best] if distances[best] <= 0.55 else "Unknown")

dlib recommends 0.6. This uses 0.55, and the number survived scrutiny for a specific reason: measured against a cleanly rebuilt gallery, 0% of same-person pairs exceed it. It only looked too tight against the old gallery, where 12.6% did — the fix was the gallery, not the threshold. A runner-up margin test and per-person distance aggregation were both implemented, measured, and removed as neutral-or-worse.

07

Enrolment builds the gallery

Enrolment runs once per person over a handful of photos, so it can afford the expensive detector. It uses CNN with an upsampled HOG fallback — 3 of the 35 measured enrolment photos are undetectable by HOG at any scale and would silently drop out of the gallery otherwise. Detection runs on an 800px copy and the box is scaled back into a 1600px array for encoding, with num_jitters=10.

Three guards keep one bad sample from poisoning the gallery, because nearest-neighbour matching means a single wrong face is worse than a missing one:

  • Largest face wins. The previous code took face_encodings(...)[0] — dlib's arbitrary order. On a two-person enrolment photo that files a stranger under the subject's name.
  • Ambiguity is refused, not guessed. A second face at ≥60% of the subject's area means the folder name is unresolvable, so the photo is skipped with a message naming the fix.
  • Outliers are pruned. Per person, mean pairwise distance with a median + 2×MAD threshold, floored at 0.60. Needs ≥3 samples to mean anything.

Self-consistency cannot catch one person enrolled twice under two names — each name's photos agree with themselves perfectly — so that case is detected separately and reported as a warning. Which name to keep is the user's call, so nothing is deleted.

The bug this was all built to kill

Enrolment loaded at 600px wide, sorting at 800px, and neither applied the orientation tag — so the gallery and the query encodings came from visibly different pictures. Aligning both paths onto one module dropped same-person pairs falling outside tolerance from 12.6% to 0%. That percentage was photos of enrolled people landing in Unknown_Faces/.

08

Job ordering, and why abort is cheap

Recognition used to run inline, one photo at a time, on a single core — the reason a People sort over a large library took hours. It is also pure compute with no shared state, which makes it the one part of the loop that parallelises cleanly.

So it was lifted into a single pass that completes before any file operation begins. That ordering is doing real work: the pool lives and dies inside one function, so an abort or an error tears the workers down there rather than orphaning them mid-move — and because nothing has been written yet, aborting during the expensive phase needs no rollback at all.

PHASE 1 · RECOGNITION · PARALLELw1w2w3wNPool(processes = cpu_count − 1, capped at 2 when the ladder can reach CNN)nothing on disk yetabort here → no rollback neededPHASE 2 · FILE OPS · SERIALhandle_file_op() — one photo at a time, original order preservedmanifeston abort: replay in reverse
File operations deliberately stayed serial and in the original order, so progress messages and the rollback manifest are unaffected by the parallelism. Only the pure-compute phase was parallelised — everything touching the filesystem still runs on one thread.
09

The encoding cache

A SQLite table keyed (path, engine_version), validated against (file_size, file_mtime). Two decisions in it are worth stating.

It caches encodings, not names. Names depend on who is enrolled and go stale the moment someone is added. Matching an encoding against the gallery is a single numpy operation, so it is re-run every time and stays correct for free.

A cheap miss is not evidence for an expensive question. A "no face" result from a fast scan must not satisfy an accurate request. The row records passes_run, and reuse is gated on depth:

Cached statusReusable whenWhy
okpass_used ≤ ladder_length(request)The pass that found the face is one this request would also have run.
no_facepasses_run ≥ ladder_length(request)The cached scan was at least as thorough as the one being asked for.
anysize and mtime unchangedAn edited photo is always re-read.

That comparison is only sound because the ladders are strict prefixes of one another — fast ⊂ balanced ⊂ accurate. The bracket structure in §05 is not presentation; it is the invariant this rule depends on. Bumping ENGINE_VERSION invalidates every row rather than trusting encodings from an older recipe, and only the parent process touches the cache, so there is a single writer and no lock contention.

10

Writing to disk

The write is shutil.copy2 or shutil.move into a path assembled with os.path.join, followed by os.utime() stamping the destination with its EXIF date so the file manager's own sort agrees with the folder structure. Name collisions are resolved by numbering, never by overwriting. Move mode branches on st_dev, and the two branches have genuinely different safety properties:

st_dev(src) == st_dev(dst)same physical volume?yesnoshutil.move() per file = renameinstant · abort replays the manifestspace check → copytree to temprefuses the job, never fills the volumeno temp copy, no source rmtreenot fully transactional, but cannot lose datasort → ignore-aware source deletebottom-up; ignored subtrees surviveA blanket rmtree(source) would destroy ignored subtrees that were never copied anywhere — so the cleanup walks bottom-up.
The cross-drive path is transactional and fully reversible. The same-drive path trades that for speed and is still safe — a rename either happened or it did not, and the manifest covers the rest.

Space sharing on APFS

A People sort files one group photo under every person in it, so a photo with four enrolled faces is written four times — and in copy mode the originals stay too. On a nearly-full volume that is the difference between the feature being usable and not.

IMG_8842.jpg20 MB · 4 enrolled facespeople sortMayank/With Others/20 MBAditi/With Others/20 MBRhea/With Others/20 MBKabir/With Others/20 MB20 MB consumed on the volumeclonefile(2) — independent files, shared blocksHardlinks were rejected: one shared inode means the os.utime() EXIF stamp would apply to every copy at once.
Called through ctypes; any failure — EXDEV, non-APFS, Windows, Linux — falls through to shutil.copy2, which is the previous behaviour exactly. du and Finder are the wrong instruments here: APFS does not expose per-file block sharing, so four clones of a 20 MB photo still report 80 MB there. Volume free space is the truth.
11

Measured numbers

Every figure below came from a measurement on real photos, and each drove a decision that is still in the code.

MeasurementValueWhat it decidedWhere it lives
Same-person pairs outside tolerance12.6% → 0%Aligning enrolment and query onto one loader. Kept 0.55 rather than loosening the threshold.face_engine.py
Faces found upright vs rotated 90°33/35 → 1/35exif_transpose before anything touches pixels, and rotation passes 3–4.face_engine.py
Portrait shrunk to group-photo scale11/20 → 19/20Scaling by long edge at 1600 with upsample 1, rather than forcing width 800.face_engine.py
Per-photo detection, old vs ladder0.97s → 0.20sEarly-exit ladder instead of an unconditional upsample=2 on every image.face_engine.py
Single 1600px CNN detection~23% of RAMDetect on an 800px copy, scale the box back up; MAX_CNN_WORKERS = 2.face_engine.py
Four 60 MB clones vs four copies0 MB vs 240 MBclonefile(2) for the People-sort fan-out. Measured with os.statvfs, not du.organizer_logic.py
A pass at 2000px / upsample 25× time, 0 gainRemoved. The ladder stops at five passes.face_engine.py
12

Stack

LayerChoiceNote
ShellTauri 2 (Rust)Ships a bundled Python backend; the signed updater is the only outbound endpoint.
UIReact 18Progress and logs arrive over SSE, not polling.
APIFastAPIBound to loopback, per-install token header, single-job invariant.
ImagingPillow · pillow-heif · rawpy · WandLayered decode; each optional loader degrades to a logged skip.
Recognitiondlib via face_recognitionHOG + MMOD CNN detectors, 128-D ResNet embedding.
Geocodingreverse_geocoderOffline k-d tree over a bundled city set. No network path exists.
PersistenceSQLiteEncoding cache and passive photo metadata; startup compaction.
Concurrencymultiprocessing.PoolSpawn-safe: workers import a quiet module, gallery bound once per worker.
IntegrationMCP serverExposes sort, find, enrol and scheduling as tools for an LLM client.
13

Summary

A folder scan is os.walk pruned by a path-prefix ignore set; files are admitted by extension against one canonical tuple, then decoded by Pillow, with pillow-heif for HEIC and rawpy/Wand for RAW. Pillow's _getexif yields the shutter date and GPS DMS, which is converted to decimal degrees and resolved to Country/State/City by an offline k-d tree reverse geocoder — no network. Destination paths are built with os.path.join, then shutil.copy2/shutil.move writes the file, os.utime back-dates it, and on APFS clonefile(2) via ctypes shares blocks so fan-out copies cost no extra disk.

People sorting runs a separate pipeline: enrolled faces are encoded once into a versioned 128-D gallery, then every source photo goes through one parallel Pool pass that completes before any file is touched, so an abort mid-scan needs no rollback. Each photo climbs a detection ladder — HOG at 1024, HOG at 1600 upsampled, ±90° rotations, then dlib's MMOD CNN — and exits at the first pass returning an encoding; fast/balanced/accurate select ladder depth and nothing else. Matching is nearest-neighbour Euclidean distance under a 0.55 tolerance. Results are cached in SQLite keyed on (path, engine_version), invalidated by (size, mtime), storing encodings rather than names — because names go stale whenever someone is enrolled, and re-deriving them is one numpy operation.

The other sideBasic Understanding of LocalLens →

The same pipeline without the engineering — what happens to a folder of photos, in plain English. The page to send someone who just wants to know it is safe.

read the source ↗·home·press ← → for the next section, or ? for every key
© LocalLens · AGPL-3.0net: idle←→ sectionsstars — · forks — · visits —

locallens@community:~$ keys

Jump to a section

press g, then the letter

gpPost
gaAnnouncement
gbBlog
gwHow it workswalkthrough
giPipelineinside the engine
gcContact
ghHome

Move around

no prefix — just press it

←Previous sectionwraps around
→Next sectionwraps around
↑Scroll up
↓Scroll down
PgUpScroll up a screen
PgDnScroll down a screen
HomeTop of the page
EndBottom of the page
TabNext link or controlShift+Tab goes back

The command line

the prompt in the header

/Focus the command linefrom anywhere
TabAccept the suggestionthe dim text after the cursor
→Accept the suggestionfrom the end of the line
EnterRun what you typed
↑Previous commandwhile the prompt has focus
↓Next commandwhile the prompt has focus
EscClear and leave the prompt
?Open this listor type keys

Nothing here needs a mouse. Type help at the prompt for the command list.