A small Python toolkit for turning a folder of geotagged photos into a JSON document describing a tree appreciation walk — an ordered sequence of points, each backed by a photo, ready to render in a map/gallery UI.
One folder per walk under walks/, carried through three stages. The source
folder is the source of truth and is edited in place; publication produces a
separate, throwaway artifact you can rebuild any time before deploy.
downloaded photos extract edit publish
(full EXIF, GPS) ──► tree-walks ──► tree-walks-editor ──► tree-walks-publish
│
walks/<name>/*.jpg ─────────────────── edited in place ──────────► publish/<name>/
+ walk.json (walk.json) walk.json + media/
| Stage | Command | Does |
|---|---|---|
| 1. Extract | tree-walks walks/<name> |
Read EXIF, order by capture time, write walk.json into the folder. |
| 2. Edit | tree-walks-editor walks/<name> |
Fix GPS, reorder, add titles/notes, pick the hero, shape the route. |
| 3. Publish | tree-walks-publish walks/<name> |
Write a clean, deployable publish/<name>/ (slim walk.json + web-sized media/). |
Uses uv for dependency management.
Every walk lives in its own folder under walks/ — drop a folder of photos in
there, then point the tool at it:
uv run tree-walks walks/london-tree-appreciation-walk📍 Photos work best with GPS location. Each stop is placed on the map from the photo's embedded GPS coordinates, so ideally shoot with location enabled — on most phones it's a per-app setting (iOS: Settings → Privacy & Security → Location Services → Camera; Android: Camera → Settings → Location tags). It doesn't have to be on all the time: geotagging every photo you take is a real privacy trade-off, so it's fine to switch it on just for a walk and off again afterwards. Photos without GPS aren't dropped — they're flagged
location_missing: trueand you place them by hand in the editor.
This scans the folder, reads each image's EXIF, lays the points out in capture-time
order, and writes walk.json alongside the photos.
walk.json is edited (by hand or via the editor UI) after it's generated —
correcting bad GPS, adding titles/notes — so re-running merges rather than
overwrites. Keyed by each point's stable id (the image filename stem):
- a photo already in
walk.jsonkeeps its saved point untouched, including its position — your edits and any manual re-ordering win; - a new photo added to the folder becomes a fresh point from its EXIF, appended after the existing points;
- a photo removed from the folder drops out of the walk.
Capture time only seeds the order: a fresh walk is laid out by EXIF timestamp, but
the timestamp is never stored, so once a walk exists its saved point order is what
sticks. Pass --rebuild to throw away the existing file and regenerate purely from
EXIF (discards edits and re-orders by capture time).
tree-walks <folder> [-o OUTPUT] [-n NAME] [--rebuild]
folder Folder of images to turn into a walk.
-o, --output Output path (default: <folder>/walk.json). Use '-' for stdout.
-n, --name Walk name (default: keep existing, else derive from folder name).
--rebuild Ignore the existing walk.json; rebuild from EXIF, discarding edits.
Once a walk.json exists, open the behind-the-scenes editor to sanity-check the
route and fix things before publication:
uv run tree-walks-editor walks/london-tree-appreciation-walkThis starts a local server (default http://127.0.0.1:8757) and opens a map + gallery editor in your browser. From there you can:
- See the ordered route on a map, with numbered, draggable markers and the photo for each stop.
- Reorder stops by dragging rows in the sidebar (the
sequencenumbers re-derive automatically). - Fix bad GPS by dragging a marker, or by typing new coordinates.
- Repair missing locations (
location_missing: true) — pick the point and click Set on map to drop it where it belongs. - Add titles and notes for each stop.
- Choose the cover image — pick a stop and click Use as walk cover to mark
its photo as the walk's hero image (
hero_image), the lead image a published site shows at the threshold. Only one stop can be the cover.
Hit Save (or ⌘/Ctrl-S) to write back to walk.json; the previous version is
kept alongside as walk.json.bak. The server re-sequences points, recomputes
bounds/point_count, clears location_missing for any point you've located,
and stamps an edited_at. Download exports the JSON without touching disk.
Options: --port, --host, --no-open. The editor pulls map tiles from
OpenStreetMap, so it needs a network connection.
Once a walk is edited, turn it into a clean, deployable folder:
uv run tree-walks-publish walks/london-tree-appreciation-walkThis reads the source folder (never mutating it) and writes a separate,
self-contained artifact — by default to publish/<walk>/:
publish/london-tree-appreciation-walk/
walk.json # slim public schema, relative media/ paths
media/
IMG_*.jpg # web-sized (~1600px, q80), all metadata stripped
What it does:
- Optimizes images — resizes each photo so its longest edge is ≤ 1600px and strips all metadata. This is not optional polish: your originals still carry GPS and camera tags, and a public site must not ship those. (It also turns ~7 MB originals into ~0.7 MB web images.)
- Anonymises filenames — renames images to their walk sequence (
01.jpg,02.jpg, …). Phone filenames likeIMG_20260705_192004.jpgencode the exact capture time in plaintext; next to each point's public location that would re-leak the space+time trace stripping EXIF is meant to prevent. The source folder keeps its original names — only this throwaway published copy is renamed. - Slims the JSON — keeps only what a viewer needs and rewrites image paths.
Kept per-point:
id,sequence,image,location(lat/lng),title,notes,route_waypoint(the map draws the route through it). Theidandimageare re-derived to the anonymised sequence stem (01,01.jpg). Dropped:edited_at,route_vias, per-pointlocation_missing,location.altitude, and the route'sprofile/signature/generated_at. - Gates on readiness — because publish is the last check before deploy, it reports what isn't ready: a point with no location is a hard error (it can't go on the map); empty titles/notes and a missing hero image are warnings (the site is only as good as those prompts).
To publish straight into the sibling Astro site (the
tree-appreciation-walks
repo, cloned alongside this one), point --to at its root instead of using -o:
uv run tree-walks-publish walks/london-tree-appreciation-walk --to ../tree-appreciation-walksAstro wants a walk split across two locations, so this writes:
../tree-appreciation-walks/
public/walks/london-tree-appreciation-walk/IMG_*.jpg # served at /walks/<id>/…
src/content/walks/london-tree-appreciation-walk.json # the content-collection entry
In this layout each point's image is a bare filename (the site resolves it
against /walks/<id>/), while hero_image is a full path
(/walks/<id>/<file>) so it's usable on its own — index cards, the page hero,
og:image. Only this walk's image folder and JSON file are replaced on
re-publish; sibling walks are left untouched. The published schema must match
tree-appreciation-walks/src/content.config.ts — keep the two in sync.
tree-walks-publish <folder> [-o OUTPUT | --to SITE] [--no-optimize]
[--max-edge PX] [--quality Q] [--allow-incomplete]
-o, --output Self-contained output dir (default: publish/<folder-name>).
--to SITE Publish into an Astro site root instead of -o.
--no-optimize Copy originals as-is (WARNING: keeps EXIF, incl. GPS).
--max-edge Longest edge in px for resized images (default: 1600).
--quality JPEG quality 1–95 (default: 80).
--allow-incomplete Publish even if some points have no location.
Output is fully regenerated each run (the previous copy of that walk is
replaced), so re-run it freely as you finish editing. The self-contained
publish/ folder is git-ignored — treat it as a build artifact, not source.
Per image, via EXIF (Pillow) — only what's needed to map the route:
- Capture time (
DateTimeOriginal) — used to order the walk. - GPS latitude / longitude / altitude — decoded from EXIF DMS to decimal degrees.
Identifying tags (camera make/model, etc.) are intentionally not read, to
prefer anonymity. Photos without a timestamp are appended at the end (in filename order) rather
than dropped. Photos without GPS are kept and flagged with
location_missing: true so the editing UI can prompt for a corrected location.
This is the source/working walk.json produced by extract and edited by the
editor. Publishing emits a slimmer variant (see Publishing a walk)
with the editor-only fields removed and image paths rewritten for the target.
title, notes, location, route_waypoint, and hero_image are the fields
the editor lets you set (several photos have bad GPS fixes). route,
route_vias, google_maps_url, and edited_at are written by the editor and
carried across re-runs, so hand-shaped routing is never silently dropped.
schema_version lets the format evolve safely.
walks/ # source walks live here, one folder each
london-tree-appreciation-walk/ # (contents git-ignored: your originals carry GPS/EXIF)
IMG_*.jpg # originals, full EXIF
walk.json # working format (generated + edited)
publish/ # generated, deployable (git-ignored)
london-tree-appreciation-walk/
walk.json # slim public schema
media/IMG_*.jpg # web-sized, metadata-stripped
src/tree_walks/
exif.py # read EXIF: timestamp + GPS (DMS->decimal) only
walk.py # data models + build_walk(): ordering, bounds, schema
cli.py # argparse entrypoint (tree-walks)
editor.py # local map editor server (tree-walks-editor)
editor.html # the editor's single-page UI (Leaflet map + gallery)
publish.py # build the deployable artifact (tree-walks-publish)
{ "id": "london-tree-appreciation-walk", // folder name "name": "London Tree Appreciation Walk", "schema_version": 1, "created_at": "2026-07-06T...Z", // UTC ISO-8601, first generation "point_count": 16, "bounds": { // null if no photo has GPS "min_lat": 51.573, "min_lng": -0.154, "max_lat": 51.584, "max_lng": -0.123 }, "points": [ { "id": "IMG_20260705_165608", // image stem, stable id "sequence": 1, // 1-based walk order "image": "IMG_20260705_165608.jpg", // filename, relative to folder "location": { "lat": 51.583, "lng": -0.131, "altitude": 0.0 }, "title": "", // editable — for the UI "notes": "", // editable — for the UI "location_missing": false, // true when GPS was absent "route_waypoint": true // false = skip this stop on the route } ], "hero_image": null, // cover photo filename, or null (set in editor) "route": null, // editor-saved foot-walking route, or null "route_vias": [], // [lng, lat] shaping points for the route "google_maps_url": null, // walking-directions link through the waypoints "edited_at": "2026-07-06T...Z" // added by the editor on save }