5 min read, 1000 words

Immich, QGIS and GeoJSON

Maps and geographical data have always been a passing interest for me, from reading and following printed maps (yes I grew up with a Thomas Guide and could actually navigate before mapquest) wanting to and failing to draw fantasy maps, and just generally enjoying working with data systems. I’ve worked with postgis in the past, but nothing fancy, mostly zip code math off of the TIGER shapefiles, but I’ve been wanting to find a project to explore GIS more deeply with, specifically QGIS. While playing around with Immich’s map function, I realized I had an intereting geospatial dataset of my own, right in front of me. I spent a couple of minutes looking around and didn’t find any obvous knobs to get simple meta-data exports of what I wanted, so I turned to the API. The docs gave me a couple of ideas, before I realized that the map marker endpoint would basically hand me everything I needed, I just needed to wrap it in a bit of json and call it a GeoJSON. Almost instantly I had a GeoJSON file with 41k points in it, that imported cleanly into QGIS.. all from my own data generated with and for open source software. From there I was able to get the points added as a layer, generate some heatmaps, include urls for thumbnails, original image links, etc. Without a basemap it even kinda looks like the US if you squint!

Plot of my photo locations with no basemap, vageuly resembles the continental United States, very sparsely.

Plot of my photo locations with no basemap

Anyway, on to more serious things, like heatmaps! I grabbed the Density Analysis plugin based on a video I watched and guess and clicked my way to victory!
Heatmap of point density at a very broad pixel size covering roughly the Santa Barbara County area.

Heatmap of roughly Santa Barbara County

I was able to get way more granular with smaller kernel sizes, but I don’t want to completely dox myself with inferred positioning data.

Also while scrolling around playing ‘GPS anomoly or actual photo in random area I found a fun string of rainbow photos we took driving (I was passenging, don’t worry) northbound on the 101:

Line of photographs snapped to the 101 northbound

Line of photos taken on the highway

Since I’m not a tease, here’s one of the better resulting photos! The color is getting lost somewhere in my processing pipeine between phone and browser, so I’ll have to sort it out, but imagine more color and less grey!
Full rainbow against a cloudy sky, with a car's shadow stretching into the grass on the side of the highway

Full horizon rainbow

And since it worked, here’s the script if others might find it useful.

#!/usr/bin/env python3
"""
Immich Location Data Exporter
Query an Immich server and export image location data in GeoJSON format for QGIS
"""

import requests
import json
import sys
from typing import List, Dict

class ImmichLocationExporter:
    def __init__(self, server_url: str, api_key: str):
        """
        Initialize the Immich exporter
        
        Args:
            server_url: Base URL of the Immich server (e.g., 'http://localhost:2283')
            api_key: API key for authentication
        """
        self.server_url = server_url.rstrip('/')
        self.api_key = api_key
        self.headers = {
            'x-api-key': api_key,
            'Accept': 'application/json'
        }
    
    def get_map_markers(self) -> List[Dict]:
        """
        Fetch map markers from the Immich server (assets with location data only)
        
        Returns:
            List of marker dictionaries with location data
        """
        print(f"Fetching map markers from {self.server_url}...", file=sys.stderr)
        
        url = f"{self.server_url}/api/map/markers"
        
        try:
            response = requests.get(url, headers=self.headers)
            response.raise_for_status()
            markers = response.json()
            
            print(f"Retrieved {len(markers)} markers with location data", file=sys.stderr)
            return markers
        
        except requests.exceptions.RequestException as e:
            print(f"Error fetching map markers: {e}", file=sys.stderr)
            print(f"Make sure your Immich server supports the map API", file=sys.stderr)
            sys.exit(1)
    
    def create_geojson(self, markers: List[Dict]) -> Dict:
        """
        Convert map markers to GeoJSON format
        
        Args:
            markers: List of map markers with location data
            
        Returns:
            GeoJSON FeatureCollection dictionary
        """
        features = []
        
        for marker in markers:
            lat = marker.get('lat')
            lon = marker.get('lon')
            
            # Skip if missing coordinates, no one wants to go to null island today
            if lat is None or lon is None:
                continue
            
            asset_id = marker.get('id')
            
            # Build URLs only if we have both server URL and asset ID
            web_url = None
            thumbnail_url = None
            original_url = None
            
            if asset_id:
                web_url = f"{self.server_url}/photos/{asset_id}"
                thumbnail_url = f"{self.server_url}/api/assets/{asset_id}/thumbnail"
                original_url = f"{self.server_url}/api/assets/{asset_id}/original"
            
            properties = {
                'id': asset_id,
                'web_url': web_url,
                'thumbnail_url': thumbnail_url,
                'original_url': original_url,
                'city': marker.get('city'),
                'state': marker.get('state'),
                'country': marker.get('country'),
            }
            
            properties = {k: v for k, v in properties.items() if v is not None}
            
            feature = {
                'type': 'Feature',
                'geometry': {
                    'type': 'Point',
                    'coordinates': [lon, lat]  # GeoJSON uses [longitude, latitude]
                },
                'properties': properties
            }
            
            features.append(feature)
        
        # Create GeoJSON FeatureCollection
        geojson = {
            'type': 'FeatureCollection',
            'features': features,
            'crs': {
                'type': 'name',
                'properties': {
                    'name': 'EPSG:4326'  # WGS84 coordinate system
                }
            }
        }
        
        return geojson
    
    def export_to_geojson(self, output_file: str) -> None:
        """
        Export all map markers with location data to a GeoJSON file
        
        Args:
            output_file: Path to output GeoJSON file
        """
        markers = self.get_map_markers()
        
        if not markers:
            print("No markers with location data found!", file=sys.stderr)
            return
        
        geojson = self.create_geojson(markers)
        
        with open(output_file, 'w', encoding='utf-8') as f:
            json.dump(geojson, f, indent=2, ensure_ascii=False)
        
        print(f"\nSuccessfully exported {len(geojson['features'])} locations to {output_file}", file=sys.stderr)
        print(f"You can now import this file into QGIS as a vector layer.", file=sys.stderr)


def main():
    """Main entry point"""
    import argparse
    import os
    
    parser = argparse.ArgumentParser(
        description='Export Immich image location data to GeoJSON for QGIS'
    )
    parser.add_argument(
        'server_url',
        help='Immich server URL (e.g., http://localhost:2283)'
    )
    parser.add_argument(
        'api_key',
        nargs='?',
        help='Immich API key (can also be set via IMMICH_API_KEY environment variable)'
    )
    parser.add_argument(
        '-o', '--output',
        default='immich_locations.geojson',
        help='Output GeoJSON file (default: immich_locations.geojson)'
    )
    
    args = parser.parse_args()
    
    api_key = args.api_key or os.environ.get('IMMICH_API_KEY')
    
    if not api_key:
        print("Error: API key must be provided either as an argument or via IMMICH_API_KEY environment variable", file=sys.stderr)
        sys.exit(1)
    
    exporter = ImmichLocationExporter(args.server_url, api_key)
    exporter.export_to_geojson(args.output)


if __name__ == '__main__':
    main()

As always, hit me up on mastodon if you have questions or comments!


Meshcore and Heltec T114 Solar Discoveries