Import Geotagged Photos as Points in PyQGIS

Site surveys, asset inspections and tree audits all produce the same deliverable: a folder of a few hundred JPEGs taken on a phone or a GPS camera. Each file carries its own location in its EXIF header, so the map is already in the folder — it just needs reading out. QGIS ships an algorithm for exactly that, and wrapping it in a script makes it repeatable for every survey day.

This recipe belongs to Layer Data Sources & Formats in PyQGIS. It runs the import, deals with the photos that have no position, turns the camera direction into a rotated symbol, and puts the photo itself into the map tip.

From EXIF tags to attributesOn the left, a photo file with an EXIF block listing GPSLatitude, GPSLongitude, GPSAltitude, GPSImgDirection and DateTimeOriginal. Arrows lead to the native importphotos algorithm and then to a point layer in WGS 84 with PointZ geometry and fields photo, filename, directory, altitude, direction, rotation, longitude, latitude and timestamp.The location is already inside every fileIMG_4172.jpg · EXIFGPSLatitude 53.80082GPSLongitude -1.54911GPSAltitude 74.2GPSImgDirection 212DateTimeOriginal 2026:09:14 10:31written by the cameranative:importphotosPointZ · EPSG:4326photo full pathfilenamealtitude 74.2direction 212rotation 0timestamp datetimeplus longitude, latitude, directory

Prerequisites

  • QGIS 3.40 LTR or newer, or the QGIS 4 series. The algorithm has existed since 3.6.
  • A folder of JPEG or TIFF images with GPS tags. Phones write them when location access is enabled for the camera app; many dedicated cameras need an external GPS unit.
  • A projected CRS for your survey area if you intend to measure anything afterwards.

Run the import

The algorithm walks a folder, reads each image's EXIF block, and writes one point per photo that has a usable position. A second, optional output lists the files it could not place.

import processing
from qgis.core import QgsProject, QgsVectorLayer

survey_day = "/data/surveys/2026-09-14"

result = processing.run("native:importphotos", {
    "FOLDER": survey_day,
    "RECURSIVE": True,
    "OUTPUT": f"{survey_day}/photos.gpkg",
    "INVALID": f"{survey_day}/photos_without_gps.csv",
})

photos = QgsVectorLayer(result["OUTPUT"], "survey photos", "ogr")
print(photos.featureCount(), "photos placed")
QgsProject.instance().addMapLayer(photos)

Breakdown: RECURSIVE descends into subfolders, which suits a survey where each crew uploads to its own directory. The output is a PointZ layer in WGS 84, because that is what GPS tags hold; the z value comes from GPSAltitude when present. The photo field stores the absolute path, which is what map tips and attribute forms use to display the image — and why moving the folder later breaks the links, a problem covered in fixing broken layer paths for layers but not for paths stored as attribute values.

Account for every photo

A survey that produced 412 photos and a layer with 389 points has 23 photos somewhere. The INVALID table lists them, but it is easy to write and never read. Compare counts straight away and report the gap.

412 files in, where did they go?A bar for 412 image files splits into 389 placed photos and 23 unplaced ones. The unplaced ones are broken down into 17 files with no GPS tags because location was off or the phone had no fix, 4 files edited by software that stripped EXIF, and 2 screenshots saved into the same folder. A separate warning notes that photos at exactly 0,0 are placed but wrong.Reconcile the folder with the layer every time412 files389 placed23 not17no GPS tag: locationoff or no satellite fix4edited or resized andEXIF stripped on export2screenshots savedinto the survey folderalso check placed photos at exactly 0, 0 — tagged, but not located

from pathlib import Path
from qgis.core import QgsFeatureRequest

image_suffixes = {".jpg", ".jpeg", ".tif", ".tiff"}
on_disk = {p.resolve() for p in Path(survey_day).rglob("*")
           if p.suffix.lower() in image_suffixes}
placed = {Path(f["photo"]).resolve() for f in photos.getFeatures()}

unplaced = sorted(on_disk - placed)
print(f"{len(on_disk)} images, {len(placed)} placed, {len(unplaced)} unplaced")
for p in unplaced:
    print("  no position:", p.name)

null_island = photos.getFeatures(QgsFeatureRequest().setFilterExpression(
    "abs(\"longitude\") < 0.0001 AND abs(\"latitude\") < 0.0001"))
print("at 0,0:", [f["filename"] for f in null_island])

Breakdown: Building sets of resolved paths makes the comparison immune to trailing slashes and relative segments. The difference is the authoritative list of photos without a position, independent of whether the INVALID output was requested. The zero-position check catches a quieter failure: some devices write 0, 0 when they have no fix instead of omitting the tag, and those photos are placed — in the Gulf of Guinea. Remove them with a delete or a subset string before anyone zooms to the layer extent and wonders why it covers half the planet.

Reproject and point the symbols the way the camera faced

For analysis and for symbol rotation that looks right on a projected map, copy the points into your survey's CRS. Then use the direction field — the compass bearing the camera was facing — to rotate an arrow or a view-cone marker.

from qgis.core import (
    QgsProperty, QgsSymbolLayer, QgsMarkerSymbol, QgsSingleSymbolRenderer,
)

projected = processing.run("native:reprojectlayer", {
    "INPUT": photos,
    "TARGET_CRS": "EPSG:27700",
    "OUTPUT": f"{survey_day}/photos_bng.gpkg",
})["OUTPUT"]
photos_bng = QgsVectorLayer(projected, "survey photos (BNG)", "ogr")

