diff --git a/mapillary_tools/exif_read.py b/mapillary_tools/exif_read.py index 65189bd17..8953d13cf 100644 --- a/mapillary_tools/exif_read.py +++ b/mapillary_tools/exif_read.py @@ -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) @@ -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 @@ -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() @@ -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 @@ -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() diff --git a/mapillary_tools/exiftool_read.py b/mapillary_tools/exiftool_read.py index 0f645fd51..f2bb6a1e6 100644 --- a/mapillary_tools/exiftool_read.py +++ b/mapillary_tools/exiftool_read.py @@ -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 @@ -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() diff --git a/mapillary_tools/geotag/image_extractors/exif.py b/mapillary_tools/geotag/image_extractors/exif.py index 0997f7a16..902dc755c 100644 --- a/mapillary_tools/geotag/image_extractors/exif.py +++ b/mapillary_tools/geotag/image_extractors/exif.py @@ -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( diff --git a/mapillary_tools/serializer/description.py b/mapillary_tools/serializer/description.py index 62597b0c5..78a9df587 100644 --- a/mapillary_tools/serializer/description.py +++ b/mapillary_tools/serializer/description.py @@ -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] diff --git a/tests/integration/test_process.py b/tests/integration/test_process.py index 31afa5113..5ec3a3190 100644 --- a/tests/integration/test_process.py +++ b/tests/integration/test_process.py @@ -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" diff --git a/tests/unit/test_description.py b/tests/unit/test_description.py index adf95b6e2..f423ff1fb 100644 --- a/tests/unit/test_description.py +++ b/tests/unit/test_description.py @@ -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 = [ @@ -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 diff --git a/tests/unit/test_image_exif_extractor.py b/tests/unit/test_image_exif_extractor.py index 6c5c2f8ab..83ac7e256 100644 --- a/tests/unit/test_image_exif_extractor.py +++ b/tests/unit/test_image_exif_extractor.py @@ -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