Метки на картах это просто. Rust CLI приложение

от
Прочие языки    rust, cli, openlayers, exif, walkdir, cargo, геометки, maps

В этой части создадим CLI-приложение на языке Rust, которое соберёт все необходимые данные для отображения фотографий на карте: координаты, чуть-чуть информации о камере, уменьшенное превью, а также сгенерирует саму карту.

лого

Указав приложению путь к папке, оно рекурсивно пройдёт по фотографиям, соберёт GPS координаты там, где они есть, детали о фото, сделает превью и сгенерирует json файл, который потом внедрит в html шаблон с картой.

Как вы, наверное, догадались, всю информацию, включая уменьшенные превьюшки, будем брать из данных Exif, зашитых внутри фотографии. Вот вывод из программы exiftool, небольшая часть того, что содержит Exif:
  1. exiftool -s 20210528_145646_HDR.jpg
  1. File Type            JPEG
  2. Exif Byte Order      Little-endian (Intel, II)
  3. Make                 Sony
  4. Camera Model Name    Xperia Z1
  5. Exposure Time        1/500
  6. F Number             2.0
  7. ISO                  50
  8. Exif Version         0220
  9. Date/Time Original   2021:05:28 14:56:43
  10. Create Date          2021:05:28 14:56:43
  11. Shutter Speed Value  1/498
  12. Aperture Value       2.0
  13. Focal Length         4.9 mm
  14. Color Space          sRGB
  15. Exif Image Width     3554
  16. Exif Image Height    2000
  17. GPS Version ID       2.0.0.0
  18. GPS Map Datum        WGS-84
  19. Compression          JPEG (old-style)
  20. GPS Latitude         47 deg 45' 12.60"
  21. GPS Longitude        37 deg 16' 8.17"
  22. GPS Latitude Ref     North
  23. GPS Longitude Ref    East
  24. Thumbnail Offset     1006
  25. Thumbnail Length     6483

Самое главное здесь — GPS координаты. Также можно взять производителя и модель камеры, дату съёмки. Exif также может содержать превью, обычно с шириной 160 пикселей, этого нам будет достаточно.

Создание проекта
Создаём новый проект с флагом --bin, указывающим, что это будет приложение, а не библиотека:
  1. cargo new exifgeo --bin

Зависимости:
  1. [dependencies]
  2. kamadak-exif = "0.6.1"
  3. walkdir = "2.5"
  4. serde = { version = "1.0", features = ["derive"] }
  5. serde_json = "1.0"
serde для сериализации в json, walkdir для рекурсивного обхода директории, kamadak-exif для работы с Exif.

exifgeo
Прочитаем из аргументов командной строки путь к директории:
  1. fn main() -> ExitCode {
  2.     let dir = env::args_os()
  3.         .nth(1)
  4.         .map(PathBuf::from)
  5.         .filter(|path| path.is_dir());
  6.     if dir.is_none() {
  7.         eprintln!("Usage: exifgeo <directory>");
  8.         return ExitCode::FAILURE;
  9.     }
  10.  
  11.     let input_dir = dir.unwrap();
  12.     println!(
  13.         "Scanning directory: {}",
  14.         input_dir.as_os_str().to_string_lossy()
  15.     );
  16. }
В нулевом аргументе хранится путь к запускаемой программе, поэтому пропускаем этот аргумент, указав .nth(1).

Exif поддерживается не только файлами JPG, но ещё и массой других форматов, поэтому было бы неплохо сканировать их:
  1. #[cfg_attr(any(), rustfmt::skip)]
  2. const SUPPORTED_EXTENSIONS: [&str; 19] = [
  3.     // Common
  4.     "jpg", "jpeg", "png", "webp", "heic", "avif",
  5.     // Raw
  6.     "tif", "tiff", "dng", "raw",
  7.     // Camera specific
  8.     "arw", "orf", "sr2", "crw", "cr2", "cr3", "nef", "srw", "rw2"
  9. ];

Проходим рекурсивно по директории при помощи WalkDir, беря во внимание только файлы (e.file_type().is_file()) с поддерживаемыми расширениями (SUPPORTED_EXTENSIONS):
  1. let photos = WalkDir::new(input_dir)
  2.     .into_iter()
  3.     .filter_map(|e| e.ok())
  4.     .filter(|e| e.file_type().is_file())
  5.     .filter(|entry| {
  6.         match entry
  7.             .path()
  8.             .extension()
  9.             .and_then(|ext| ext.to_str().map(|s| s.to_lowercase()))
  10.         {
  11.             Some(ref e) if SUPPORTED_EXTENSIONS.contains(&e.as_str()) => true,
  12.             _ => false,
  13.         }
  14.     })
  15.     .enumerate()
  16.     .filter_map(|(idx, e)| {
  17.         let exif = /* read Exif from file */
  18.         /* retrieve fields: geo position, photo details and thumbnail) */
  19.         Some(PhotoInfo { /* struct data */ })
  20.     })
  21.     .filter(|i| i.lat.abs() > 0.001 && i.lon.abs() > 0.001)
  22.     .collect::<Vec<PhotoInfo>>();