symbol = QgsMarkerSymbol.createSimple({
    "name": "arrow", "size": "5", "color": "#b45309",
    "outline_color": "#fffdf7", "outline_width": "0.3",
})
symbol.symbolLayer(0).setDataDefinedProperty(
    QgsSymbolLayer.PropertyAngle,
    QgsProperty.fromExpression('coalesce("direction", 0)'),
)
photos_bng.setRenderer(QgsSingleSymbolRenderer(symbol))
QgsProject.instance().addMapLayer(photos_bng)

Breakdown: EXIF direction is a bearing clockwise from north, which is also how QGIS marker rotation is measured, so no conversion is needed. coalesce supplies zero where the camera wrote no direction — many phones only record it with the compass calibrated — and it is worth styling those differently if direction matters to the survey. Marker rotation is applied relative to the map's north by default; on a projected CRS far from its central meridian, grid north and true north differ by a degree or two, which is below the accuracy of a phone compass anyway.

Direction as rotation, photo as map tipFour arrow markers along a footpath each point the way their photo was taken: 212, 45, 300 and 130 degrees. One arrow has a map tip panel beside it showing a thumbnail placeholder, the filename and the capture time, driven by an HTML map tip template that reads the photo field.Readers see where the camera looked, then the photo212°45°300°130°IMG_4172.jpg14 Sep 2026, 10:31 · facing 212°

Show the photo in the map tip

Map tips accept HTML templates with expressions, so the stored path can become an image the moment somebody hovers a point.

from qgis.core import QgsMapLayer

template = (
    '<img src="file:///[% replace("photo", \'\\\\\', \'/\') %]" width="320"/>'
    '<p><b>[% "filename" %]</b><br/>'
    '[% format_date("timestamp", \'d MMM yyyy, HH:mm\') %] · '
    'facing [% coalesce(round("direction"), \'?\') %]°</p>'
)
photos_bng.setMapTipTemplate(template)
photos_bng.setDisplayExpression('"filename"')

Breakdown: Expressions inside [% %] are evaluated per feature. Swapping backslashes for forward slashes keeps the file:/// URL valid for Windows paths. The width attribute keeps a 12-megapixel photo from filling the screen. Map tips must also be switched on in the QGIS toolbar; for a record-by-record review, the same image can instead go into an attribute form with an attachment widget, which also lets reviewers open the full-size file.

Split a multi-day survey by time

When photos from several days or several crews land in one folder, the timestamp field is the cleanest way to separate them — more reliable than folder names, which crews invent on the spot. Camera clocks are a hazard, though: a phone set to the wrong time zone shifts every photo by hours, and a camera whose battery was changed may think it is 2000.

from qgis.core import QgsExpression, QgsFeatureRequest

days = photos.uniqueValues(photos.fields().indexOf("timestamp"))
by_day = sorted({v.date().toString("yyyy-MM-dd") for v in days if v})
print("capture days:", by_day)

suspicious = QgsFeatureRequest().setFilterExpression(
    "year(\"timestamp\") < 2020 OR \"timestamp\" > now()")
print("clock problems:", [f["filename"] for f in photos.getFeatures(suspicious)])

for day in by_day:
    expr = f"format_date(\"timestamp\", 'yyyy-MM-dd') = {QgsExpression.quotedValue(day)}"
    processing.run("native:extractbyexpression", {
        "INPUT": photos,
        "EXPRESSION": expr,
        "OUTPUT": f"{survey_day}/photos_{day}.gpkg",
    })

Breakdown: uniqueValues returns the distinct timestamps as QDateTime objects, so reducing them to dates gives the list of survey days without iterating every feature in Python. The suspicious-clock filter catches the two common failures — a reset clock and a future date from a wrong time zone — before they create a phantom survey day. QgsExpression.quotedValue produces a correctly quoted literal, which avoids hand-building quotes inside the expression. Writing one GeoPackage per day suits a reporting rhythm where each day's photos are reviewed and signed off separately; for a single project with a day filter, a map theme or a subset string on one layer is lighter.

QGIS version compatibility

native:importphotos has been available since QGIS 3.6 with the same parameters and output fields. Map tip templates are 3.0+. QgsSymbolLayer.PropertyAngle is spelled QgsSymbolLayer.Property.Angle from 3.30 onward and only in that scoped form on the QGIS 4 series. Support for HEIC images depends on the GDAL build; convert them to JPEG before import if the algorithm skips them.

Troubleshooting

  • No points at all. The folder holds HEIC or PNG images, or location tagging was off. Check one file with an EXIF viewer.
  • Every point has altitude 0. The camera did not record GPSAltitude, or the photos were exported from a gallery app that dropped it.
  • Arrows all point north. direction is null for every photo — the phone did not record it.
  • Map tip shows a broken image. The path moved, or it contains characters that need URL encoding; test with a simple path first.
  • Photos appear offset by a few metres. Phone GPS accuracy in urban canyons is often 5–15 m; that is the data, not the import.

Conclusion

Run native:importphotos with an INVALID output, then reconcile the folder against the layer yourself so no photo goes missing unnoticed, and filter out positions at 0, 0. Reproject for analysis, rotate markers by the direction field, and put the image in a map tip so the layer answers "what did it look like here?" without leaving QGIS.

Frequently Asked Questions

Can I import photos that have no GPS tags if I have a GPS track? Not with this algorithm. Match photo timestamps to track points by time — a nearest-time join — and write the positions back.

Does the import copy the images? No. It stores paths to the original files, so keep the folder where it is or store relative paths.

Can I import from a phone directly? Only from a folder QGIS can read; copy the photos to disk first.

How do I include the images in a printed report? Use a picture item in a layout, with its source set by an expression on the photo field, and drive it from an atlas over the photo layer.