mapillary_tools/exif_read.py (233 lines of code) (raw):

import sys from typing import List, Optional, Tuple, Type, Union, Any import datetime import os import exifread from exifread.utils import Ratio from .geo import normalize_bearing def eval_frac(value: Ratio) -> float: return float(value.num) / float(value.den) def format_time(time_string: str) -> Tuple[datetime.datetime, bool]: """ Format time string with invalid time elements in hours/minutes/seconds Format for the timestring needs to be "%Y_%m_%d_%H_%M_%S" e.g. 2014_03_31_24_10_11 => 2014_04_01_00_10_11 """ subseconds = False data = time_string.split("_") hours, minutes, seconds = int(data[3]), int(data[4]), int(data[5]) date = datetime.datetime.strptime("_".join(data[:3]), "%Y_%m_%d") subsec = 0.0 if len(data) == 7: if float(data[6]) != 0: subsec = float(data[6]) / 10 ** len(data[6]) subseconds = True date_time = date + datetime.timedelta( hours=hours, minutes=minutes, seconds=seconds + subsec ) return date_time, subseconds def gps_to_decimal(values: List[Ratio], reference: str) -> Optional[float]: sign = 1 if reference in "NE" else -1 deg, min, sec = values try: degrees = eval_frac(deg) minutes = eval_frac(min) seconds = eval_frac(sec) except ZeroDivisionError: return None return sign * (degrees + minutes / 60 + seconds / 3600) def exif_datetime_fields() -> List[List[str]]: """ Date time fields in EXIF """ return [ [ "EXIF DateTimeOriginal", "Image DateTimeOriginal", "EXIF DateTimeDigitized", "Image DateTimeDigitized", "EXIF DateTime", "Image DateTime", "GPS GPSDate", "EXIF GPS GPSDate", "EXIF DateTimeModified", ] ] def exif_gps_date_fields() -> List[List[str]]: """ Date fields in EXIF GPS """ return [["GPS GPSDate", "EXIF GPS GPSDate"]] class ExifRead: """ EXIF class for reading exif from an image """ def __init__(self, filename: str, details: bool = False) -> None: """ Initialize EXIF object with FILE as filename or fileobj """ self.filename = filename if isinstance(filename, str): with open(filename, "rb") as fp: self.tags = exifread.process_file(fp, details=details, debug=True) else: self.tags = exifread.process_file(filename, details=details, debug=True) def _extract_alternative_fields( self, fields: List[str], default: Optional[Union[str, int, float]] = None, field_type: Union[Type[float], Type[str], Type[int]] = float, ) -> Tuple[Any, Optional[str]]: """ Extract a value for a list of ordered fields. Return the value of the first existed field in the list """ for field in fields: if field in self.tags: if field_type is float: try: return eval_frac(self.tags[field].values[0]), field except ZeroDivisionError: pass elif field_type is str: return str(self.tags[field].values), field elif field_type is int: return int(self.tags[field].values[0]), field else: raise ValueError(f"Invalid field type {field_type}") return default, None def extract_altitude(self) -> Optional[float]: """ Extract altitude """ fields: List[str] = ["GPS GPSAltitude", "EXIF GPS GPSAltitude"] altitude, _ = self._extract_alternative_fields( fields, default=None, field_type=float ) if altitude is None: return None fields = ["GPS GPSAltitudeRef", "EXIF GPS GPSAltitudeRef"] ref, _ = self._extract_alternative_fields(fields, default=0, field_type=int) altitude_ref = {0: 1, 1: -1} return altitude * altitude_ref.get(ref, 1) def extract_capture_time(self) -> Optional[datetime.datetime]: """ Extract capture time from EXIF return a datetime object TODO: handle GPS DateTime """ time_string = exif_datetime_fields()[0] capture_time, time_field = self._extract_alternative_fields( time_string, default=None, field_type=str ) if time_field in exif_gps_date_fields()[0]: return self.extract_gps_time() if capture_time is None: # try interpret the filename basename, _ = os.path.splitext(os.path.basename(self.filename)) try: return datetime.datetime.strptime( basename + "000", "%Y_%m_%d_%H_%M_%S_%f" ) except ValueError: return None else: capture_time = capture_time.replace(" ", "_") capture_time = capture_time.replace(":", "_") capture_time = capture_time.replace(".", "_") capture_time = capture_time.replace("-", "_") capture_time = capture_time.replace(",", "_") capture_time = "_".join( [ts for ts in capture_time.split("_") if ts.isdigit()] ) capture_time_obj, has_subseconds = format_time(capture_time) if not has_subseconds: sub_sec = self._extract_subsec() # Fix spaces in subsec in gopro # See https://github.com/mapillary/mapillary_tools/issues/388#issuecomment-860198046 # and https://community.gopro.com/t5/Cameras/subsecond-timestamp-bug/m-p/1057505 if sub_sec.startswith(" "): make = self.extract_make() if make is not None and make.lower() == "gopro": sub_sec = sub_sec.replace(" ", "0") capture_time_obj = capture_time_obj + datetime.timedelta( seconds=float("0." + sub_sec) ) return capture_time_obj def extract_direction(self) -> Optional[float]: """ Extract image direction (i.e. compass, heading, bearing) """ fields = [ "GPS GPSImgDirection", "EXIF GPS GPSImgDirection", "GPS GPSTrack", "EXIF GPS GPSTrack", ] direction, _ = self._extract_alternative_fields( fields, default=None, field_type=float ) if direction is not None: direction = normalize_bearing(direction, check_hex=True) return direction def extract_gps_time(self) -> Optional[datetime.datetime]: """ Extract timestamp from GPS field. """ gps_date_field = "GPS GPSDate" gps_time_field = "GPS GPSTimeStamp" if gps_date_field in self.tags and gps_time_field in self.tags: date = str(self.tags[gps_date_field].values).split(":") if int(date[0]) == 0 or int(date[1]) == 0 or int(date[2]) == 0: return None t = self.tags[gps_time_field] gps_time = datetime.datetime( year=int(date[0]), month=int(date[1]), day=int(date[2]), hour=int(eval_frac(t.values[0])), minute=int(eval_frac(t.values[1])), second=int(eval_frac(t.values[2])), ) microseconds = datetime.timedelta( microseconds=int((eval_frac(t.values[2]) % 1) * 1e6) ) gps_time += microseconds return gps_time else: return None def extract_lon_lat(self) -> Tuple[Optional[float], Optional[float]]: lat_tag = self.tags.get("GPS GPSLatitude") lon_tag = self.tags.get("GPS GPSLongitude") if lat_tag and lon_tag: lat_ref_tag = self.tags.get("GPS GPSLatitudeRef") lat = gps_to_decimal( lat_tag.values, lat_ref_tag.values if lat_ref_tag else "N" ) lon_ref_tag = self.tags.get("GPS GPSLongitudeRef") lon = gps_to_decimal( lon_tag.values, lon_ref_tag.values if lon_ref_tag else "E" ) if lon is not None and lat is not None: return lon, lat # repeat above lat_tag = self.tags.get("EXIF GPS GPSLatitude") lon_tag = self.tags.get("EXIF GPS GPSLongitude") if lat_tag and lon_tag: lat_ref_tag = self.tags.get("EXIF GPS GPSLatitudeRef") lat = gps_to_decimal( lat_tag.values, lat_ref_tag.values if lat_ref_tag else "N" ) lon_ref_tag = self.tags.get("EXIF GPS GPSLongitudeRef") lon = gps_to_decimal( lon_tag.values, lon_ref_tag.values if lon_ref_tag else "E" ) if lon is not None and lat is not None: return lon, lat return None, None def extract_make(self) -> Optional[str]: """ Extract camera make """ fields = ["EXIF LensMake", "Image Make"] make, _ = self._extract_alternative_fields(fields, default=None, field_type=str) return make def extract_model(self) -> Optional[str]: """ Extract camera model """ fields = ["EXIF LensModel", "Image Model"] model, _ = self._extract_alternative_fields( fields, default=None, field_type=str ) return model def extract_orientation(self) -> int: """ Extract image orientation """ fields = ["Image Orientation"] orientation, _ = self._extract_alternative_fields( fields, default=1, field_type=int ) if orientation not in range(1, 9): return 1 return orientation def _extract_subsec(self) -> str: """ Extract microseconds """ fields = [ "Image SubSecTimeOriginal", "EXIF SubSecTimeOriginal", "Image SubSecTimeDigitized", "EXIF SubSecTimeDigitized", "Image SubSecTime", "EXIF SubSecTime", ] sub_sec, _ = self._extract_alternative_fields( fields, default="", field_type=str ) return sub_sec if __name__ == "__main__": import pprint for filename in sys.argv[1:]: exif = ExifRead(filename, details=True) pprint.pprint( { "capture_time": exif.extract_capture_time(), "gps_time": exif.extract_gps_time(), "direction": exif.extract_direction(), "model": exif.extract_model(), "make": exif.extract_make(), "lon_lat": exif.extract_lon_lat(), "altitude": exif.extract_altitude(), } )