diff --git a/tests/unit/data/corrupt_exif_large_thumbnail.jpg b/tests/unit/data/corrupt_exif_large_thumbnail.jpg new file mode 100644 index 000000000..389c4eacc Binary files /dev/null and b/tests/unit/data/corrupt_exif_large_thumbnail.jpg differ diff --git a/tests/unit/data/corrupt_exif_trusted_wrong_type.jpg b/tests/unit/data/corrupt_exif_trusted_wrong_type.jpg new file mode 100644 index 000000000..336169364 Binary files /dev/null and b/tests/unit/data/corrupt_exif_trusted_wrong_type.jpg differ diff --git a/tests/unit/data/corrupt_exif_wrong_type.jpg b/tests/unit/data/corrupt_exif_wrong_type.jpg new file mode 100644 index 000000000..a6656932d Binary files /dev/null and b/tests/unit/data/corrupt_exif_wrong_type.jpg differ diff --git a/tests/unit/test_api_v4.py b/tests/unit/test_api_v4.py index c0e259824..07287c795 100644 --- a/tests/unit/test_api_v4.py +++ b/tests/unit/test_api_v4.py @@ -7,7 +7,6 @@ import pytest import requests - from mapillary_tools import api_v4 diff --git a/tests/unit/test_camm_parser.py b/tests/unit/test_camm_parser.py index e4bd12d62..23f274a33 100644 --- a/tests/unit/test_camm_parser.py +++ b/tests/unit/test_camm_parser.py @@ -10,7 +10,11 @@ from mapillary_tools import geo, telemetry, types, uploader from mapillary_tools.camm import camm_builder, camm_parser -from mapillary_tools.mp4 import construct_mp4_parser as cparser, simple_mp4_builder +from mapillary_tools.mp4 import ( + construct_mp4_parser as cparser, + mp4_sample_parser as sample_parser, + simple_mp4_builder, +) def test_filter_points_by_edit_list(): @@ -356,8 +360,6 @@ def test_build_and_parse3(): def test_camm_trak_carries_mvhd_timestamps(): """Verify that creation_time and modification_time from the source video's mvhd are carried into the CAMM track's tkhd and mdhd boxes.""" - from mapillary_tools.mp4 import mp4_sample_parser as sample_parser - movie_timescale = 1_000_000 src_creation_time = 3_692_845_200 # 2021-01-01 in MP4 epoch src_modification_time = 3_692_845_300 diff --git a/tests/unit/test_exifedit.py b/tests/unit/test_exifedit.py index 571b41efc..fb4d311ad 100644 --- a/tests/unit/test_exifedit.py +++ b/tests/unit/test_exifedit.py @@ -27,6 +27,13 @@ CORRUPT_EXIF_FILE_2 = data_dir.joinpath("corrupt_exif_2.jpg") FIXED_EXIF_FILE = data_dir.joinpath("fixed_exif.jpg") FIXED_EXIF_FILE_2 = data_dir.joinpath("fixed_exif_2.jpg") +# JPEGs whose EXIF can be read but cannot be written back out unchanged. +# UNDUMPABLE_EXIF_FILE carries a non-essential tag that cannot be saved; +# UNDUMPABLE_ESSENTIAL_EXIF_FILE carries an essential one. +UNDUMPABLE_EXIF_FILE = data_dir.joinpath("corrupt_exif_wrong_type.jpg") +UNDUMPABLE_ESSENTIAL_EXIF_FILE = data_dir.joinpath("corrupt_exif_trusted_wrong_type.jpg") +# A JPEG whose embedded thumbnail is too large to be written back out. +LARGE_THUMBNAIL_EXIF_FILE = data_dir.joinpath("corrupt_exif_large_thumbnail.jpg") def add_image_description_general(_test_obj, filename): @@ -216,6 +223,54 @@ def test_add_negative_lat_lon(self): assert (test_longitude, test_latitude) == exif_data.extract_lon_lat() + def test_add_make_and_model(self): + empty_exifedit = ExifEdit(EMPTY_EXIF_FILE_TEST) + empty_exifedit.add_make("Canon") + empty_exifedit.add_model("EOS 5D") + empty_exifedit.write(EMPTY_EXIF_FILE_TEST) + + exif_data = ExifRead(EMPTY_EXIF_FILE_TEST) + self.assertEqual("Canon", exif_data.extract_make()) + self.assertEqual("EOS 5D", exif_data.extract_model()) + + def test_add_make_empty_raises(self): + empty_exifedit = ExifEdit(EMPTY_EXIF_FILE_TEST) + with self.assertRaises(ValueError): + empty_exifedit.add_make("") + + def test_add_model_empty_raises(self): + empty_exifedit = ExifEdit(EMPTY_EXIF_FILE_TEST) + with self.assertRaises(ValueError): + empty_exifedit.add_model("") + + def test_add_orientation_invalid_raises(self): + empty_exifedit = ExifEdit(EMPTY_EXIF_FILE_TEST) + with self.assertRaises(ValueError): + empty_exifedit.add_orientation(99) + + def test_write_bytes_without_filename_raises(self): + with open(EMPTY_EXIF_FILE_TEST, "rb") as fp: + edit = ExifEdit(fp.read()) + edit.add_orientation(1) + # The source is raw bytes, so write() has no filename to fall back on. + with self.assertRaises(ValueError): + edit.write() + + def test_unwritable_non_essential_tag_is_dropped(self): + """An image with an unsavable non-essential tag is still saved without it.""" + edit = ExifEdit(UNDUMPABLE_EXIF_FILE) + image_bytes = edit.dump_image_bytes() + self.assertGreater(len(image_bytes), 0) + # The unsavable tag is absent from the output. + saved = piexif.load(image_bytes) + self.assertNotIn(piexif.ImageIFD.Software, saved["0th"]) + + def test_unwritable_essential_tag_raises(self): + """Saving fails if an essential tag cannot be written, rather than dropping it.""" + edit = ExifEdit(UNDUMPABLE_ESSENTIAL_EXIF_FILE) + with self.assertRaises(ValueError): + edit.dump_image_bytes() + # REPEAT CERTAIN TESTS AND ADD ADDITIONAL TESTS FOR THE CORRUPT EXIF def test_load_and_dump_corrupt_exif(self): corrupt_exifedit = ExifEdit(CORRUPT_EXIF_FILE) @@ -268,57 +323,17 @@ def test_add_repeatedly_time_original_corrupt_exif_2(self): add_repeatedly_time_original_general(self, CORRUPT_EXIF_FILE_2) def test_large_thumbnail_handling(self): - """Test that images with thumbnails larger than 64kB are handled gracefully.""" - # Create a test image with a large thumbnail (>64kB) - test_image_path = data_dir.joinpath("tmp", "large_thumbnail.jpg") - - # Create a simple test image - img = Image.new("RGB", (100, 100), color="red") - img.save(test_image_path, "JPEG") - - # Create a large thumbnail (>64kB) by creating a high-quality large thumbnail - # Use a larger size and add noise to make it incompressible - large_thumbnail = Image.new("RGB", (2048, 2048)) - # Fill with random-like data to prevent compression - pixels = large_thumbnail.load() - for i in range(2048): - for j in range(2048): - pixels[i, j] = ( - (i * 7 + j * 13) % 256, - (i * 11 + j * 17) % 256, - (i * 19 + j * 23) % 256, - ) - - thumbnail_bytes = io.BytesIO() - large_thumbnail.save(thumbnail_bytes, "JPEG", quality=100) - thumbnail_data = thumbnail_bytes.getvalue() - - # Verify thumbnail is larger than 64kB - self.assertGreater( - len(thumbnail_data), - 64 * 1024, - f"Test thumbnail should be larger than 64kB but got {len(thumbnail_data)} bytes", - ) + """An oversized embedded thumbnail is dropped on save; other EXIF survives. - # Load the image and add GPS data first - exif_edit = ExifEdit(test_image_path) + The image carries a thumbnail too large to be written back out. Saving + should still succeed, and GPS data added through the API is preserved. + """ + exif_edit = ExifEdit(LARGE_THUMBNAIL_EXIF_FILE) test_latitude = 50.5475894785 test_longitude = 15.595866685 exif_edit.add_lat_lon(test_latitude, test_longitude) - # Manually insert the large thumbnail into the internal EXIF structure - # This simulates what would happen if an image came in with a large thumbnail - exif_edit._ef["thumbnail"] = thumbnail_data - exif_edit._ef["1st"] = { - piexif.ImageIFD.Compression: 6, - piexif.ImageIFD.XResolution: (72, 1), - piexif.ImageIFD.YResolution: (72, 1), - piexif.ImageIFD.ResolutionUnit: 2, - piexif.ImageIFD.JPEGInterchangeFormat: 0, - piexif.ImageIFD.JPEGInterchangeFormatLength: len(thumbnail_data), - } - - # Given thumbnail is too large, max 64kB, thumbnail and 1st metadata should be removed. + # The oversized thumbnail should be dropped so the image can be saved. image_bytes = exif_edit.dump_image_bytes() # Verify the output is valid @@ -330,25 +345,23 @@ def test_large_thumbnail_handling(self): self.assertEqual(result_image.format, "JPEG") self.assertEqual(result_image.size, (100, 100)) - # Verify we can read the GPS data from the result + # The GPS data added before saving is still present. output_exif = piexif.load(image_bytes) self.assertIn("GPS", output_exif) self.assertIn(piexif.GPSIFD.GPSLatitude, output_exif["GPS"]) self.assertIn(piexif.GPSIFD.GPSLongitude, output_exif["GPS"]) - # CRITICAL: Verify the large thumbnail was actually removed - # The fix should have deleted both "thumbnail" and "1st" to handle the error - # piexif.load() may include "thumbnail": None after removal + # The oversized thumbnail is no longer present in the saved image. thumbnail_value = output_exif.get("thumbnail") self.assertTrue( thumbnail_value is None or thumbnail_value == b"", - f"Large thumbnail should have been removed but got: {thumbnail_value[:100] if thumbnail_value else None}", + f"thumbnail should have been removed but got: {thumbnail_value[:100] if thumbnail_value else None}", ) first_value = output_exif.get("1st") self.assertTrue( first_value is None or first_value == {} or len(first_value) == 0, - f"1st metadata should have been removed but got: {first_value}", + f"thumbnail metadata should have been removed but got: {first_value}", ) diff --git a/tests/unit/test_exifread.py b/tests/unit/test_exifread.py index 8e7d51c49..8d1cce8b5 100644 --- a/tests/unit/test_exifread.py +++ b/tests/unit/test_exifread.py @@ -4,8 +4,11 @@ # LICENSE file in the root directory of this source tree. import datetime +import io import os +import struct import typing as T +import xml.etree.ElementTree as ET from pathlib import Path import py.path @@ -14,9 +17,21 @@ from mapillary_tools.exif_read import ( _parse_coord, ExifRead, + ExifReadFromEXIF, + ExifReadFromXMP, + extract_xmp_efficiently, parse_datetimestr_with_subsec_and_offset, + XMP_NAMESPACES, ) from mapillary_tools.exif_write import ExifEdit +from mapillary_tools.exiftool_read import ( + EXIFTOOL_NAMESPACES as EXIFTOOL_READ_NAMESPACES, + ExifToolRead, +) +from mapillary_tools.exiftool_read_video import ( + EXIFTOOL_NAMESPACES as EXIFTOOL_READ_VIDEO_NAMESPACES, + ExifToolReadVideo, +) """Initialize all the neccessary data""" @@ -280,7 +295,6 @@ class TestExtractCameraUuidFromEXIF: def test_body_serial_only(self): """Test with only body serial number present""" - from mapillary_tools.exif_read import ExifReadFromEXIF reader = ExifReadFromEXIF.__new__(ExifReadFromEXIF) reader.tags = { @@ -290,7 +304,6 @@ def test_body_serial_only(self): def test_lens_serial_only(self): """Test with only lens serial number present""" - from mapillary_tools.exif_read import ExifReadFromEXIF reader = ExifReadFromEXIF.__new__(ExifReadFromEXIF) reader.tags = { @@ -300,7 +313,6 @@ def test_lens_serial_only(self): def test_both_body_and_lens_serial(self): """Test with both body and lens serial numbers present""" - from mapillary_tools.exif_read import ExifReadFromEXIF reader = ExifReadFromEXIF.__new__(ExifReadFromEXIF) reader.tags = { @@ -311,7 +323,6 @@ def test_both_body_and_lens_serial(self): def test_no_serial_numbers(self): """Test with no serial numbers present""" - from mapillary_tools.exif_read import ExifReadFromEXIF reader = ExifReadFromEXIF.__new__(ExifReadFromEXIF) reader.tags = {} @@ -319,7 +330,6 @@ def test_no_serial_numbers(self): def test_generic_serial_fallback(self): """Test fallback to generic EXIF SerialNumber""" - from mapillary_tools.exif_read import ExifReadFromEXIF reader = ExifReadFromEXIF.__new__(ExifReadFromEXIF) reader.tags = { @@ -329,7 +339,6 @@ def test_generic_serial_fallback(self): def test_makernote_serial_fallback(self): """Test fallback to MakerNote SerialNumber""" - from mapillary_tools.exif_read import ExifReadFromEXIF reader = ExifReadFromEXIF.__new__(ExifReadFromEXIF) reader.tags = { @@ -339,7 +348,6 @@ def test_makernote_serial_fallback(self): def test_body_serial_priority_over_generic(self): """Test that BodySerialNumber takes priority over generic SerialNumber""" - from mapillary_tools.exif_read import ExifReadFromEXIF reader = ExifReadFromEXIF.__new__(ExifReadFromEXIF) reader.tags = { @@ -350,7 +358,6 @@ def test_body_serial_priority_over_generic(self): def test_whitespace_stripped(self): """Test that whitespace is stripped from serial numbers""" - from mapillary_tools.exif_read import ExifReadFromEXIF reader = ExifReadFromEXIF.__new__(ExifReadFromEXIF) reader.tags = { @@ -361,7 +368,6 @@ def test_whitespace_stripped(self): def test_special_characters_removed(self): """Test that special characters are removed from serial numbers""" - from mapillary_tools.exif_read import ExifReadFromEXIF reader = ExifReadFromEXIF.__new__(ExifReadFromEXIF) reader.tags = { @@ -376,9 +382,6 @@ class TestExtractCameraUuidFromXMP: def _create_xmp_reader(self, tags_dict: dict): """Helper to create an ExifReadFromXMP with mocked tags""" - from mapillary_tools.exif_read import ExifReadFromXMP, XMP_NAMESPACES - import xml.etree.ElementTree as ET - # Build a minimal XMP document rdf_ns = XMP_NAMESPACES["rdf"] xmp_xml = f''' @@ -456,12 +459,6 @@ class TestVideoExtractCameraUuid: def _create_video_exif_reader(self, tags_dict: dict): """Helper to create an ExifToolReadVideo with mocked tags""" - from mapillary_tools.exiftool_read_video import ( - ExifToolReadVideo, - EXIFTOOL_NAMESPACES, - ) - import xml.etree.ElementTree as ET - # Build XML with child elements (not attributes) - this is how ExifTool XML works root = ET.Element( "rdf:RDF", {"xmlns:rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#"} @@ -470,8 +467,8 @@ def _create_video_exif_reader(self, tags_dict: dict): # Add child elements for each tag for key, value in tags_dict.items(): prefix, tag_name = key.split(":") - if prefix in EXIFTOOL_NAMESPACES: - full_tag = "{" + EXIFTOOL_NAMESPACES[prefix] + "}" + tag_name + if prefix in EXIFTOOL_READ_VIDEO_NAMESPACES: + full_tag = "{" + EXIFTOOL_READ_VIDEO_NAMESPACES[prefix] + "}" + tag_name child = ET.SubElement(root, full_tag) child.text = value @@ -528,16 +525,13 @@ class TestExifToolReadExtractCameraUuid: def _create_exiftool_reader(self, tags_dict: dict): """Helper to create an ExifToolRead with mocked tags""" - from mapillary_tools.exiftool_read import ExifToolRead, EXIFTOOL_NAMESPACES - import xml.etree.ElementTree as ET - # Build XML structure that ExifToolRead expects root = ET.Element("rdf:Description") for tag, value in tags_dict.items(): prefix, tag_name = tag.split(":", 1) - if prefix in EXIFTOOL_NAMESPACES: - full_tag = "{" + EXIFTOOL_NAMESPACES[prefix] + "}" + tag_name + if prefix in EXIFTOOL_READ_NAMESPACES: + full_tag = "{" + EXIFTOOL_READ_NAMESPACES[prefix] + "}" + tag_name child = ET.SubElement(root, full_tag) child.text = value @@ -627,3 +621,266 @@ def test_whitespace_stripped(self): } ) assert reader.extract_camera_uuid() == "BODY123_LENS456" + + +def _build_xmp_doc(tags: T.Dict[str, str]) -> str: + """Build an XMP packet whose rdf:Description carries ``tags`` as attributes.""" + rdf_ns = XMP_NAMESPACES["rdf"] + xml = ( + '' + '' + f'' + " ExifReadFromXMP: + return ExifReadFromXMP(ET.ElementTree(ET.fromstring(_build_xmp_doc(tags)))) + + +def _build_jpeg_with_xmp(xmp_xml: str) -> bytes: + """Build a minimal JPEG containing ``xmp_xml`` in an APP1 XMP segment.""" + identifier = b"http://ns.adobe.com/xap/1.0/\x00" + payload = identifier + xmp_xml.encode("utf-8") + # APP1 length field counts itself (2 bytes) plus the payload + app1 = b"\xff\xe1" + struct.pack(">H", len(payload) + 2) + payload + return b"\xff\xd8" + app1 + b"\xff\xd9" # SOI ... EOI + + +class TestExifReadFromXMPMetadata: + """Tests for reading metadata from XMP.""" + + def test_extract_altitude(self): + assert ( + _make_xmp_reader({"exif:GPSAltitude": "123.5"}).extract_altitude() == 123.5 + ) + + def test_extract_altitude_missing(self): + assert _make_xmp_reader({}).extract_altitude() is None + + def test_extract_lon_lat_numeric(self): + reader = _make_xmp_reader( + { + "exif:GPSLatitude": "50.5", + "exif:GPSLatitudeRef": "N", + "exif:GPSLongitude": "15.5", + "exif:GPSLongitudeRef": "E", + } + ) + assert reader.extract_lon_lat() == (15.5, 50.5) + + def test_extract_lon_lat_adobe_format(self): + reader = _make_xmp_reader( + { + "exif:GPSLatitude": "33,18.32N", + "exif:GPSLatitudeRef": "N", + "exif:GPSLongitude": "44,24.54E", + "exif:GPSLongitudeRef": "E", + } + ) + lonlat = reader.extract_lon_lat() + assert lonlat is not None + lon, lat = lonlat + assert lat == pytest.approx(33.30533, abs=1e-4) + assert lon == pytest.approx(44.40900, abs=1e-4) + + def test_extract_lon_lat_missing(self): + assert _make_xmp_reader({}).extract_lon_lat() is None + + def test_extract_make_and_model_stripped(self): + reader = _make_xmp_reader({"tiff:Make": "Canon ", "tiff:Model": " EOS "}) + assert reader.extract_make() == "Canon" + assert reader.extract_model() == "EOS" + + def test_extract_make_lens_fallback(self): + assert ( + _make_xmp_reader({"exifEX:LensMake": "LensCo"}).extract_make() == "LensCo" + ) + + def test_extract_make_missing(self): + assert _make_xmp_reader({}).extract_make() is None + assert _make_xmp_reader({}).extract_model() is None + + def test_extract_width_height(self): + reader = _make_xmp_reader( + {"exif:PixelXDimension": "1920", "exif:PixelYDimension": "1080"} + ) + assert reader.extract_width() == 1920 + assert reader.extract_height() == 1080 + + def test_extract_width_height_gpano_fallback(self): + assert ( + _make_xmp_reader({"GPano:FullPanoWidthPixels": "4096"}).extract_width() + == 4096 + ) + assert ( + _make_xmp_reader( + {"GPano:CroppedAreaImageHeightPixels": "2048"} + ).extract_height() + == 2048 + ) + + def test_extract_orientation(self): + assert _make_xmp_reader({"tiff:Orientation": "3"}).extract_orientation() == 3 + + def test_extract_orientation_invalid_defaults_to_1(self): + assert _make_xmp_reader({"tiff:Orientation": "99"}).extract_orientation() == 1 + + def test_extract_orientation_missing_defaults_to_1(self): + assert _make_xmp_reader({}).extract_orientation() == 1 + + def test_extract_direction(self): + assert ( + _make_xmp_reader({"exif:GPSImgDirection": "180.5"}).extract_direction() + == 180.5 + ) + + def test_extract_direction_track_fallback(self): + assert _make_xmp_reader({"exif:GPSTrack": "90.0"}).extract_direction() == 90.0 + + def test_extract_direction_missing(self): + assert _make_xmp_reader({}).extract_direction() is None + + def test_extract_exif_datetime(self): + reader = _make_xmp_reader({"exif:DateTimeOriginal": "2021:07:15 15:37:30"}) + assert reader.extract_exif_datetime() == datetime.datetime( + 2021, 7, 15, 15, 37, 30 + ) + + def test_extract_exif_datetime_digitized_fallback(self): + reader = _make_xmp_reader({"exif:DateTimeDigitized": "2020:01:02 03:04:05"}) + assert reader.extract_exif_datetime() == datetime.datetime(2020, 1, 2, 3, 4, 5) + + def test_extract_exif_datetime_missing(self): + assert _make_xmp_reader({}).extract_exif_datetime() is None + + def test_extract_gps_datetime_iso(self): + reader = _make_xmp_reader({"exif:GPSTimeStamp": "2021-07-15T05:37:30Z"}) + assert reader.extract_gps_datetime() == datetime.datetime( + 2021, 7, 15, 5, 37, 30, tzinfo=datetime.timezone.utc + ) + + def test_extract_gps_datetime_separate_date_and_time(self): + reader = _make_xmp_reader( + { + "exif:GPSDateStamp": "2021:07:15", + "exif:GPSTimeStamp": "05:37:30", + } + ) + assert reader.extract_gps_datetime() == datetime.datetime( + 2021, 7, 15, 5, 37, 30, tzinfo=datetime.timezone.utc + ) + + def test_extract_gps_datetime_missing(self): + assert _make_xmp_reader({}).extract_gps_datetime() is None + + def test_extract_capture_time_prefers_gps(self): + reader = _make_xmp_reader( + { + "exif:GPSTimeStamp": "2021-07-15T05:37:30Z", + "exif:DateTimeOriginal": "2000:01:01 00:00:00", + } + ) + assert reader.extract_capture_time() == datetime.datetime( + 2021, 7, 15, 5, 37, 30, tzinfo=datetime.timezone.utc + ) + + def test_extract_capture_time_falls_back_to_exif(self): + reader = _make_xmp_reader({"exif:DateTimeOriginal": "2021:07:15 15:37:30"}) + assert reader.extract_capture_time() == datetime.datetime( + 2021, 7, 15, 15, 37, 30 + ) + + def test_extract_capture_time_missing(self): + assert _make_xmp_reader({}).extract_capture_time() is None + + +class TestExtractXmpEfficiently: + """Tests for locating XMP metadata embedded in a JPEG.""" + + def test_returns_xmp_when_present(self): + xmp = _build_xmp_doc({"tiff:Make": "Canon"}) + result = extract_xmp_efficiently(io.BytesIO(_build_jpeg_with_xmp(xmp))) + assert result is not None + assert "" in result + + def test_returns_none_without_soi(self): + assert extract_xmp_efficiently(io.BytesIO(b"not a jpeg")) is None + + def test_returns_none_when_no_xmp_segment(self): + # SOI immediately followed by EOI: valid JPEG start, no APP1/XMP + assert extract_xmp_efficiently(io.BytesIO(b"\xff\xd8\xff\xd9")) is None + + def test_skips_non_xmp_app1_segment(self): + # An APP1 segment that is not XMP (e.g. an EXIF identifier) is skipped, + # and the following XMP APP1 segment is still found. + exif_id = b"Exif\x00\x00rest-of-exif" + exif_app1 = b"\xff\xe1" + struct.pack(">H", len(exif_id) + 2) + exif_id + xmp_app1 = _build_jpeg_with_xmp(_build_xmp_doc({"tiff:Make": "Canon"}))[2:] + data = b"\xff\xd8" + exif_app1 + xmp_app1 + result = extract_xmp_efficiently(io.BytesIO(data)) + assert result is not None + assert "Canon" in result + + +class TestExifReadXmpFallback: + """Reading metadata from a JPEG whose values live in XMP, not EXIF.""" + + def _make_reader(self, tags: T.Dict[str, str]) -> ExifRead: + jpeg = _build_jpeg_with_xmp(_build_xmp_doc(tags)) + return ExifRead(io.BytesIO(jpeg)) + + def test_make_model_fallback(self): + reader = self._make_reader({"tiff:Make": "XMPMake", "tiff:Model": "XMPModel"}) + assert reader.extract_make() == "XMPMake" + assert reader.extract_model() == "XMPModel" + + def test_altitude_fallback(self): + assert ( + self._make_reader({"exif:GPSAltitude": "123.5"}).extract_altitude() == 123.5 + ) + + def test_lon_lat_fallback(self): + reader = self._make_reader( + { + "exif:GPSLatitude": "50.5", + "exif:GPSLatitudeRef": "N", + "exif:GPSLongitude": "15.5", + "exif:GPSLongitudeRef": "E", + } + ) + assert reader.extract_lon_lat() == (15.5, 50.5) + + def test_width_height_fallback(self): + reader = self._make_reader( + {"exif:PixelXDimension": "1920", "exif:PixelYDimension": "1080"} + ) + assert reader.extract_width() == 1920 + assert reader.extract_height() == 1080 + + def test_capture_time_fallback(self): + reader = self._make_reader({"exif:DateTimeOriginal": "2020:01:02 03:04:05"}) + assert reader.extract_capture_time() == datetime.datetime(2020, 1, 2, 3, 4, 5) + + def test_camera_uuid_fallback(self): + reader = self._make_reader( + {"exif:SerialNumber": "BODYX", "exif:LensSerialNumber": "LENSY"} + ) + assert reader.extract_camera_uuid() == "BODYX_LENSY" + + def test_no_xmp_and_no_exif_returns_none(self): + # A JPEG with neither EXIF nor XMP: every extractor returns None. + reader = ExifRead(io.BytesIO(b"\xff\xd8\xff\xd9")) + assert reader.extract_make() is None + assert reader.extract_lon_lat() is None + assert reader.extract_capture_time() is None + assert reader.extract_camera_uuid() is None diff --git a/tests/unit/test_exiftool_read_video.py b/tests/unit/test_exiftool_read_video.py index ced1c5024..ca585d18e 100644 --- a/tests/unit/test_exiftool_read_video.py +++ b/tests/unit/test_exiftool_read_video.py @@ -8,7 +8,6 @@ import xml.etree.ElementTree as ET import pytest - from mapillary_tools.exiftool_read_video import ( _aggregate_gps_track, _aggregate_gps_track_by_sample_time, diff --git a/tests/unit/test_geo.py b/tests/unit/test_geo.py index 27790ad82..bd3a785e7 100644 --- a/tests/unit/test_geo.py +++ b/tests/unit/test_geo.py @@ -5,12 +5,14 @@ import dataclasses import datetime +import math import random import typing as T import unittest from mapillary_tools import geo from mapillary_tools.geo import Point +from mapillary_tools.telemetry import CAMMGPSPoint, GPSFix, GPSPoint # lat, lon, bearing, alt @@ -778,7 +780,6 @@ def test_avg_speed_with_base_points(self): def test_avg_speed_with_gps_points_using_epoch_time(self): """Test avg_speed with GPSPoint using epoch_time field.""" - from mapillary_tools.telemetry import GPSPoint, GPSFix # Video time is 0-10 seconds, but GPS epoch time spans 100 seconds # This simulates timelapse where video time != GPS time @@ -814,7 +815,6 @@ def test_avg_speed_with_gps_points_using_epoch_time(self): def test_avg_speed_with_gps_points_fallback_to_time(self): """Test avg_speed with GPSPoint falls back to time when epoch_time is None.""" - from mapillary_tools.telemetry import GPSPoint, GPSFix points = [ GPSPoint( @@ -847,7 +847,6 @@ def test_avg_speed_with_gps_points_fallback_to_time(self): def test_avg_speed_with_gps_points_zero_epoch_time_fallback(self): """Test avg_speed falls back to time when epoch_time is 0.""" - from mapillary_tools.telemetry import GPSPoint, GPSFix points = [ GPSPoint( @@ -879,7 +878,6 @@ def test_avg_speed_with_gps_points_zero_epoch_time_fallback(self): def test_avg_speed_with_camm_gps_points(self): """Test avg_speed with CAMMGPSPoint using time_gps_epoch field.""" - from mapillary_tools.telemetry import CAMMGPSPoint # Video time is 0-10 seconds, but GPS epoch time spans 50 seconds points = [ @@ -922,7 +920,6 @@ def test_avg_speed_with_camm_gps_points(self): def test_avg_speed_with_camm_gps_points_zero_epoch_fallback(self): """Test avg_speed with CAMMGPSPoint falls back when time_gps_epoch is 0.""" - from mapillary_tools.telemetry import CAMMGPSPoint points = [ CAMMGPSPoint( @@ -973,7 +970,6 @@ def test_avg_speed_single_point(self): def test_avg_speed_zero_time_diff_returns_nan(self): """Test avg_speed returns NaN when time difference is zero.""" - import math # Two points at the same timestamp points = [ @@ -989,7 +985,6 @@ class TestInterpolatePreservesPointType(unittest.TestCase): def test_interpolate_gps_points_returns_gps_point(self): """Test that interpolating GPSPoints returns a GPSPoint.""" - from mapillary_tools.telemetry import GPSPoint, GPSFix points = [ GPSPoint( @@ -1035,7 +1030,6 @@ def test_interpolate_gps_points_returns_gps_point(self): def test_interpolate_gps_points_with_none_epoch_time(self): """Test interpolating GPSPoints when epoch_time is None.""" - from mapillary_tools.telemetry import GPSPoint, GPSFix points = [ GPSPoint( @@ -1071,7 +1065,6 @@ def test_interpolate_gps_points_with_none_epoch_time(self): def test_interpolate_camm_gps_points_returns_camm_gps_point(self): """Test that interpolating CAMMGPSPoints returns a CAMMGPSPoint.""" - from mapillary_tools.telemetry import CAMMGPSPoint points = [ CAMMGPSPoint( @@ -1145,7 +1138,6 @@ def test_interpolate_base_points_returns_base_point(self): def test_interpolator_preserves_gps_point_type(self): """Test that Interpolator preserves GPSPoint type.""" - from mapillary_tools.telemetry import GPSPoint, GPSFix track = [ GPSPoint( @@ -1180,7 +1172,6 @@ def test_interpolator_preserves_gps_point_type(self): def test_interpolator_preserves_camm_gps_point_type(self): """Test that Interpolator preserves CAMMGPSPoint type.""" - from mapillary_tools.telemetry import CAMMGPSPoint track = [ CAMMGPSPoint( diff --git a/tests/unit/test_gpmf_gps_filter.py b/tests/unit/test_gpmf_gps_filter.py index c86b5c760..c15ef40e5 100644 --- a/tests/unit/test_gpmf_gps_filter.py +++ b/tests/unit/test_gpmf_gps_filter.py @@ -8,7 +8,6 @@ import statistics import pytest - from mapillary_tools.geo import Point from mapillary_tools.gpmf import gps_filter from mapillary_tools.gpmf.gpmf_gps_filter import remove_noisy_points, remove_outliers diff --git a/tests/unit/test_gpmf_parser.py b/tests/unit/test_gpmf_parser.py index bae30ce11..f8ac7e944 100644 --- a/tests/unit/test_gpmf_parser.py +++ b/tests/unit/test_gpmf_parser.py @@ -5,10 +5,10 @@ import datetime import os +import struct from pathlib import Path import pytest - from mapillary_tools import telemetry from mapillary_tools.gpmf import gpmf_parser @@ -331,8 +331,6 @@ def _build_gps9_sample_bytes( self, lat, lon, alt, speed2d, speed3d, days, secs_ms, dop, fix ): """Encode raw GPS9 values as bytes using the 'lllllllSS' format.""" - import struct - return struct.pack( ">iiiiiiiHH", lat, @@ -566,8 +564,6 @@ def test_no_strm_key(self): def test_gps9_preferred_over_gps5(self): """GPS9 is tried first within each STRM; GPS5 is fallback.""" - import struct - sample_bytes = struct.pack( ">iiiiiiiHH", 510776007, diff --git a/tests/unit/test_gpx_serializer.py b/tests/unit/test_gpx_serializer.py index 7744d548e..3a52cad53 100644 --- a/tests/unit/test_gpx_serializer.py +++ b/tests/unit/test_gpx_serializer.py @@ -11,12 +11,7 @@ from mapillary_tools.geo import Point from mapillary_tools.serializer.gpx import GPXSerializer from mapillary_tools.telemetry import CAMMGPSPoint, GPSFix, GPSPoint -from mapillary_tools.types import ( - ErrorMetadata, - FileType, - ImageMetadata, - VideoMetadata, -) +from mapillary_tools.types import ErrorMetadata, FileType, ImageMetadata, VideoMetadata def _make_image( diff --git a/tests/unit/test_history.py b/tests/unit/test_history.py index a64f5aafb..dd0783768 100644 --- a/tests/unit/test_history.py +++ b/tests/unit/test_history.py @@ -9,7 +9,6 @@ from unittest.mock import patch import pytest - from mapillary_tools import history, types diff --git a/tests/unit/test_http.py b/tests/unit/test_http.py index 9e5b5c42e..f23676732 100644 --- a/tests/unit/test_http.py +++ b/tests/unit/test_http.py @@ -6,7 +6,6 @@ from unittest.mock import MagicMock import requests - from mapillary_tools import http diff --git a/tests/unit/test_ipc.py b/tests/unit/test_ipc.py index 190421de5..37458e6ee 100644 --- a/tests/unit/test_ipc.py +++ b/tests/unit/test_ipc.py @@ -8,7 +8,6 @@ from unittest.mock import patch import pytest - from mapillary_tools import ipc diff --git a/tests/unit/test_sample_video.py b/tests/unit/test_sample_video.py index e92aef4a8..0743eeb42 100644 --- a/tests/unit/test_sample_video.py +++ b/tests/unit/test_sample_video.py @@ -15,7 +15,6 @@ import py.path import pytest - from mapillary_tools import ( exceptions, exif_read,