From 8ce64fbb8a88000d6ada57ff3c5a85d924db40c9 Mon Sep 17 00:00:00 2001 From: Alessandro Dalvit Date: Wed, 6 Sep 2023 11:17:00 +0200 Subject: [PATCH] Add support for coordinates in Adobe format in XMP tags (#653) --- mapillary_tools/exif_read.py | 56 +++++++++++++++---- mapillary_tools/exiftool_read.py | 4 ++ tests/data/adobe_coords/adobe_coords.jpg | Bin 0 -> 10863 bytes tests/integration/test_process.py | 31 ++++++++++ tests/integration/test_process_and_upload.py | 2 +- tests/unit/test_exifread.py | 33 ++++++++++- 6 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 tests/data/adobe_coords/adobe_coords.jpg diff --git a/mapillary_tools/exif_read.py b/mapillary_tools/exif_read.py index 3bc6f3d1..69644cb3 100644 --- a/mapillary_tools/exif_read.py +++ b/mapillary_tools/exif_read.py @@ -1,8 +1,10 @@ import abc import datetime import logging +import re import typing as T import xml.etree.ElementTree as et +from fractions import Fraction from pathlib import Path import exifread @@ -21,6 +23,8 @@ # https://github.com/ianare/exif-py/issues/167 EXIFREAD_LOG = logging.getLogger("exifread") EXIFREAD_LOG.setLevel(logging.ERROR) +SIGN_BY_DIRECTION = {None: 1, "N": 1, "S": -1, "E": 1, "W": -1} +ADOBE_FORMAT_REGEX = re.compile(r"(\d+),(\d{1,3}\.?\d*)([NSWE])") def eval_frac(value: Ratio) -> float: @@ -47,6 +51,38 @@ def gps_to_decimal(values: T.Tuple[Ratio, Ratio, Ratio]) -> T.Optional[float]: return degrees + minutes / 60 + seconds / 3600 +def _parse_coord_numeric(coord: str, ref: T.Optional[str]) -> T.Optional[float]: + try: + return float(coord) * SIGN_BY_DIRECTION[ref] + except (ValueError, KeyError): + return None + + +def _parse_coord_adobe(coord: str) -> T.Optional[float]: + """ + Parse Adobe coordinate format: + """ + matches = ADOBE_FORMAT_REGEX.match(coord) + if matches: + deg = Ratio(int(matches.group(1)), 1) + min_frac = Fraction.from_float(float(matches.group(2))) + min = Ratio(min_frac.numerator, min_frac.denominator) + sec = Ratio(0, 1) + converted = gps_to_decimal((deg, min, sec)) + if converted is not None: + return converted * SIGN_BY_DIRECTION[matches.group(3)] + return None + + +def _parse_coord(coord: T.Optional[str], ref: T.Optional[str]) -> T.Optional[float]: + if coord is None: + return None + parsed = _parse_coord_numeric(coord, ref) + if parsed is None: + parsed = _parse_coord_adobe(coord) + return parsed + + def _parse_iso(dtstr: str) -> T.Optional[datetime.datetime]: try: return datetime.datetime.fromisoformat(dtstr) @@ -378,22 +414,22 @@ def extract_direction(self) -> T.Optional[float]: ) def extract_lon_lat(self) -> T.Optional[T.Tuple[float, float]]: - lat = self._extract_alternative_fields(["exif:GPSLatitude"], float) + lat_ref = self._extract_alternative_fields(["exif:GPSLatitudeRef"], str) + lat_str: T.Optional[str] = self._extract_alternative_fields( + ["exif:GPSLatitude"], str + ) + lat: T.Optional[float] = _parse_coord(lat_str, lat_ref) if lat is None: return None - lon = self._extract_alternative_fields(["exif:GPSLongitude"], float) + lon_ref = self._extract_alternative_fields(["exif:GPSLongitudeRef"], str) + lon_str: T.Optional[str] = self._extract_alternative_fields( + ["exif:GPSLongitude"], str + ) + lon = _parse_coord(lon_str, lon_ref) if lon is None: return None - ref = self._extract_alternative_fields(["exif:GPSLongitudeRef"], str) - if ref and ref.upper() == "W": - lon = -1 * lon - - ref = self._extract_alternative_fields(["exif:GPSLatitudeRef"], str) - if ref and ref.upper() == "S": - lat = -1 * lat - return lon, lat def extract_make(self) -> T.Optional[str]: diff --git a/mapillary_tools/exiftool_read.py b/mapillary_tools/exiftool_read.py index 2ae67286..5906d8ae 100644 --- a/mapillary_tools/exiftool_read.py +++ b/mapillary_tools/exiftool_read.py @@ -310,6 +310,10 @@ def extract_lon_lat(self) -> T.Optional[T.Tuple[float, float]]: if lon_lat is not None: return lon_lat + lon_lat = self._extract_lon_lat("XMP-exif:GPSLongitude", "XMP-exif:GPSLatitude") + if lon_lat is not None: + return lon_lat + return None def _extract_lon_lat( diff --git a/tests/data/adobe_coords/adobe_coords.jpg b/tests/data/adobe_coords/adobe_coords.jpg new file mode 100644 index 0000000000000000000000000000000000000000..67ab54f7815173aa0f19ab15e0215dc5fcb1bcd6 GIT binary patch literal 10863 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 TJ-4yz#O>_z84hR`3~&AynK?^8 literal 0 HcmV?d00001 diff --git a/tests/integration/test_process.py b/tests/integration/test_process.py index c6e0c73a..ff0be6fc 100644 --- a/tests/integration/test_process.py +++ b/tests/integration/test_process.py @@ -59,6 +59,16 @@ "MAPDeviceModel": "VIRB 360", "MAPOrientation": 1, }, + "adobe_coords.jpg": { + "filetype": "image", + "MAPLatitude": -0.0702668, + "MAPLongitude": 34.3819352, + "MAPCaptureTime": "2019_07_16_10_26_11_000", + "MAPCompassHeading": {"TrueHeading": 0, "MagneticHeading": 0}, + "MAPDeviceMake": "SAMSUNG", + "MAPDeviceModel": "SM-C200", + "MAPOrientation": 1, + }, } @@ -260,6 +270,27 @@ def test_angle_with_offset_with_exiftool(setup_data: py.path.local): return test_angle_with_offset(setup_data, use_exiftool=True) +def test_parse_adobe_coordinates(setup_data: py.path.local): + args = f"{EXECUTABLE} process --file_types=image {PROCESS_FLAGS} {setup_data}/adobe_coords" + x = subprocess.run(args, shell=True) + verify_descs( + [ + { + "filename": str(Path(setup_data, "adobe_coords", "adobe_coords.jpg")), + "filetype": "image", + "MAPLatitude": -0.0702668, + "MAPLongitude": 34.3819352, + "MAPCaptureTime": _local_to_utc("2019-07-16T10:26:11"), + "MAPCompassHeading": {"TrueHeading": 0.0, "MagneticHeading": 0.0}, + "MAPDeviceMake": "SAMSUNG", + "MAPDeviceModel": "SM-C200", + "MAPOrientation": 1, + } + ], + Path(setup_data, "adobe_coords/mapillary_image_description.json"), + ) + + def test_zip(tmpdir: py.path.local, setup_data: py.path.local): zip_dir = tmpdir.mkdir("zip_dir") x = subprocess.run( diff --git a/tests/integration/test_process_and_upload.py b/tests/integration/test_process_and_upload.py index 5487f7d2..d97b7718 100644 --- a/tests/integration/test_process_and_upload.py +++ b/tests/integration/test_process_and_upload.py @@ -181,7 +181,7 @@ def test_process_and_upload_images_only( setup_upload: py.path.local, ): x = subprocess.run( - f"{EXECUTABLE} --verbose process_and_upload --filetypes=image {UPLOAD_FLAGS} {PROCESS_FLAGS} {setup_data} {setup_data} {setup_data}/images/DSC00001.JPG --desc_path=-", + f"{EXECUTABLE} --verbose process_and_upload --filetypes=image {UPLOAD_FLAGS} {PROCESS_FLAGS} {setup_data}/images {setup_data}/images {setup_data}/images/DSC00001.JPG --desc_path=-", shell=True, ) assert x.returncode == 0, x.stderr diff --git a/tests/unit/test_exifread.py b/tests/unit/test_exifread.py index 3855bf4f..62e6a19e 100644 --- a/tests/unit/test_exifread.py +++ b/tests/unit/test_exifread.py @@ -1,5 +1,7 @@ import datetime import os + +import typing as T import unittest from pathlib import Path @@ -8,7 +10,11 @@ import pytest from mapillary_tools import geo -from mapillary_tools.exif_read import ExifRead, parse_datetimestr_with_subsec_and_offset +from mapillary_tools.exif_read import ( + _parse_coord, + ExifRead, + parse_datetimestr_with_subsec_and_offset, +) from mapillary_tools.exif_write import ExifEdit from PIL import ExifTags, Image @@ -250,6 +256,31 @@ def test_parse(): assert str(dt) == "2021-10-10 17:29:54.124000-02:00", dt +@pytest.mark.parametrize( + "raw_coord,raw_ref,expected", + [ + (None, "", None), + ("foo", "N", None), + ("0.0", "foo", None), + ("0.0", "N", 0), + ("1.5", "N", 1.5), + ("1.5", "S", -1.5), + ("-1.5", "N", -1.5), + ("-1.5", "S", 1.5), + ("-1.5", "S", 1.5), + ("33,18.32N", "N", 33.30533), + ("33,18.32N", "S", 33.30533), + ("33,18.32S", "", -33.30533), + ("44,24.54E", "", 44.40900), + ("44,24.54W", "", -44.40900), + ], +) +def test_parse_coordinates( + raw_coord: T.Optional[str], raw_ref: str, expected: T.Optional[float] +): + assert _parse_coord(raw_coord, raw_ref) == pytest.approx(expected) + + # test ExifWrite write a timestamp and ExifRead read it back def test_read_and_write(setup_data: py.path.local): image_path = Path(setup_data, "test_exif.jpg")