Geotagging is easy. Rust CLI app

3 min read January 26, 2025 #openlayers #rust #maps #cli

In this section, we will create a CLI application in Rust that collects all the necessary data to display photos on the map: coordinates, some camera information, a thumbnail, and will also generate the map.

logo

By specifying a directory path, the application will recursively go through the photos, collect GPS coordinates where possible, collect photo details, create previews, and generate a JSON file that will then be embedded into an HTML map template.

As you might have guessed, all the information, including the thumbnails, will be taken from the Exif data. Here is the output from the exiftool program, a small part of what Exif contains:

exiftool -s 20210528_145646_HDR.jpg
File Type            JPEG
Exif Byte Order      Little-endian (Intel, II)
Make                 Sony
Camera Model Name    Xperia Z1
Exposure Time        1/500
F Number             2.0
ISO                  50
Exif Version         0220
Date/Time Original   2021:05:28 14:56:43
Create Date          2021:05:28 14:56:43
Shutter Speed Value  1/498
Aperture Value       2.0
Focal Length         4.9 mm
Color Space          sRGB
Exif Image Width     3554
Exif Image Height    2000
GPS Version ID       2.0.0.0
GPS Map Datum        WGS-84
Compression          JPEG (old-style)
GPS Latitude         47 deg 45' 12.60"
GPS Longitude        37 deg 16' 8.17"
GPS Latitude Ref     North
GPS Longitude Ref    East
Thumbnail Offset     1006
Thumbnail Length     6483

The most important thing here is the GPS coordinates. Also we can get the camera manufacturer and model, and the date the photo was taken. Exif can also contain a preview, usually with a width of 160 pixels, which will be sufficient for us.

Creating the project

Create a new project with the --bin flag, indicating that this will be an application, not a library:

cargo new exifgeo --bin

Dependencies:

[dependencies]
kamadak-exif = "0.6.1"
walkdir = "2.5"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

serde for JSON serialization, walkdir for recursive directory traversal, kamadak-exif for working with Exif.

exifgeo

Read the directory path from the command line arguments:

fn main() -> ExitCode {
    let dir = env::args_os()
        .nth(1)
        .map(PathBuf::from)
        .filter(|path| path.is_dir());
    if dir.is_none() {
        eprintln!("Usage: exifgeo <directory>");
        return ExitCode::FAILURE;
    }

    let input_dir = dir.unwrap();
    println!(
        "Scanning directory: {}",
        input_dir.as_os_str().to_string_lossy()
    );
}

The zeroth argument contains the path to the executable program, so skip this argument by specifying .nth(1).

Exif is supported not only by JPG files but also by many other formats, so it would be better to scan them too:

#[cfg_attr(any(), rustfmt::skip)]
const SUPPORTED_EXTENSIONS: [&str; 19] = [
    // Common
    "jpg", "jpeg", "png", "webp", "heic", "avif",
    // Raw
    "tif", "tiff", "dng", "raw",
    // Camera specific
    "arw", "orf", "sr2", "crw", "cr2", "cr3", "nef", "srw", "rw2"
];

Recursively traverse the directory using WalkDir, considering only files (e.file_type().is_file()) with supported extensions (SUPPORTED_EXTENSIONS):

let photos = WalkDir::new(input_dir)
    .into_iter()
    .filter_map(|e| e.ok())
    .filter(|e| e.file_type().is_file())
    .filter(|entry| {
        match entry
            .path()
            .extension()
            .and_then(|ext| ext.to_str().map(|s| s.to_lowercase()))
        {
            Some(ref e) if SUPPORTED_EXTENSIONS.contains(&e.as_str()) => true,
            _ => false,
        }
    })
    .enumerate()
    .filter_map(|(idx, e)| {
        let exif = /* read Exif from file */
        /* retrieve fields: geo position, photo details and thumbnail) */
        Some(PhotoInfo { /* struct data */ })
    })
    .filter(|i| i.lat.abs() > 0.001 && i.lon.abs() > 0.001)
    .collect::<Vec<PhotoInfo>>();

Next, read the Exif data and collect the necessary information. If the data is present, fill the PhotoInfo structure with photo information, and then select only those with non-zero longitude and latitude.

#[derive(Serialize)]
struct PhotoInfo {
    name: String,
    path: String,
    thumb: String,
    lat: f32,
    lon: f32,
    make: String,
    model: String,
    date: String,
}

Be sure to specify #[derive(Serialize)] so that serde can serialize it.

Finally, read Exif and fill the PhotoInfo structure:

let exif = match File::open(e.path()) {
    Ok(file) => exif::Reader::new()
        .read_from_container(&mut BufReader::new(&file))
        .ok()?,
    _ => return None,
};

let name = e.file_name().to_string_lossy().to_string();
let path = e.path();
let latitude = get_coord(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef, "N")?;
let longitude = get_coord(&exif, Tag::GPSLongitude, Tag::GPSLongitudeRef, "E")?;
let make = get_string(&exif, Tag::Make).unwrap_or(UNKNOWN.to_string());
let model = get_string(&exif, Tag::Model).unwrap_or(UNKNOWN.to_string());
let date = get_datetime(&exif, Tag::DateTimeOriginal).unwrap_or(UNKNOWN.to_string());
let thumb = get_thumbnail_data(&exif)
    .and_then(|data| save_thumbnail(format!("{}/t{}.jpg", thumbs_dir, idx), data))?;

Some(PhotoInfo {
    name,
    path: path.display().to_string().replace("\\", "/"),
    thumb,
    lat: latitude as f32,
    lon: longitude as f32,
    make,
    model,
    date,
})

At the beginning of the article, I provided an example of what Exif data looks like. Here we simply read certain tags, not forgetting to check the tag data types in the specification. If the Exif thumbnail exists, we read and save it under a sequential number in a separate thumbs folder. I won't go into the Exif details, I'll write about it in the next article.

So, at this stage, we have a vector with photo data. We serialize it into a JSON string:

let map_json = serde_json::to_string(&photos).unwrap_or("[]".to_string());

Prepare the HTML template from the previous article and include it into the program binary using the include_bytes! macro:

static MAP_HTML: &[u8] = include_bytes!("map.html");

In the HTML template, where the JSON with geodata should be loaded, insert a special marker:

handleMapDataLoaded({map:1});

All that's left is to embed the obtained JSON into the HTML page and write it to a file:

let html = String::from_utf8_lossy(MAP_HTML).replace("{map:1}", &map_json);
let mut file = File::create("map.html").expect("Could not create map.html file");
file.write_all(html.as_bytes()).expect("Could not write to map.html file");

We embed it by simply replacing the string {map:1} with the obtained JSON.

Building the program

You can run the program using cargo:

cargo run
cargo run -- path/to/dir

Or by building the binary:

cargo build

However, this will build a debug version. To build a minimized release version, you should add to Cargo.toml:

[profile.release]
strip = true
opt-level = "z"
lto = true

and execute:

cargo build --release

Run the program:

20250110T220143.png

An HTML file with a map is generated:

20250110T220308.jpg

I also built the program for Android using Termux:

20250112T135343.jpg

Everything works there as well.


📄 Source code
🔽 Download app