Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for coordinates in Adobe format in XMP tags #653

Merged
merged 12 commits into from
Sep 6, 2023
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": {
malconsei marked this conversation as resolved.
Show resolved Hide resolved
"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