From 51caa8a6bdd4ebe5d1addffa150f96c74fff10c1 Mon Sep 17 00:00:00 2001 From: Jake Smarter Date: Tue, 2 Sep 2025 10:45:00 +0200 Subject: [PATCH] fix: Write fully reduced Exif rationals with full precision Add debug (verbose) messages on written tags --- mapillary_tools/exif_write.py | 90 ++++++++++++++++++++++++----------- tests/unit/test_exifedit.py | 17 ++----- 2 files changed, 66 insertions(+), 41 deletions(-) diff --git a/mapillary_tools/exif_write.py b/mapillary_tools/exif_write.py index 33c1b6bde..25d6c419b 100644 --- a/mapillary_tools/exif_write.py +++ b/mapillary_tools/exif_write.py @@ -6,6 +6,7 @@ import json import logging import math +from fractions import Fraction from pathlib import Path import piexif @@ -29,16 +30,19 @@ def __init__(self, filename_or_bytes: Path | bytes) -> None: @staticmethod def decimal_to_dms( - value: float, precision: int - ) -> tuple[tuple[float, int], tuple[float, int], tuple[float, int]]: - """ - Convert decimal position to degrees, minutes, seconds in a fromat supported by EXIF - """ - deg = math.floor(value) - min = math.floor((value - deg) * 60) - sec = math.floor((value - deg - min / 60) * 3600 * precision) - - return (deg, 1), (min, 1), (sec, precision) + value: float, + ) -> tuple[tuple[int, int], tuple[int, int], tuple[int, int]]: + """Convert decimal position to Exif degrees, minutes, and seconds rationals""" + + deg: int = int(value) + min: int = int(value := (value - deg) * 60) + sec: float = (value - min) * 60 + + return ( + (deg, 1), + (min, 1), + (Fraction.from_float(sec).limit_denominator().as_integer_ratio()), + ) def add_image_description(self, data: dict) -> None: """Add a dict to image description.""" @@ -83,41 +87,69 @@ def add_gps_datetime(self, dt: datetime.datetime) -> None: self._ef["GPS"][piexif.GPSIFD.GPSTimeStamp] = ( (dt.hour, 1), (dt.minute, 1), - # num / den = (dt.second * 1e6 + dt.microsecond) / 1e6 - (int(dt.second * 1e6 + dt.microsecond), int(1e6)), + ( + Fraction.from_float(dt.second + dt.microsecond / 1e6) + .limit_denominator() + .as_integer_ratio() + ), ) - - def add_lat_lon(self, lat: float, lon: float, precision: float = 1e7) -> None: + if LOG.isEnabledFor(logging.DEBUG): + LOG.debug( + 'GPSDateStamp: "%s"\tGPSTimeStamp: %s', + self._ef["GPS"][piexif.GPSIFD.GPSDateStamp], + self._ef["GPS"][piexif.GPSIFD.GPSTimeStamp], + ) + + def add_lat_lon(self, lat: float, lon: float) -> None: """Add lat, lon to gps (lat, lon in float).""" + self._ef["GPS"][piexif.GPSIFD.GPSLatitudeRef] = "N" if lat > 0 else "S" + self._ef["GPS"][piexif.GPSIFD.GPSLatitude] = ExifEdit.decimal_to_dms( + math.fabs(lat) + ) self._ef["GPS"][piexif.GPSIFD.GPSLongitudeRef] = "E" if lon > 0 else "W" self._ef["GPS"][piexif.GPSIFD.GPSLongitude] = ExifEdit.decimal_to_dms( - abs(lon), int(precision) - ) - self._ef["GPS"][piexif.GPSIFD.GPSLatitude] = ExifEdit.decimal_to_dms( - abs(lat), int(precision) + math.fabs(lon) ) + if LOG.isEnabledFor(logging.DEBUG): + LOG.debug( + "GPSLatitude: %s\tGPSLongitude: %s", + self._ef["GPS"][piexif.GPSIFD.GPSLatitude], + self._ef["GPS"][piexif.GPSIFD.GPSLongitude], + ) + + def add_altitude(self, altitude: float) -> None: + """Add altitude.""" - def add_altitude(self, altitude: float, precision: int = 100) -> None: - """Add altitude (pre is the precision).""" ref = 0 if altitude > 0 else 1 self._ef["GPS"][piexif.GPSIFD.GPSAltitude] = ( - int(abs(altitude) * precision), - precision, + Fraction.from_float(math.fabs(altitude)) + .limit_denominator() + .as_integer_ratio() ) self._ef["GPS"][piexif.GPSIFD.GPSAltitudeRef] = ref - - def add_direction( - self, direction: float, ref: str = "T", precision: int = 100 - ) -> None: + if LOG.isEnabledFor(logging.DEBUG): + LOG.debug( + 'GPSAltitudeRef: "%s"\tGPSAltitude: %s', + self._ef["GPS"][piexif.GPSIFD.GPSAltitudeRef], + self._ef["GPS"][piexif.GPSIFD.GPSAltitude], + ) + + def add_direction(self, direction: float, ref: str = "T") -> None: """Add image direction.""" + # normalize direction - direction = direction % 360.0 + direction = math.fmod(direction, 360.0) self._ef["GPS"][piexif.GPSIFD.GPSImgDirection] = ( - int(abs(direction) * precision), - precision, + Fraction.from_float(direction).limit_denominator().as_integer_ratio() ) self._ef["GPS"][piexif.GPSIFD.GPSImgDirectionRef] = ref + if LOG.isEnabledFor(logging.DEBUG): + LOG.debug( + 'GPSImgDirectionRef: "%s"\tGPSImgDirection: %s', + self._ef["GPS"][piexif.GPSIFD.GPSImgDirectionRef], + self._ef["GPS"][piexif.GPSIFD.GPSImgDirection], + ) def add_make(self, make: str) -> None: if not make: diff --git a/tests/unit/test_exifedit.py b/tests/unit/test_exifedit.py index 34e66181b..e5aa8fbeb 100644 --- a/tests/unit/test_exifedit.py +++ b/tests/unit/test_exifedit.py @@ -68,11 +68,10 @@ def add_date_time_original_general(test_obj, filename: Path): def add_lat_lon_general(test_obj, filename): test_latitude = 50.5475894785 test_longitude = 15.595866685 - precision = 1e7 empty_exifedit = ExifEdit(filename) - empty_exifedit.add_lat_lon(test_latitude, test_longitude, precision) + empty_exifedit.add_lat_lon(test_latitude, test_longitude) empty_exifedit.write(EMPTY_EXIF_FILE_TEST) exif_data = ExifRead(EMPTY_EXIF_FILE_TEST) @@ -82,11 +81,10 @@ def add_lat_lon_general(test_obj, filename): def add_altitude_general(test_obj, filename: Path): test_altitude = 15.5 - test_altitude_precision = 100 empty_exifedit = ExifEdit(filename) - empty_exifedit.add_altitude(test_altitude, test_altitude_precision) + empty_exifedit.add_altitude(test_altitude) empty_exifedit.write(EMPTY_EXIF_FILE_TEST) exif_data = ExifRead(EMPTY_EXIF_FILE_TEST) @@ -118,13 +116,10 @@ def add_repeatedly_time_original_general(test_obj, filename): def add_direction_general(test_obj, filename): test_direction = 1 test_direction_ref = "T" - test_direction_precision = 100 empty_exifedit = ExifEdit(filename) - empty_exifedit.add_direction( - test_direction, test_direction_ref, test_direction_precision - ) + empty_exifedit.add_direction(test_direction, test_direction_ref) empty_exifedit.write(EMPTY_EXIF_FILE_TEST) exif_data = ExifRead(EMPTY_EXIF_FILE_TEST) @@ -181,11 +176,10 @@ def test_add_repeatedly_time_original(self): def test_add_time_original_to_existing_exif(self): test_altitude = 15.5 - test_altitude_precision = 100 empty_exifedit = ExifEdit(EMPTY_EXIF_FILE_TEST) - empty_exifedit.add_altitude(test_altitude, test_altitude_precision) + empty_exifedit.add_altitude(test_altitude) empty_exifedit.write(EMPTY_EXIF_FILE_TEST) test_datetime = datetime.datetime(2016, 9, 30, 8, 29, 26, 249000) @@ -205,11 +199,10 @@ def test_add_time_original_to_existing_exif(self): def test_add_negative_lat_lon(self): test_latitude = -50.5 test_longitude = -15.5 - precision = 1e7 empty_exifedit = ExifEdit(EMPTY_EXIF_FILE_TEST) - empty_exifedit.add_lat_lon(test_latitude, test_longitude, precision) + empty_exifedit.add_lat_lon(test_latitude, test_longitude) empty_exifedit.write(EMPTY_EXIF_FILE_TEST) exif_data = ExifRead(EMPTY_EXIF_FILE_TEST)