Метки на картах это просто. Rust CLI приложение
от aNNiMON
Прочие языки
rust, cli, openlayers, exif, walkdir, cargo, геометки, maps
В этой части создадим CLI-приложение на языке Rust, которое соберёт все необходимые данные для отображения фотографий на карте: координаты, чуть-чуть информации о камере, уменьшенное превью, а также сгенерирует саму карту.
Указав приложению путь к папке, оно рекурсивно пройдёт по фотографиям, соберёт GPS координаты там, где они есть, детали о фото, сделает превью и сгенерирует json файл, который потом внедрит в html шаблон с картой.
Как вы, наверное, догадались, всю информацию, включая уменьшенные превьюшки, будем брать из данных Exif, зашитых внутри фотографии. Вот вывод из программы exiftool, небольшая часть того, что содержит Exif:
Самое главное здесь — GPS координаты. Также можно взять производителя и модель камеры, дату съёмки. Exif также может содержать превью, обычно с шириной 160 пикселей, этого нам будет достаточно.
Создание проекта
Создаём новый проект с флагом --bin, указывающим, что это будет приложение, а не библиотека:
Зависимости:
serde для сериализации в json, walkdir для рекурсивного обхода директории, kamadak-exif для работы с Exif.
exifgeo
Прочитаем из аргументов командной строки путь к директории:
В нулевом аргументе хранится путь к запускаемой программе, поэтому пропускаем этот аргумент, указав .nth(1).
Exif поддерживается не только файлами JPG, но ещё и массой других форматов, поэтому было бы неплохо сканировать их:
Проходим рекурсивно по директории при помощи WalkDir, беря во внимание только файлы (e.file_type().is_file()) с поддерживаемыми расширениями (SUPPORTED_EXTENSIONS):
Дальше читаем Exif и собираем нужные данные. Если они есть, то заполняем структуру с информацией о фото PhotoInfo, после чего отбираем только те, где долгота и широта ненулевые.
Структура с информацией о фото:
Обязательно указываем #[derive(Serialize)], чтобы serde смог сериализовать её.
Наконец, чтение Exif и заполнение структуры PhotoInfo:
В начале статьи я приводил пример, как выглядят данные Exif. Здесь же мы просто читаем те или иные теги, предварительно проверив в спецификации, какой тип данных там может лежать. Превью, если оно есть, вычитываем из Exif и сохраняем в отдельной папке thumbs под порядковым номером.
На внутренностях Exif останавливаться не буду, напишу об этом в следующей статье.
Итак, на данном этапе у нас есть вектор с данными о фото. Сериализуем его в json строку:
Подготовим html шаблон из предыдущей статьи и при помощи макроса include_bytes! добавим его в программу:
В html шаблоне в месте, где должен грузиться json с геоданными, вставим специальный маркер
Осталось внедрить полученный json в html страницу и записать в файл:
Внедряем простой заменой строки {map:1} на полученный json.
Сборка программы
Запустить программу можно при помощи cargo:
Или собрав бинарник:
Но в таком случае будет собрана отладочная версия. Для сборки релизной минифицированной версии следует добавить в Cargo.toml:
и выполнить:
Запускаем программу:
Генерируется html файл с картой:
Также я собрал программу под Android, используя Termux:
Там всё тоже работает.
📄 Исходный код
🔽 Программа
Указав приложению путь к папке, оно рекурсивно пройдёт по фотографиям, соберёт GPS координаты там, где они есть, детали о фото, сделает превью и сгенерирует json файл, который потом внедрит в html шаблон с картой.
Как вы, наверное, догадались, всю информацию, включая уменьшенные превьюшки, будем брать из данных Exif, зашитых внутри фотографии. Вот вывод из программы exiftool, небольшая часть того, что содержит Exif:
- 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
Самое главное здесь — GPS координаты. Также можно взять производителя и модель камеры, дату съёмки. Exif также может содержать превью, обычно с шириной 160 пикселей, этого нам будет достаточно.
Создание проекта
Создаём новый проект с флагом --bin, указывающим, что это будет приложение, а не библиотека:
- cargo new exifgeo --bin
Зависимости:
- [dependencies]
- kamadak-exif = "0.6.1"
- walkdir = "2.5"
- serde = { version = "1.0", features = ["derive"] }
- serde_json = "1.0"
exifgeo
Прочитаем из аргументов командной строки путь к директории:
- 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()
- );
- }
Exif поддерживается не только файлами JPG, но ещё и массой других форматов, поэтому было бы неплохо сканировать их:
- #[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"
- ];
Проходим рекурсивно по директории при помощи WalkDir, беря во внимание только файлы (e.file_type().is_file()) с поддерживаемыми расширениями (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>>();
Структура с информацией о фото:
- #[derive(Serialize)]
- struct PhotoInfo {
- name: String,
- path: String,
- thumb: String,
- lat: f32,
- lon: f32,
- make: String,
- model: String,
- date: String,
- }
Наконец, чтение Exif и заполнение структуры PhotoInfo:
- 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,
- })
На внутренностях Exif останавливаться не буду, напишу об этом в следующей статье.
Итак, на данном этапе у нас есть вектор с данными о фото. Сериализуем его в json строку:
- let map_json = serde_json::to_string(&photos).unwrap_or("[]".to_string());
Подготовим html шаблон из предыдущей статьи и при помощи макроса include_bytes! добавим его в программу:
- static MAP_HTML: &[u8] = include_bytes!("map.html");
В html шаблоне в месте, где должен грузиться json с геоданными, вставим специальный маркер
- handleMapDataLoaded({map:1});
Осталось внедрить полученный json в html страницу и записать в файл:
- 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");
Сборка программы
Запустить программу можно при помощи cargo:
- cargo run
- cargo run -- path/to/dir
- cargo build
- [profile.release]
- strip = true
- opt-level = "z"
- lto = true
- cargo build --release
Запускаем программу:
Генерируется html файл с картой:
Также я собрал программу под Android, используя Termux:
Там всё тоже работает.
📄 Исходный код
🔽 Программа