Skip to main content

DJI Geotagger Downloads

A precise PPK + MRK-based geotagging tool for DJI RTK drones

This Python library enables centimetre-level camera geotagging by combining PPK .pos solutions, DJI .MRK gimbal offset corrections, and EXIF/XMP metadata from DJI RTK drone images. It is designed for photogrammetry and remote sensing workflows that require accurate EOPs.

Features

  • Convert raw GNSS logs (.bin, .dat) to RINEX using RTKLIB convbin
  • Download precise ephemeris (SP3/CLK) automatically from IGS
  • Run differential PPK (rover against base) with RTKLIB rnx2rtkp
  • Resolve the base station position from CSRS-PPP (submitted automatically or from a .sum) or known coordinates
  • Apply the DJI .MRK lever arm to the camera centre, propagating full 3×3 covariance
  • Transform to any CRS, with guards against PROJ's silent failure modes
  • Batch multiple flight folders with per-flight error isolation

Installation

pip install dji-geotagger

Or from source:

git clone https://github.com/geo-raypan/dji-geotagger.git
cd dji-geotagger
pip install -e .

Dependencies

Python ≥ 3.11, plus pillow, defusedxml, pandas, numpy, pyproj, tqdm, requests, georinex, astropy, pymap3d.

RTKLIB (convbin, rnx2rtkp) is downloaded automatically on first use.

Quick Start

import dji_geotagger as dgt

# 1. Convert GNSS raw data to RINEX
base_obs, base_nav = dgt.raw2rinex(
    input_path=r"DRTK3/DRTK3_20250730.dat",
    antenna_height_in_meter=2.0,
)

# 2. Resolve the base station position (submitted to CSRS-PPP, fetched back)
base_position = dgt.resolve_base_position(
    mode="online",
    base_obs=base_obs,
    email="you@example.com",
    ppp_kwargs={"process_type": "Static", "sysref": "ITRF"},
)

# 3. Process all flights
geotag_df = dgt.geotag(
    flight_folders=[
        r"P1/DJI_202507301227_011_LOCATION",
        r"P1/DJI_202507301227_012_LOCATION",
    ],
    base_obs=base_obs,
    base_nav=base_nav,
    base_position=base_position,
)

geotag_df.to_csv("geotagged_results.csv", index=False)

Base Station Position

All three modes return the same structure, so the rest of the script is unchanged whichever is used.

# Submit to CSRS-PPP and fetch the .sum back (no account needed)
dgt.resolve_base_position(mode="online", base_obs=base_obs,
                          email="you@example.com")

# An existing .sum - omit sum_file_path to auto-detect one next to the .obs
dgt.resolve_base_position(mode="sum", sum_file_path=r"DRTK3/PPP/base.sum")

# Known coordinates. hgt must be ELLIPSOIDAL; orthometric heights are refused.
dgt.resolve_base_position(mode="manual", manual_kwargs=dict(
    lat_dd=51.0, lon_dd=-114.0, hgt=1000.0,
    coord_sys="NAD83(CSRS)", epoch="2010.0",
    sigma_ENU=(0.010, 0.010, 0.020),   # metres, 1-sigma; None to disable
))

sigma_ENU must be given explicitly. Pass None to report rover-only precision — it is never assumed to be zero.

To deliver at a fixed epoch, ask CSRS-PPP for it here:

ppp_kwargs={"sysref": "NAD83", "nad83_epoch": "NAD83_20100101"}

This is the only step that can propagate an epoch, and it returns the propagation uncertainty too. "NAD83_CURR" does not propagate.

Coordinate Transformation

geotag() leaves coordinates in the frame CSRS-PPP solved in, tagged with the reference epoch. That pair is lossless, so keep it — any other CRS can be derived from it later.

utm_df = dgt.transform_coordinates(geotag_df, 22811)   # NAD83(CSRS)v8 / UTM 11N
print(utm_df.attrs["transform"])                        # provenance

Uncertainties are rotated into the target frame, accounting for meridian convergence and the point scale factor.

