Skip to content

Commit

Permalink
Add support for coordinates in Adobe format in XMP tags (#653)
Browse files Browse the repository at this point in the history
  • Loading branch information
malconsei authored Sep 6, 2023
1 parent 7c895e4 commit 8ce64fb
Show file tree
Hide file tree
Showing 6 changed files with 114 additions and 12 deletions.
56 changes: 46 additions & 10 deletions mapillary_tools/exif_read.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand All @@ -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: <degrees,fractionalminutes[NSEW]>
"""
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)
Expand Down Expand Up @@ -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]:
Expand Down
4 changes: 4 additions & 0 deletions mapillary_tools/exiftool_read.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Binary file added tests/data/adobe_coords/adobe_coords.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 31 additions & 0 deletions tests/integration/test_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}


Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/test_process_and_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 32 additions & 1 deletion tests/unit/test_exifread.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import datetime
import os

import typing as T
import unittest
from pathlib import Path

Expand All @@ -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

Expand Down Expand Up @@ -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")
Expand Down

0 comments on commit 8ce64fb

Please sign in to comment.