Дальше читаем Exif и собираем нужные данные. Если они есть, то заполняем структуру с информацией о фото PhotoInfo, после чего отбираем только те, где долгота и широта ненулевые.

Структура с информацией о фото:
  1. #[derive(Serialize)]
  2. struct PhotoInfo {
  3.     name: String,
  4.     path: String,
  5.     thumb: String,
  6.     lat: f32,
  7.     lon: f32,
  8.     make: String,
  9.     model: String,
  10.     date: String,
  11. }
Обязательно указываем #[derive(Serialize)], чтобы serde смог сериализовать её.

Наконец, чтение Exif и заполнение структуры PhotoInfo:
  1. let exif = match File::open(e.path()) {
  2.     Ok(file) => exif::Reader::new()
  3.         .read_from_container(&mut BufReader::new(&file))
  4.         .ok()?,
  5.     _ => return None,
  6. };
  7.  
  8. let name = e.file_name().to_string_lossy().to_string();
  9. let path = e.path();
  10. let latitude = get_coord(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef, "N")?;
  11. let longitude = get_coord(&exif, Tag::GPSLongitude, Tag::GPSLongitudeRef, "E")?;
  12. let make = get_string(&exif, Tag::Make).unwrap_or(UNKNOWN.to_string());
  13. let model = get_string(&exif, Tag::Model).unwrap_or(UNKNOWN.to_string());
  14. let date = get_datetime(&exif, Tag::DateTimeOriginal).unwrap_or(UNKNOWN.to_string());
  15. let thumb = get_thumbnail_data(&exif)
  16.     .and_then(|data| save_thumbnail(format!("{}/t{}.jpg", thumbs_dir, idx), data))?;
  17.  
  18. Some(PhotoInfo {
  19.     name,
  20.     path: path.display().to_string().replace("\\", "/"),
  21.     thumb,
  22.     lat: latitude as f32,
  23.     lon: longitude as f32,
  24.     make,
  25.     model,
  26.     date,
  27. })
В начале статьи я приводил пример, как выглядят данные Exif. Здесь же мы просто читаем те или иные теги, предварительно проверив в спецификации, какой тип данных там может лежать. Превью, если оно есть, вычитываем из Exif и сохраняем в отдельной папке thumbs под порядковым номером.
На внутренностях Exif останавливаться не буду, напишу об этом в следующей статье.

Итак, на данном этапе у нас есть вектор с данными о фото. Сериализуем его в json строку:
  1. let map_json = serde_json::to_string(&photos).unwrap_or("[]".to_string());

Подготовим html шаблон из предыдущей статьи и при помощи макроса include_bytes! добавим его в программу:
  1. static MAP_HTML: &[u8] = include_bytes!("map.html");

В html шаблоне в месте, где должен грузиться json с геоданными, вставим специальный маркер
  1. handleMapDataLoaded({map:1});

Осталось внедрить полученный json в html страницу и записать в файл:
  1. let html = String::from_utf8_lossy(MAP_HTML).replace("{map:1}", &map_json);
  2. let mut file = File::create("map.html").expect("Could not create map.html file");
  3. file.write_all(html.as_bytes()).expect("Could not write to map.html file");
Внедряем простой заменой строки {map:1} на полученный json.


Сборка программы
Запустить программу можно при помощи cargo:
  1. cargo run
  2. cargo run -- path/to/dir
Или собрав бинарник:
  1. cargo build
Но в таком случае будет собрана отладочная версия. Для сборки релизной минифицированной версии следует добавить в Cargo.toml:
  1. [profile.release]
  2. strip = true
  3. opt-level = "z"
  4. lto = true
и выполнить:
  1. cargo build --release

Запускаем программу:
20250110T220143.png
Генерируется html файл с картой:
20250110T220308.jpg

Также я собрал программу под Android, используя Termux:
20250112T135343.jpg
Там всё тоже работает.


📄 Исходный код
🔽 Программа
  • +5
  • views 70