Use versioned EPSG codes. EPSG:2956 and EPSG:22812 are both named "NAD83(CSRS) / UTM zone 12N", but the first names no realization, so PROJ falls back to a ballpark shift and discards the datum shift entirely. The versioned NAD83(CSRS) UTM codes run 222xx (v2) to 228xx (v8).

Where EPSG has no versioned code, build the CRS:

itrf_utm    = dgt.make_utm_crs(11, 9988)             # no ITRF2020 UTM exists
alberta_3tm = dgt.rebase_projected_crs(3780, 10412)  # 3TM grid, v8 datum

PROJ degrades silently rather than raising, so three cases are refused by default:

Refused Override
Ballpark fallback (no rigorous transformation exists) allow_ballpark=True
Datum ensemble target, e.g. plain WGS 84 allow_datum_ensemble=True
Missing epoch supply source_epoch=

Note that cam_lat/cam_lon are already WGS 84 for practical purposes, so asking for it is rarely necessary.

Output Format

geotag() returns a compact table by default; full_output=True adds all intermediate columns.

Column Description
FileName Image filename
UTCAtExposure UTC datetime of exposure
coord_sys, epoch Reference frame and epoch
cam_lat, cam_lon, cam_h Camera centre, ellipsoidal height (metres)
cam_X, cam_Y, cam_Z Camera centre ECEF (metres)
sigma_E, sigma_N, sigma_U 1-sigma uncertainty, local ENU (metres)
DGT_YawDegree, DGT_PitchDegree, DGT_RollDegree Camera attitude (degrees)
rtk_status Fixed, Float, Single or Unknown

With full_output=True: seq, GPS_time, GPS_week, antenna position (X/Y/Z, lat_dd/lon_dd/hgt), cov_total_ECEF, sigma_total_ECEF, epoch_decimal_year, base_source, cov_repaired, the lever arm (gimbal_dN/dE/dD, gimbal_dX/dY/dZ), aircraft and gimbal attitude, EXIF/XMP fields, and flight.

After transform_coordinates() every coordinate column holds its value in the target frame, and a projected target adds two more:

Column Description
cam_E, cam_N Camera centre easting/northing on the target grid (metres)

coord_sys then names the target CRS in full, e.g. NAD83(CSRS)v8 / UTM zone 11N, so the file says which frame it is in.

Skipped flights are listed in geotag_df.attrs["failed_flights"].

Covariance / Uncertainty Model

The reported per-image uncertainty combines two independent error sources as full 3×3 covariance matrices in ECEF, then rotates the result into local ENU:

$$\Sigma_{\text{total}} = \Sigma_{\text{PPK}} + \Sigma_{\text{PPP}}, \qquad \Sigma_{\text{ENU}} = R,\Sigma_{\text{total}},R^{\top}$$

  • $\Sigma_{\text{PPK}}$ — per-epoch rover precision from the RTKLIB .pos solution
  • $\Sigma_{\text{PPP}}$ — base station precision from the CSRS-PPP .sum, including the epoch-propagation term when one applies
  • $R$ — the ECEF → ENU rotation at the epoch's latitude/longitude

Working at the matrix level preserves inter-axis correlations. Set base_error_propagation_on=False for rover-only precision.

⚠️ The reported sigma is slightly optimistic

Treat it as a lower bound. Not propagated: linear interpolation between GNSS epochs, camera/GNSS clock offset, lever-arm error, and the coordinate transformation's own error.

A small fraction of RTKLIB epochs report an indefinite covariance matrix, which cannot be used as a bundle-adjustment weight. pos2df() detects these and substitutes the nearest valid epoch's matrix (fix_bad_covariance=True, the default); positions are never altered and repaired epochs are flagged in cov_repaired.

Key Functions

Coregeotag(), compute_camera_position()

Raw data & PPKraw2rinex(), process_ppk(), pos2df(), mrk2df(), parse_img_dir()

Base stationresolve_base_position(), build_base_position(), run_online_ppp(), sum_file_parser()

Coordinate transformationtransform_coordinates(), make_utm_crs(), rebase_projected_crs(), resolve_source_crs()

Infrastructureensure_rtklib(), configure_logging(), Progress, OperationCancelled

