Skip to main content

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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 .xmp companion 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.Software tag with a GeoSnag: 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.csv to 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.

Metadata

Release files for geosnag 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for geosnag 0.3.0
File Size Uploaded Provenance Actions
geosnag-0.3.0.tar.gz 53.4 kB With provenance Details for geosnag-0.3.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for geosnag 0.3.0
File Interpreter ABI Platform Actions
geosnag-0.3.0-py3-none-any.whl Python 3 none any Details for geosnag-0.3.0-py3-none-any.whl

Total release size: 87.5 kB

Release files / geosnag-0.3.0.tar.gz

Download URL geosnag-0.3.0.tar.gz
Size 53.4 kB
Tags Source
SHA-256 checksum
How to use checksums
a1311181ef01d7d62097047313aef142f41ef1284f1dfcdb5b61efbf245487a1
BLAKE2b-256 checksum
How to use checksums
dd6ca8503d665e201f36a61a5753f5c262d05b2d290d713623ef22108b62a1bb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.7

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Feb 25, 2026.

Transparency log

Release files / geosnag-0.3.0-py3-none-any.whl

Download URL geosnag-0.3.0-py3-none-any.whl
Size 34.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
721cf452135a603a6dad0875e596e7d6b660537fc89485894531569dc02b33e7
BLAKE2b-256 checksum
How to use checksums
c0cf6c4e3a7f13c25c0fdb83e712ff6d44b7f9b60e891111f6550933502f1709
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.7

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Feb 25, 2026.

Transparency log

Security for geosnag 0.3.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for geosnag-0.3.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Feb 25, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page