Python CLI tools for geotagging photos/videos with Google Location History and adjusting photo timestamps by time offset.
- Smart timezone handling without timezone guessing
- Multiple timeline formats: semanticSegments, legacy, timelineObjects
- Comprehensive format support: JPG, PNG, TIFF, WebP, DNG, RAW from all major camera brands, MP4/MOV videos
- Batch exiftool reads (reduce 500 subprocess calls to just 3) — ~10x faster
- Parallel processing with configurable workers (default: 4)
- Backup mode keeps
_originalfiles - Dry-run mode to preview changes before committing
- Interactive and command-line modes
- Detailed logging with auto-incrementing log files
- Adjust timestamps by specified amounts:
+08:00:00,-00:30:00,+1:12:00:00, etc. - Preview-and-confirm interactive mode — shows first file's time before/after shift
- Parallel processing with configurable workers (default: 4)
- All formats supported: same as geotagging tool (JPG, DNG, RAW, MP4/MOV, etc.)
- Works with drone photos that have wrong timezone (UTC vs local time)
- Device clock correction (camera clock was slow/fast)
- Batch or single-file processing
- Dry-run mode to preview changes
- Interactive and command-line modes
| Problem | Tool | Example |
|---|---|---|
| Photos have no location/GPS data | Geotagging (tagger_cli.py) |
Photos from trip, need to add GPS from Google Timeline |
| Photos have wrong timestamp/timezone | Time Shift (shift_time_cli.py) |
Drone recorded in UTC, need local time; or camera clock was wrong |
| Both problems | Use both tools in sequence | First shift timestamps to correct time, then geotag with GPS |
- Python 3.10+
- exiftool (system command)
- Windows: winget install exiftool
- macOS: brew install exiftool
- Linux: sudo apt-get install libimage-exiftool-perl
pip install -r requirements.txtFor geotagging photos with GPS locations:
python tagger_cli.py # Interactive mode (guided)
python tagger_cli.py --timeline timeline.json --input ./photos # Command-line modeFor adjusting photo timestamps:
python shift_time_cli.py # Interactive mode (preview + confirm)
python shift_time_cli.py --input ./photos --shift +08:00:00 # Command-line modeSimply run with no arguments for guided prompts:
python tagger_cli.pyYou'll be prompted to:
- Select your timeline.json file (use
.for current directory, or full path) - Choose photos/videos to geotag
- Configure time margin, file extensions, and other options
- Preview changes in dry-run mode before committing
Tips for interactive mode paths:
- For photo folders:
.(current dir),.\subfolder, orC:\Users\name\Photos - For log files: Enter a filename, not a directory
- ✅ Correct:
tagger.logor./logs/tagger.log - ❌ Wrong:
.or./logs(these are directories)
- ✅ Correct:
- Use
.\subfolder(Windows) or./subfolder(macOS/Linux) for subdirectories - Use absolute paths like
C:\Users\name\Photosor/Users/name/Photoswhen needed
For scripting or automation:
# Geotag a single photo
python tagger_cli.py --timeline timeline.json --input photo.jpg
# Geotag all JPEGs in a folder with 60-minute margin
python tagger_cli.py --timeline timeline.json --input ./trip --recursive --time-margin 60
# Process with 8 parallel workers (faster on multi-core systems)
python tagger_cli.py --timeline timeline.json --input ./photos --workers 8
# Sequential processing (use if parallel causes issues)
python tagger_cli.py --timeline timeline.json --input ./photos --workers 1
# Preview without making changes (dry-run)
python tagger_cli.py --timeline timeline.json --input ./photos --dry-run
# Save detailed log to file
python tagger_cli.py --timeline timeline.json --input ./photos --log-file tagger.log --verboseIf the timeline and photos are in the same directory as the script:
Windows:
# Current directory (same folder as script)
python tagger_cli.py --timeline timeline.json --input .
# Subdirectory in current location
python tagger_cli.py --timeline timeline.json --input .\subfolder
# Absolute path
python tagger_cli.py --timeline C:\Users\YourName\timeline.json --input C:\Users\YourName\PhotosmacOS/Linux:
# Current directory (same folder as script)
python tagger_cli.py --timeline timeline.json --input .
# Subdirectory in current location
python tagger_cli.py --timeline timeline.json --input ./subfolder
# Absolute path
python tagger_cli.py --timeline /Users/yourname/timeline.json --input /Users/yourname/PhotosNote: Python accepts both / and \ on Windows, but the native separator is \.
In command-line mode, wrap the path in quotes:
Windows:
# Subdirectory with space
python tagger_cli.py --timeline timeline.json --input ".\My Photos"
# Absolute path with spaces
python tagger_cli.py --timeline "C:\Users\John Doe\timeline.json" --input "C:\Users\John Doe\My Photos"macOS/Linux:
# Subdirectory with space
python tagger_cli.py --timeline timeline.json --input "./My Photos"
# Absolute path with spaces
python tagger_cli.py --timeline "/Users/John Doe/timeline.json" --input "/Users/John Doe/My Photos"In interactive mode, just type the path normally - spaces are no problem:
Enter path to timeline.json: C:\Users\John Doe\timeline.json
Enter path to photo/folder: .\My Photos
- Standard: JPG, JPEG, PNG, TIFF, TIF, WebP
- Raw (Adobe): DNG
- Raw (Sony): ARW, SRF, SR2
- Raw (Canon): CR2, CR3, CRW
- Raw (Nikon): NEF, NRW
- Raw (Fujifilm): RAF
- Raw (Panasonic/Lumix): RW2, RWL
- Raw (Olympus): ORF
- Raw (Pentax): PEF, PTX
- Raw (Epson): ERF
- Raw (Samsung): SRW
- Raw (GoPro): GPR
- Raw (Hasselblad): 3FR
- MP4, MOV
All formats are processed by default. Use --extensions to customize.
- --timeline FILE: Google Timeline JSON file (optional if running interactively)
- --input PATH: File or folder to process (optional if running interactively)
- --time-margin N: Max time difference in minutes (default: 30)
- --timeout N: exiftool subprocess timeout in seconds (default: 60). Increase for large video files.
- --dry-run: Show what would be tagged without writing
- --log-file FILE: Write detailed log to file
- --backup: Keep _original backup files
- --recursive: Process subfolders recursively
- --extensions EXT: Comma-separated extensions (default: all supported formats listed above)
- --workers N: Number of parallel workers for processing (default: 4). Use 1 for sequential processing (equivalent to original behavior)
- -v, --verbose: Enable DEBUG level logging
If you encounter "exiftool timed out" errors with large files, increase the timeout:
# For large 4K videos (increase to 120-180 seconds)
python tagger_cli.py --timeline timeline.json --input ./videos --timeout 180
# For very large or slow storage (increase to 300+ seconds)
python tagger_cli.py --timeline timeline.json --input ./videos --timeout 300Timeout applies to each file write operation independently, not the entire batch. Default is 60 seconds, which works for most files.
Parallel Processing (default: 4 workers)
- Uses a thread pool to process files in parallel after batch-reading timestamps
- Recommended for SSD drives and typical use (10x+ faster than sequential)
- Each worker runs independently without synchronization overhead
Sequential Processing (--workers 1)
- Processes files one at a time (original behavior)
- Useful if you encounter issues with parallel execution
- Identical output and tagging logic as default
Batch Timestamp Reading
- All approaches use batch exiftool reads (500 files → 3 subprocess calls instead of 500)
- Reduces CPU/I/O overhead regardless of worker count
- Per-file error isolation (one malformed file doesn't fail the batch)
The tool logs the time difference between the photo and GPS point for each successful match. Use --verbose and --log-file to see detailed match quality:
python tagger_cli.py --timeline timeline.json --input ./photos --verbose --log-file tagger.logIn the log file, you'll see lines like:
[DEBUG] Time delta: +0.1 min (+6 sec) ← Photo 6 sec BEFORE GPS point
[DEBUG] Time delta: +2.9 min (+174 sec) ← Photo 2.9 min before GPS point
[DEBUG] Time delta: -10.2 min (-610 sec) ← Photo 10.2 min AFTER GPS point
Understanding the sign (+ or -):
- POSITIVE (+): Photo was taken before the GPS point was logged
- NEGATIVE (-): Photo was taken after the GPS point was logged
What to look for:
- Close to 0 (±0-30 sec): Excellent match, device clocks well synchronized
- ±1-5 min: Good match, typical GPS update intervals
- ±5-30 min: Acceptable if within your
--time-margin, but check if timezone or clock is off - Consistent pattern (all +/all -): Device camera clock running consistently fast or slow
- > time margin: Files skipped, no match found
Use this to:
- Detect timezone synchronization issues (consistently off by N minutes)
- Validate matching quality for specific files
- Troubleshoot why certain photos don't match
- Assess GPS logging gaps during your trip
When using --log-file, the tool automatically creates unique filenames if the file already exists:
- First run:
tagger.log - Second run:
tagger.log.1 - Third run:
tagger.log.2 - And so on...
This ensures you never lose log history. Each run gets its own log file, making it easy to:
- Compare results across multiple runs
- Debug issues from different processing attempts
- Keep a complete audit trail without manual renaming
Example:
# First run creates tagger.log
python tagger_cli.py --timeline timeline.json --input ./photos --log-file tagger.log
# Second run creates tagger.log.1 (tagger.log is preserved)
python tagger_cli.py --timeline timeline.json --input ./photos --log-file tagger.log
# Third run creates tagger.log.2
python tagger_cli.py --timeline timeline.json --input ./photos --log-file tagger.logAdjust photo and video timestamps when they have the wrong time or timezone offset. Common use cases:
- Drone photos recorded in UTC but need local time
- Camera clock was set incorrectly (slow or fast)
- Device clock didn't adjust for daylight saving time
- DNG files with timezone issues (same
-api ignoreMinorErrors=1fix as geotagging tool)
Run with no arguments for guided setup with live preview:
python shift_time_cli.pyWhat happens:
- Select a folder with photos/videos
- Tool reads and displays the first file's current timestamp
- You enter a time shift amount (e.g.,
+08:00:00,-00:30:00) - Tool shows the preview: original time → new time after shift
- Confirm the direction and magnitude before applying to all files
- Optionally configure other options (dry-run, backup, workers, logging)
This interactive preview prevents mistakes — you can see exactly what the shift will do before committing!
# Shift all files in a folder by +8 hours
python shift_time_cli.py --input ./drone_photos --shift +08:00:00
# Shift by -30 minutes with backup
python shift_time_cli.py --input ./photos --shift -00:30:00 --backup
# Shift by 1 day 2 hours with parallel processing (4 workers)
python shift_time_cli.py --input ./photos --shift +1:02:00:00 --workers 4
# Preview without making changes (dry-run)
python shift_time_cli.py --input ./photos --shift +08:00:00 --dry-run
# Save detailed log to file
python shift_time_cli.py --input ./photos --shift +08:00:00 --log-file shift.log --verboseTime shift amount: [+|-][DD:]HH:MM:SS
Examples:
+08:00:00— shift forward by 8 hours-00:30:00— shift backward by 30 minutes+1:12:00:00— shift forward by 1 day and 12 hours (use+1:12:00:00format)+01:02:03— shift forward by 1 hour, 2 minutes, 3 seconds
Note: For negative shifts in command-line mode, use --shift=-00:30:00 (with equals) to avoid argparse treating it as a flag.
--input PATH: Folder with photos/videos (interactive mode if omitted)--shift SHIFT: Time shift:[+|-][DD:]HH:MM:SS(interactive mode if omitted)--dry-run: Show what would be changed without writing--backup: Keep_originalbackup files (default: overwrite)--recursive: Process subfolders recursively--extensions EXT: Comma-separated extensions (default: all supported formats)--log-file FILE: Write detailed log to file--verbose/-v: Enable DEBUG level logging--workers N: Number of parallel workers (default: 4). Use 1 for sequential.--timeout N: exiftool subprocess timeout in seconds (default: 60)
Same as tagger_cli.py: JPG, PNG, TIFF, WebP, DNG, RAW files from all major camera brands (Sony, Canon, Nikon, Fujifilm, Panasonic, Olympus, Pentax, Epson, Samsung, GoPro, Hasselblad), and MP4/MOV videos.
- Reads DateTimeOriginal and CreateDate from images (EXIF tags)
- Reads CreateDate and MediaCreateDate from videos (QuickTime tags)
- Applies the specified time shift to all timestamps
- Handles both EXIF and raw formats with the same
-api ignoreMinorErrors=1flag - Preserves file creation time, only shifts timestamp metadata
Drone camera with wrong timezone:
# Drone was set to UTC, local timezone is UTC+8
python shift_time_cli.py --input ./drone_photos --shift +08:00:00Camera clock was slow:
# Camera was 2 hours behind
python shift_time_cli.py --input ./photos --shift +02:00:00 --backupDevice adjusted for daylight saving time:
# Clock needs to go back 1 hour
python shift_time_cli.py --input ./photos --shift -01:00:00 --dry-runCompares naive local times directly - no timezone guessing required.
The GPS point provides UTC time plus timezone offset, which we convert to local time. The image EXIF provides naive local time. These match directly without timezone conversion ambiguity.
Google's Timeline export can be inconsistent with timezone data:
- Some segments have
startTimeTimezoneUtcOffsetMinutes(e.g., 660 for UTC+11) ✓ - Some segments don't, but timestamps have embedded timezone (e.g.,
+01:00in ISO string) ❌
The tool handles this intelligently:
- Primary source: Uses
startTimeTimezoneUtcOffsetMinuteswhen available (most reliable) - Fallback: If missing, extracts timezone from the embedded ISO timestamp
- Propagation: If a segment has no timezone offset, uses the last known timezone from previous segments (this handles Google's inconsistency)
Result: Photos get tagged with correct timezone even if your timeline.json has missing or conflicting timezone data.
When traveling across multiple time zones, there is a potential edge case where photos cannot be uniquely matched to GPS points:
-
The issue:
local_time(the naive datetime used for matching) is not globally unique across timezone boundaries.- Example: a photo taken in Melbourne at 08:00 AM (UTC+11) and a photo taken in London at 08:00 AM (UTC+0) on the same day produce identical
local_timevalues (2024-03-15 08:00:00). - The binary search algorithm cannot distinguish between these two different moments in absolute time.
- Example: a photo taken in Melbourne at 08:00 AM (UTC+11) and a photo taken in London at 08:00 AM (UTC+0) on the same day produce identical
-
When it matters: If you travel across time zones and take photos in both zones on the same calendar date, some photos might be matched to GPS points from the wrong timezone leg.
-
Workaround: Process photos from each timezone leg separately:
- Export one leg at a time (e.g., all Melbourne photos, then all London photos)
- Use separate timeline.json exports if available
- Or accept the small risk if the trip is short and timezone changes are minor
-
When it works fine: Single-timezone trips, or when all photos and GPS points fall within the same timezone window.
pytest tests/ -v
pytest tests/ --cov=tagger --cov-report=htmlPhoto-Location-Tagger/
├── tagger/
│ ├── __init__.py
│ ├── utils.py # Coordinate normalization, timezone utilities
│ ├── timeline_parser.py # Parse timeline.json to list of GPSPoint
│ ├── location_finder.py # Binary-search closest GPSPoint
│ ├── exif_writer.py # Write GPS and OffsetTimeOriginal
│ └── time_shifter.py # Shift datetime by offset amount
├── tagger_cli.py # Geotagging CLI entry point
├── shift_time_cli.py # Time shift utility CLI entry point
├── tests/
│ ├── conftest.py # Pytest fixtures
│ ├── fixtures/
│ │ └── sample_timeline.json
│ ├── test_timeline_parser.py
│ ├── test_location_finder.py
│ ├── test_exif_writer.py
│ └── test_e2e.py
├── requirements.txt
└── README.md
Note: As of 2025, Google Location History is no longer available in Google Takeout. You must export it directly from your device.
- Open Google Maps on your phone
- Tap your profile picture → Settings → Location Settings
- Tap Timeline (or Your timeline)
- Tap the menu icon (⋮) → Settings and privacy → Export your timeline
- Select the date range and format (choose JSON)
- Download the exported file to your computer
- The file will be named something like
timeline.jsonortimeline-YYYY.json
Place the timeline.json file in an accessible location and point the tool to it:
python tagger_cli.py --timeline /path/to/timeline.json --input ./photosOr run interactively (no parameters needed):
python tagger_cli.pyCause: Large 4K video files can take longer than the default timeout to process.
Solution: The tool now uses:
- 60-second timeout for writes (suitable for 4K video files)
- 30-second timeout for reads
- These are set automatically; no configuration needed
If you encounter timeouts on extremely large files (>5GB), the timeouts can be increased by modifying tagger/exif_writer.py and raising the timeout=60 values.
Cause: If exiftool is interrupted or crashes during writing, it leaves a temporary file (<filename>_exiftool_tmp) that blocks future writes to the same file.
Solution: The tool automatically cleans up stale temporary files before attempting writes. If you manually need to clean them:
# Remove all stale exiftool temp files in a directory
find . -name "*_exiftool_tmp" -deleteCause: DNG (raw) files from some cameras show maker note parsing warnings, which exiftool previously treated as fatal errors.
Solution: The tool now uses the -api ignoreMinorErrors=1 flag, which:
- Treats maker note warnings as non-fatal
- Still writes GPS coordinates and timezone data successfully
- Matches behavior of ExiftoolGUI and other professional tools
DNG files should now geotag successfully alongside JPGs.
Cause: MP4/MOV video files use QuickTime tags instead of EXIF tags for metadata, which older code didn't support.
Solution: The tool now:
- Detects video files (.mp4, .mov, .m4v) automatically
- Reads QuickTime tags (CreateDate, MediaCreateDate) for videos
- Falls back to EXIF tags for image files
- Works seamlessly in both single-file and batch mode
Video timestamps should now be found and processed normally.
MIT