Two helpers are not re-exported:

from dji_geotagger.ppk.ephemeris_downloader import download_igs_data
from dji_geotagger.config.import_config import override_rtklib_config

Configuration

RTKLIB defaults are in dji_geotagger/config/default_ppk_dict.py. Override with user_conf:

dgt.process_ppk(base_obs, base_nav, rover_obs,
                user_conf={'pos1-posmode': 'kinematic'})

geotag(), process_ppk() and raw2rinex() accept progress, which also carries cancellation:

progress = dgt.Progress(on_progress=lambda e: print(e.message),
                        should_cancel=lambda: stop_requested)
try:
    dgt.geotag(..., progress=progress)
except dgt.OperationCancelled:
    ...

Console logging is configured on import; dgt.configure_logging(console=False) silences it.

Troubleshooting

RTKLIB not found — run dgt.ensure_rtklib() up front.

No CSRS-PPP account — use mode="manual" with a surveyed position.

Base and rover do not overlap in time — PPK is differential, so epochs outside the base station's span cannot be solved. Checked before RTKLIB runs; disable with check_overlap=False.

One flight fails in a batch — skipped by default and listed in attrs["failed_flights"]. Use on_flight_error="raise" to stop instead.

Image-time mismatch — check the camera clock is within ±1 s of GPS, that EXIF/XMP timestamps are UTC, and that MRK files cover the same period.

Every image reports no metadatadefusedxml is missing; Pillow's getxmp() needs it and fails silently without it.

Performance Tips

  • Use IGS Rapid orbits (available ~17–18 hours after end-of-day UTC)
  • Process multiple flights in one geotag() call
  • Filter low-confidence solutions using covariance thresholds

References

License

This project is licensed under the BSD 2-Clause (see LICENSE for details).

Acknowledgments

  • Developed at the University of Calgary, Applied Geospatial Research Group (appliedgrg.ca)
  • Inspired by real-world field workflows involving DJI Matrice 350 RTK + Zenmuse P1, Hemisphere base stations, and CSRS-PPP post-processing
  • RTKLIB by Tomoji Takasu

Release files for dji-geotagger 3.0.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 dji-geotagger 3.0.0
File Size Uploaded
dji_geotagger-3.0.0.tar.gz 196.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for dji-geotagger 3.0.0
File Interpreter ABI Platform
dji_geotagger-3.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 401.5 kB

Release files / dji_geotagger-3.0.0.tar.gz

Download URL dji_geotagger-3.0.0.tar.gz
Size 196.2 kB
Tags Source
SHA-256 checksum
How to use checksums
7dc5d33e9a8afbd49dd71e23bf44a3958235bb0de37d34a3261b6148e5abb975
BLAKE2b-256 checksum
How to use checksums
c259d4458a3b0c6903cf748d676fbb5d88691c33005ee03646413095da479faa
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.9

Release files / dji_geotagger-3.0.0-py3-none-any.whl

Download URL dji_geotagger-3.0.0-py3-none-any.whl
Size 205.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
078503d7a8068b8a236839aa73dfa36294ee7344cc6329ec01f184ce7f4fe032
BLAKE2b-256 checksum
How to use checksums
c039b33c8e152cd7fed5e0631564b365c42aebc0432811850a6efab63ed68a1e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.9

Release history Release notifications | RSS feed

3.1.1

2 release files

3.1.0

2 release files

3.0.3

2 release files

This release

3.0.0 This release

2 release files

2.1.1

2 release files

2.1.0

2 release files

2.0.1

2 release files

1.0.11

2 release files

1.0.10

2 release files

1.0.9

2 release files

1.0.8

2 release files

1.0.5

2 release files

1.0.4

2 release files

1.0.3

2 release files

1.0.2

2 release files

1.0.0

2 release files

0.2.9

2 release files

0.2.7

2 release files

0.2.5

2 release files

0.2.3

2 release files

0.2.1

2 release files

0.1.9

2 release files

0.1.8

2 release files

0.1.6

2 release files

0.1.5

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

1 release file

0.1.1

2 release files

0.1.0

2 release files

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