Auto-enrich photos with GPS coordinates from other photos taken nearby in time
Project description
GeoSnag
Automatically enrich GPS-less camera photos using coordinates from phone photos taken on the same day.
You shoot with a DSLR or mirrorless camera that has no GPS module. Your phone is always in your pocket, geotagging every shot. After a trip, your camera photos have no location data while your phone photos do.
GeoSnag fixes this. Point it at your photo directories, and it matches each GPS-less photo to the nearest geotagged photo by timestamp — then copies the coordinates over. No manual sorting, no separate camera/phone folder setup. It just figures it out.
Built for Synology NAS, but works anywhere Python runs.
How It Works
GeoSnag runs a four-stage pipeline:
- Scan — reads EXIF metadata from every photo in your configured directories, using multithreaded I/O and an on-disk index cache so repeat runs are fast.
- Classify — photos that already carry GPS coordinates become sources. Photos without GPS become targets. This happens automatically based on metadata — no folder-based configuration needed.
- Match — for each target, GeoSnag finds the closest source by timestamp within the same calendar day. Each match gets a confidence score based on how close in time the two shots were. You can set a maximum time window (default ±2 hours) and a minimum confidence threshold.
- Write — copies GPS coordinates from the matched source into the target. Supports writing directly to EXIF, creating XMP sidecar files, or both. Every modification is preceded by a backup.
Features
Broad Format Support
GeoSnag reads and writes EXIF metadata for all major camera formats: JPEG, ARW (Sony), NEF (Nikon), CR2 and CR3 (Canon), DNG, ORF (Olympus), RAF (Fuji), RW2 (Panasonic), PNG, HEIC, and HEIF. HEIC/HEIF support is provided through pillow-heif, so Apple ProRAW and modern iPhone photos work out of the box.
Intelligent Matching
Matching is timestamp-based with per-match confidence scoring. A phone photo taken 5 minutes before your camera shot scores higher than one taken 90 minutes later. The configurable time window (default ±2 hours, adjustable via --max-delta) and optional minimum confidence threshold let you tune the tradeoff between coverage and accuracy. All matching is day-scoped — GeoSnag won't accidentally pair a Monday photo with a Tuesday GPS source.
Three Write Modes
Choose how GPS data is written to your files:
exif(default) — writes GPS coordinates directly into the photo's EXIF metadata using pyexiv2. This is the most compatible option: photo apps, map views, and cloud services will see the location immediately.xmp_sidecar— creates a.xmpcompanion file next to the original. Non-destructive — the original file is never modified. Useful for RAW workflows where you want to preserve the original file bit-for-bit.both— writes EXIF and creates an XMP sidecar. Belt and suspenders.
Performance at Scale
Photo libraries on a NAS can contain hundreds of thousands of files. GeoSnag is designed to handle this efficiently:
- Scan index — after the first run, EXIF metadata is cached to
.geosnag_index.json. Subsequent runs only read metadata from new or modified files, detected by file size and modification time. A full rescan of 100k+ photos that takes minutes on first run completes in seconds on the next. - Match cache — targets that had no match are remembered along with a fingerprint of the available GPS sources. If sources haven't changed, there's no point re-evaluating those targets. This is especially valuable for libraries where new photos are added incrementally.
- Multithreaded scanning — EXIF reads are parallelized across configurable worker threads (default: 4). On NAS hardware with slow disks but multiple cores, this makes a significant difference.
Safe by Default
GeoSnag defaults to dry-run mode. Running geosnag without --apply scans everything, finds all matches, prints a summary — and touches nothing. You review the output, then run geosnag --apply when you're satisfied.
Additional safety measures:
- No backup files needed — pyexiv2 is built on libexiv2, a mature C++ library that has been the EXIF standard for 20+ years (used by Lightroom, digiKam, darktable, ExifTool). libexiv2 performs writes atomically: it parses and modifies the full EXIF structure in memory, then writes it back in a single operation. If anything fails — disk full, permission denied, corrupt header — it raises an exception before the file is touched. The original is never partially overwritten.
- Processed-file tagging — after writing GPS data, GeoSnag stamps the file's
Exif.Image.Softwaretag with aGeoSnag:marker. On future runs, tagged files are automatically skipped. This prevents double-processing and makes re-runs safe. - GPS validation — latitude and longitude values are range-checked before writing. Invalid coordinates from corrupted EXIF data won't propagate.
- CSV reports — use
--report matches.csvto export the full match table for review before committing to--apply.
NAS-Friendly
GeoSnag was designed for Synology NAS environments:
- Automatically excludes Synology thumbnail directories (
@eaDir) and recycle bins (#recycle). - Configurable glob-based exclusion patterns for anything else you want to skip.
- Runs on Python 3.9 (available via Synology Package Center) with minimal dependencies.
- Low memory footprint — metadata is processed in streaming fashion, not loaded entirely into RAM.
Quick Start
pip install geosnag[all] # full install (includes pyexiv2 write backend)
curl -O https://raw.githubusercontent.com/zheqd/geosnag/main/config.example.yaml
cp config.example.yaml config.yaml
nano config.yaml # set your photo directories
geosnag # dry run — preview matches
geosnag --apply # write GPS data
On Synology NAS where pyexiv2/libexiv2 is unavailable, install ExifTool as the write backend:
pip install geosnag # minimal install (no pyexiv2)
opkg install perl-image-exiftool
If PyPI is not available (e.g. air-gapped Synology NAS), install the latest wheel directly from GitHub:
pip install "$(curl -s https://api.github.com/repos/zheqd/geosnag/releases/latest \
| grep browser_download_url | grep '\.whl' | cut -d'"' -f4)"
See INSTALL.md for detailed Synology NAS setup instructions.
Configuration
Copy config.example.yaml to config.yaml and edit it:
Always single-quote paths in scan_dirs. Unquoted paths with ! cause a YAML parse error, and paths with # are silently truncated at the # (treated as a comment). Single quotes are safe for all characters.
scan_dirs:
- '/volume1/photo/personal'
- '/volume1/photo/shared'
- '/volume1/homes/user/Photos/!PhotoLibrary/2016/08' # ← quotes required
- '/volume1/homes/user/photo library' # ← quotes required
recursive: true
extensions:
- .jpg
- .jpeg
- .arw
- .nef
- .cr2
- .cr3
- .dng
- .heic
- .heif
- .png
matching:
max_time_delta_minutes: 120 # ±2 hours
min_confidence: 0 # 0–100, reject matches below this
write_mode: exif # exif | xmp_sidecar | both
skip_processed: true
dry_run: true # always start with dry run
workers: 4 # parallel scan threads
log_level: INFO
exclude_patterns:
- "*/@eaDir/*"
- "*/#recycle/*"
- "*.geosnag.bak"
All directories in scan_dirs are scanned the same way. Photos with GPS become sources, photos without GPS become targets. There is no separate camera/mobile directory configuration.
CLI Reference
geosnag [OPTIONS]
--apply Write GPS data (default is dry run)
--dry-run, -n Force dry run even if config says otherwise
--config, -c PATH Path to config file (default: config.yaml)
--report, -r PATH Save match report to CSV
--write-mode, -w MODE exif | xmp_sidecar | both
--max-delta, -d MINUTES Max time difference for matching
--workers N Number of parallel scan threads
--reindex Force full rescan, ignore cached index
--rematch Force re-evaluation of all targets, ignore match cache
--no-index Disable scan index entirely
--no-skip-processed Re-process files already tagged by GeoSnag
--preview-count N Number of matches to preview (default: 20)
--verbose, -v Enable debug logging
You can also run GeoSnag as a Python module: python -m geosnag [OPTIONS].
Project Structure
geosnag/
__init__.py Version and project-wide constants
__main__.py python -m geosnag entry point
cli.py CLI argument parsing and pipeline orchestration
scanner.py EXIF metadata reader (supports all listed formats)
matcher.py Timestamp-based GPS matching with confidence scoring
writer.py GPS coordinate writer (EXIF and XMP sidecar)
index.py Scan index cache for incremental runs
parallel.py Multithreaded scanning
tests/
test_e2e.py End-to-end pipeline tests (61 tests)
test_index.py Index and cache tests (107 tests)
pyproject.toml Package metadata, dependencies, CLI entry point
config.example.yaml Configuration template
Requirements
- Python 3.9 or later
- exifread — EXIF metadata reading
- pillow-heif — HEIC/HEIF format support (pulls in Pillow automatically)
- PyYAML — configuration file parsing
- pyexiv2 (optional) — EXIF/XMP metadata writing (requires libexiv2 system library)
All Python dependencies are installed automatically via pip install .. pyexiv2 is optional — install with pip install geosnag[all] or use ExifTool as the write backend. On Synology NAS, you may need to install libexiv2 separately — see INSTALL.md.
Testing
python tests/test_e2e.py # 61 end-to-end tests
python tests/test_index.py # 107 index/cache tests
Or with pytest:
pip install pytest
pytest tests/ -v
License
Apache 2.0 — see LICENSE.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file geosnag-0.2.2.tar.gz.
File metadata
- Download URL: geosnag-0.2.2.tar.gz
- Upload date:
- Size: 52.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4edf0bfa3e23de56cd0efb4f3ebe0b934299aa9fbaf112c73c0936954472fd21
|
|
| MD5 |
285fdabf0a46b4c0d3cd659913a54ab7
|
|
| BLAKE2b-256 |
b4cb07fcdc53baabfd3e7afad160732e5e7c44f83dc074925286cddf1a427d5b
|
Provenance
The following attestation bundles were made for geosnag-0.2.2.tar.gz:
Publisher:
release.yml on zheqd/geosnag
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
geosnag-0.2.2.tar.gz -
Subject digest:
4edf0bfa3e23de56cd0efb4f3ebe0b934299aa9fbaf112c73c0936954472fd21 - Sigstore transparency entry: 976500668
- Sigstore integration time:
-
Permalink:
zheqd/geosnag@89aa9cc8a1ff1182841cb0d92dc5c09e3fa853c3 -
Branch / Tag:
refs/tags/v0.2.2 - Owner: https://github.com/zheqd
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@89aa9cc8a1ff1182841cb0d92dc5c09e3fa853c3 -
Trigger Event:
push
-
Statement type:
File details
Details for the file geosnag-0.2.2-py3-none-any.whl.
File metadata
- Download URL: geosnag-0.2.2-py3-none-any.whl
- Upload date:
- Size: 33.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8c38569bfe77821e13480196134d7c4f6ec3a9d7709d8c31587950c89fd8a3cf
|
|
| MD5 |
1f2b15db5b81a8dda6c653bde26a078a
|
|
| BLAKE2b-256 |
235a52c923ca053ad5301a9f5f07a8229b46e138ea097bef9f01d59856cd12ca
|
Provenance
The following attestation bundles were made for geosnag-0.2.2-py3-none-any.whl:
Publisher:
release.yml on zheqd/geosnag
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
geosnag-0.2.2-py3-none-any.whl -
Subject digest:
8c38569bfe77821e13480196134d7c4f6ec3a9d7709d8c31587950c89fd8a3cf - Sigstore transparency entry: 976500672
- Sigstore integration time:
-
Permalink:
zheqd/geosnag@89aa9cc8a1ff1182841cb0d92dc5c09e3fa853c3 -
Branch / Tag:
refs/tags/v0.2.2 - Owner: https://github.com/zheqd
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@89aa9cc8a1ff1182841cb0d92dc5c09e3fa853c3 -
Trigger Event:
push
-
Statement type: