Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions mapillary_tools/exif_read.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,27 @@ def make_valid_timezone_offset(delta: datetime.timedelta) -> datetime.timedelta:
return delta


# No image was captured before 1970. Cameras without a clock write placeholder
# dates before it: 0001:01:01 00:00:00, a date in 1904 (the Mac epoch), or
# 1969:12:31 19:00:00 (Unix time 0 in a time zone west of UTC)
_MIN_CAPTURE_YEAR = 1970


def is_valid_capture_time(dt: datetime.datetime) -> bool:
"""
Whether **dt** can be the time an image was captured, rather than a
placeholder date

>>> is_valid_capture_time(datetime.datetime(2021, 7, 15, 15, 37, 30))
True
>>> is_valid_capture_time(datetime.datetime(1, 1, 1))
False
>>> is_valid_capture_time(datetime.datetime(1969, 12, 31, 19, 0))
False
"""
return _MIN_CAPTURE_YEAR <= dt.year


_FIELD_TYPE = T.TypeVar("_FIELD_TYPE", int, float, str)


Expand Down Expand Up @@ -391,7 +412,8 @@ def _extract_exif_datetime(
subsec = subsec.replace(" ", "0")
offset = self._extract_alternative_fields([offset_tag], str)
dt = parse_datetimestr_with_subsec_and_offset(dtstr, subsec, offset)
if dt is None:
if dt is None or not is_valid_capture_time(dt):
# A placeholder date: let the next date tag be tried
return None
return dt

Expand Down Expand Up @@ -436,7 +458,11 @@ def extract_gps_datetime(self) -> datetime.datetime | None:

def extract_capture_time(self) -> datetime.datetime | None:
dt = self.extract_gps_datetime()
if dt is not None and dt.date() != datetime.date(1970, 1, 1):
if (
dt is not None
and dt.date() != datetime.date(1970, 1, 1)
and is_valid_capture_time(dt)
):
return dt

dt = self.extract_exif_datetime()
Expand Down Expand Up @@ -738,7 +764,8 @@ def _extract_exif_datetime(
subsec = subsec.replace(" ", "0")
offset = self._extract_alternative_fields([offset_tag], field_type=str)
dt = parse_datetimestr_with_subsec_and_offset(dtstr, subsec, offset)
if dt is None:
if dt is None or not is_valid_capture_time(dt):
# A placeholder date: let the next date tag be tried
return None
return dt

Expand Down Expand Up @@ -786,7 +813,11 @@ def extract_capture_time(self) -> datetime.datetime | None:
gps_dt = self.extract_gps_datetime()
except (ValueError, TypeError, ZeroDivisionError):
gps_dt = None
if gps_dt is not None and gps_dt.date() != datetime.date(1970, 1, 1):
if (
gps_dt is not None
and gps_dt.date() != datetime.date(1970, 1, 1)
and is_valid_capture_time(gps_dt)
):
return gps_dt

dt = self.extract_exif_datetime()
Expand Down
15 changes: 12 additions & 3 deletions mapillary_tools/exiftool_read.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@ def _extract_exif_datetime(
subsec = subsec.replace(" ", "0")
offset = self._extract_alternative_fields(offset_tags, str)
dt = exif_read.parse_datetimestr_with_subsec_and_offset(dtstr, subsec, offset)
if dt is None:
if dt is None or not exif_read.is_valid_capture_time(dt):
# A placeholder date: let the next date tag be tried
return None
return dt

Expand Down Expand Up @@ -277,14 +278,22 @@ def extract_capture_time(self) -> datetime.datetime | None:
dt = self.extract_gps_datetime()
except (ValueError, TypeError, ZeroDivisionError):
dt = None
if dt is not None and dt.date() != datetime.date(1970, 1, 1):
if (
dt is not None
and dt.date() != datetime.date(1970, 1, 1)
and exif_read.is_valid_capture_time(dt)
):
return dt

try:
dt = self.extract_gps_datetime_from_xmp()
except (ValueError, TypeError, ZeroDivisionError):
dt = None
if dt is not None and dt.date() != datetime.date(1970, 1, 1):
if (
dt is not None
and dt.date() != datetime.date(1970, 1, 1)
and exif_read.is_valid_capture_time(dt)
):
return dt

dt = self.extract_exif_datetime()
Expand Down
7 changes: 5 additions & 2 deletions mapillary_tools/geotag/image_extractors/exif.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@ def _capture_unix_time(capture_time: datetime.datetime) -> float:
"""
The Unix time of the capture time of an image, assuming that a naive one
is in the local timezone. Some cameras write 0001:01:01 00:00:00 for no
date, which has no Unix time: raise as for an image with no capture time,
rather than let geo.as_unix_time() move it to 1970.
date, which has no Unix time, and others other dates before 1970 (see
exif_read.is_valid_capture_time()): raise as for an image with no capture
time, rather than let geo.as_unix_time() move it to 1970.
"""
try:
if not exif_read.is_valid_capture_time(capture_time):
raise ValueError(f"placeholder date {capture_time}")
return capture_time.timestamp()
except (ValueError, OverflowError, OSError):
raise exceptions.MapillaryGeoTaggingError(
Expand Down
24 changes: 17 additions & 7 deletions mapillary_tools/serializer/description.py
Original file line number Diff line number Diff line change
Expand Up @@ -552,13 +552,23 @@ def decode(cls, entry: T.Sequence[T.Any]) -> geo.Point:


def build_capture_time(time: datetime.datetime | int | float) -> str:
if isinstance(time, (float, int)):
dt = datetime.datetime.fromtimestamp(time, datetime.timezone.utc)
# otherwise it will be assumed to be in local time
dt = dt.replace(tzinfo=datetime.timezone.utc)
else:
# otherwise it will be assumed to be in local time
dt = time.astimezone(datetime.timezone.utc)
"""
Format a capture time for a description file, raising
MapillaryMetadataValidationError for a time that cannot be written as a
date (such as one that a time zone offset moves before year 1)
"""
try:
if isinstance(time, (float, int)):
dt = datetime.datetime.fromtimestamp(time, datetime.timezone.utc)
# otherwise it will be assumed to be in local time
dt = dt.replace(tzinfo=datetime.timezone.utc)
else:
# otherwise it will be assumed to be in local time
dt = time.astimezone(datetime.timezone.utc)
except (ValueError, OverflowError, OSError) as ex:
raise exceptions.MapillaryMetadataValidationError(
f"Invalid capture time {time}: {ex}"
) from ex
return datetime.datetime.strftime(dt, "%Y_%m_%d_%H_%M_%S_%f")[:-3]


Expand Down
35 changes: 35 additions & 0 deletions tests/integration/test_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -1485,3 +1485,38 @@ def test_process_video_with_a_gpx_far_longer_than_it_warns(
assert "could start anywhere along the track" in result.stderr
[desc] = json.loads(result.stdout)
assert desc["MAPGPSTrack"][0][0] == 0


def test_process_image_with_a_placeholder_capture_time_and_offset(
tmpdir: py.path.local,
):
"""
A camera without a clock writes 0001:01:01 00:00:00. With a time zone
offset, that time is before year 1 in UTC, and crashed the run
"""
from PIL import Image
from PIL.ExifTags import Base, GPS, IFD
from PIL.TiffImagePlugin import IFDRational

folder = tmpdir.mkdir("images")
for name, date in [
("placeholder.jpg", "0001:01:01 00:00:00"),
("valid.jpg", "2020:01:02 03:04:05"),
]:
img = Image.new("RGB", (4, 4), (0, 0, 0))
exif = img.getexif()
exif.get_ifd(IFD.Exif)[Base.DateTimeOriginal] = date
exif.get_ifd(IFD.Exif)[Base.OffsetTimeOriginal] = "+01:00"
gps = exif.get_ifd(IFD.GPSInfo)
gps[GPS.GPSLatitudeRef] = "N"
gps[GPS.GPSLatitude] = (IFDRational(24), IFDRational(33), IFDRational(36))
gps[GPS.GPSLongitudeRef] = "W"
gps[GPS.GPSLongitude] = (IFDRational(81), IFDRational(48), IFDRational(27))
img.save(str(folder.join(name)), format="JPEG", exif=exif)

# The EXIF reader alone, which needs no ExifTool installed
descs = run_process_for_descs(["--geotag_source=native", str(folder)])

by_name = {Path(desc["filename"]).name: desc for desc in descs}
assert by_name["placeholder.jpg"]["error"]["type"] == "MapillaryGeoTaggingError"
assert by_name["valid.jpg"]["MAPCaptureTime"] == "2020_01_02_02_04_05_000"
29 changes: 28 additions & 1 deletion tests/unit/test_description.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,27 @@
# This source code is licensed under the BSD license found in the
# LICENSE file in the root directory of this source tree.

import datetime
import json
from pathlib import Path

from mapillary_tools import geo, telemetry
import pytest
from mapillary_tools import geo, telemetry, types
from mapillary_tools.exceptions import MapillaryMetadataValidationError
from mapillary_tools.serializer.description import (
build_capture_time,
DescriptionJSONSerializer,
ImageVideoDescriptionFileSchema,
PointEncoder,
validate_and_fail_metadata,
validate_image_desc,
)

# 0001-01-01 00:00:00+01:00, which is before year 1 in UTC
_BEFORE_YEAR_ONE = datetime.datetime(
1, 1, 1, tzinfo=datetime.timezone(datetime.timedelta(hours=1))
).timestamp()


def test_validate_descs_ok():
descs = [
Expand Down Expand Up @@ -123,6 +132,24 @@ def test_serialize_image_description_ok():
}


def test_build_capture_time_out_of_range():
with pytest.raises(MapillaryMetadataValidationError, match="Invalid capture time"):
build_capture_time(_BEFORE_YEAR_ONE)


def test_a_capture_time_out_of_range_fails_the_image(tmp_path: Path):
image = tmp_path / "image.jpg"
image.write_bytes(b"")
metadata = types.ImageMetadata(
filename=image, time=_BEFORE_YEAR_ONE, lat=1.0, lon=2.0, alt=None, angle=None
)

error = validate_and_fail_metadata(metadata)

assert isinstance(error, types.ErrorMetadata)
assert isinstance(error.error, MapillaryMetadataValidationError)


def test_encode_base_point():
p = geo.Point(
time=1.5, lat=37.7749295, lon=-122.4194155, alt=10.1235, angle=90.1234
Expand Down
121 changes: 121 additions & 0 deletions tests/unit/test_image_exif_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,124 @@ def test_a_valid_capture_time_is_read(self, tmp_path: Path):
assert metadata.time == datetime.datetime(2020, 1, 2, 3, 4, 5).timestamp()
assert metadata.lat == pytest.approx(24.5601)
assert metadata.lon == pytest.approx(-81.8077)


def _write_image_with_dates(path: Path, tags: T.Dict[int, str]) -> Path:
img = Image.new("RGB", (4, 4), (0, 0, 0))
exif = img.getexif()
for tag, value in tags.items():
if tag == Base.DateTime:
exif[tag] = value
else:
exif.get_ifd(IFD.Exif)[tag] = value
gps = exif.get_ifd(IFD.GPSInfo)
gps[GPS.GPSLatitudeRef] = "N"
gps[GPS.GPSLatitude] = (IFDRational(24), IFDRational(33), IFDRational(3636, 100))
gps[GPS.GPSLongitudeRef] = "W"
gps[GPS.GPSLongitude] = (IFDRational(81), IFDRational(48), IFDRational(2772, 100))
img.save(path, format="JPEG", exif=exif)
return path


class TestPlaceholderCaptureTimes:
"""
Cameras without a clock write dates before 1970, at which no image was
captured. One with a time zone offset had a Unix time, and crashed the
run where the description is written.
"""

@pytest.mark.parametrize(
"date",
[NO_DATE, "1904:02:07 13:28:44", "1969:12:31 19:00:00"],
)
def test_exif(self, tmp_path: Path, date: str):
image = _write_image_with_dates(
tmp_path / "image.jpg",
{Base.DateTimeOriginal: date, Base.OffsetTimeOriginal: "+01:00"},
)

with pytest.raises(
exceptions.MapillaryGeoTaggingError, match="Unable to extract timestamp"
):
ImageEXIFExtractor(image).extract()

def test_exif_reads_the_next_date_tag(self, tmp_path: Path):
image = _write_image_with_dates(
tmp_path / "image.jpg",
{
Base.DateTimeOriginal: "1904:02:07 13:28:44",
Base.DateTimeDigitized: "2020:01:02 03:04:05",
},
)

metadata = ImageEXIFExtractor(image).extract()

assert metadata.time == datetime.datetime(2020, 1, 2, 3, 4, 5).timestamp()

def test_exif_reads_the_modify_date_last(self, tmp_path: Path):
image = _write_image_with_dates(
tmp_path / "image.jpg",
{
Base.DateTimeOriginal: NO_DATE,
Base.DateTimeDigitized: NO_DATE,
Base.DateTime: "2020:01:02 03:04:05",
},
)

metadata = ImageEXIFExtractor(image).extract()

assert metadata.time == datetime.datetime(2020, 1, 2, 3, 4, 5).timestamp()

@pytest.mark.parametrize(
"date",
[NO_DATE, "1904:02:07 13:28:44", "1969:12:31 19:00:00"],
)
def test_exiftool(self, tmp_path: Path, date: str):
element = _exiftool_element(
{
"GPS:GPSLatitude": "24.5601",
"GPS:GPSLatitudeRef": "N",
"GPS:GPSLongitude": "81.8077",
"GPS:GPSLongitudeRef": "W",
"ExifIFD:DateTimeOriginal": date,
"ExifIFD:OffsetTimeOriginal": "+01:00",
}
)

with pytest.raises(
exceptions.MapillaryGeoTaggingError, match="Unable to extract timestamp"
):
ImageExifToolExtractor(tmp_path / "image.jpg", element).extract()

def test_exiftool_reads_the_next_date_tag(self, tmp_path: Path):
element = _exiftool_element(
{
"GPS:GPSLatitude": "24.5601",
"GPS:GPSLatitudeRef": "N",
"GPS:GPSLongitude": "81.8077",
"GPS:GPSLongitudeRef": "W",
"GPS:GPSDateStamp": "1904:02:07",
"GPS:GPSTimeStamp": "13:28:44",
"ExifIFD:DateTimeOriginal": "1969:12:31 19:00:00",
"ExifIFD:CreateDate": "2020:01:02 03:04:05",
}
)
image = tmp_path / "image.jpg"
image.write_bytes(b"")

metadata = ImageExifToolExtractor(image, element).extract()

assert metadata.time == datetime.datetime(2020, 1, 2, 3, 4, 5).timestamp()

def test_a_capture_time_from_1970_is_read(self, tmp_path: Path):
image = _write_image_with_dates(
tmp_path / "image.jpg",
{
Base.DateTimeOriginal: "1970:01:02 00:00:00",
Base.OffsetTimeOriginal: "+00:00",
},
)

metadata = ImageEXIFExtractor(image).extract()

assert metadata.time == 24 * 3600
Loading