Skip to content

Tag Reader's API Reference

Reader

CropValues dataclass

Cropped equirectangular pictures metadata

Attributes:

Name Type Description
fullWidth int

Full panorama width

fullHeight int

Full panorama height

width int

Cropped area width

height int

Cropped area height

left int

Cropped area left offset

top int

Cropped area top offset

Source code in geopic_tag_reader/reader.py
@dataclass
class CropValues:
    """Cropped equirectangular pictures metadata

    Attributes:
        fullWidth (int): Full panorama width
        fullHeight (int): Full panorama height
        width (int): Cropped area width
        height (int): Cropped area height
        left (int): Cropped area left offset
        top (int): Cropped area top offset
    """

    fullWidth: int
    fullHeight: int
    width: int
    height: int
    left: int
    top: int

GeoPicTags dataclass

Tags associated to a geolocated picture

Attributes:

Name Type Description
lat float

GPS Latitude (in WGS84)

lon float

GPS Longitude (in WGS84)

ts datetime

The capture date (date & time with timezone)

heading int

Picture GPS heading (in degrees, North = 0°, East = 90°, South = 180°, West = 270°). Value is computed based on image center (if yaw=0°)

type str

The kind of picture (flat, equirectangular)

make str

The camera manufacturer name

model str

The camera model name

focal_length float

The camera focal length (in mm)

crop CropValues

The picture cropped area metadata (optional)

exif dict[str, str]

Raw EXIF tags from picture (following Exiv2 naming scheme, see https://exiv2.org/metadata.html)

tagreader_warnings list[str]

List of thrown warnings during metadata reading

altitude float

altitude (in m) (optional)

pitch float

Picture pitch angle, compared to horizon (in degrees, bottom = -90°, horizon = 0°, top = 90°)

roll float

Picture roll angle, on a right/left axis (in degrees, left-arm down = -90°, flat = 0°, right-arm down = 90°)

yaw float

Picture yaw angle, on a vertical axis (in degrees, front = 0°, right = 90°, rear = 180°, left = 270°). This offsets the center image from GPS direction for a correct 360° sphere correction

ts_by_source TimeBySource

all read timestamps from image, for finer processing.

Implementation note: this needs to be sync with the PartialGeoPicTags structure

Source code in geopic_tag_reader/reader.py
@dataclass
class GeoPicTags:
    """Tags associated to a geolocated picture

    Attributes:
        lat (float): GPS Latitude (in WGS84)
        lon (float): GPS Longitude (in WGS84)
        ts (datetime): The capture date (date & time with timezone)
        heading (int): Picture GPS heading (in degrees, North = 0°, East = 90°, South = 180°, West = 270°). Value is computed based on image center (if yaw=0°)
        type (str): The kind of picture (flat, equirectangular)
        make (str): The camera manufacturer name
        model (str): The camera model name
        focal_length (float): The camera focal length (in mm)
        crop (CropValues): The picture cropped area metadata (optional)
        exif (dict[str, str]): Raw EXIF tags from picture (following Exiv2 naming scheme, see https://exiv2.org/metadata.html)
        tagreader_warnings (list[str]): List of thrown warnings during metadata reading
        altitude (float): altitude (in m) (optional)
        pitch (float): Picture pitch angle, compared to horizon (in degrees, bottom = -90°, horizon = 0°, top = 90°)
        roll (float): Picture roll angle, on a right/left axis (in degrees, left-arm down = -90°, flat = 0°, right-arm down = 90°)
        yaw (float): Picture yaw angle, on a vertical axis (in degrees, front = 0°, right = 90°, rear = 180°, left = 270°). This offsets the center image from GPS direction for a correct 360° sphere correction
        ts_by_source (TimeBySource): all read timestamps from image, for finer processing.


    Implementation note: this needs to be sync with the PartialGeoPicTags structure
    """

    lat: float
    lon: float
    ts: datetime.datetime
    heading: Optional[int]
    type: str
    make: Optional[str]
    model: Optional[str]
    focal_length: Optional[float]
    crop: Optional[CropValues]
    exif: Dict[str, str] = field(default_factory=lambda: {})
    tagreader_warnings: List[str] = field(default_factory=lambda: [])
    altitude: Optional[float] = None
    pitch: Optional[float] = None
    roll: Optional[float] = None
    yaw: Optional[float] = None
    ts_by_source: Optional[TimeBySource] = None

InvalidExifException

Bases: Exception

Exception for invalid EXIF information from image

Source code in geopic_tag_reader/reader.py
class InvalidExifException(Exception):
    """Exception for invalid EXIF information from image"""

    def __init__(self, msg):
        super().__init__(msg)

InvalidFractionException

Bases: Exception

Exception for invalid list of fractions

Source code in geopic_tag_reader/reader.py
class InvalidFractionException(Exception):
    """Exception for invalid list of fractions"""

PartialExifException

Bases: Exception

Exception for partial / missing EXIF information from image

Contains a PartialGeoPicTags with all tags that have been read and the list of missing tags

Source code in geopic_tag_reader/reader.py
class PartialExifException(Exception):
    """
    Exception for partial / missing EXIF information from image

    Contains a PartialGeoPicTags with all tags that have been read and the list of missing tags
    """

    def __init__(self, msg, missing_mandatory_tags: Set[str], partial_tags: PartialGeoPicTags):
        super().__init__(msg)
        self.missing_mandatory_tags = missing_mandatory_tags
        self.tags = partial_tags

PartialGeoPicTags dataclass

Tags associated to a geolocated picture when not all tags have been found

Implementation note: this needs to be sync with the GeoPicTags structure

Source code in geopic_tag_reader/reader.py
@dataclass
class PartialGeoPicTags:
    """Tags associated to a geolocated picture when not all tags have been found

    Implementation note: this needs to be sync with the GeoPicTags structure
    """

    lat: Optional[float] = None
    lon: Optional[float] = None
    ts: Optional[datetime.datetime] = None
    heading: Optional[int] = None
    type: Optional[str] = None
    make: Optional[str] = None
    model: Optional[str] = None
    focal_length: Optional[float] = None
    crop: Optional[CropValues] = None
    exif: Dict[str, str] = field(default_factory=lambda: {})
    tagreader_warnings: List[str] = field(default_factory=lambda: [])
    altitude: Optional[float] = None
    pitch: Optional[float] = None
    roll: Optional[float] = None
    yaw: Optional[float] = None
    ts_by_source: Optional[TimeBySource] = None

TimeBySource dataclass

All datetimes read from available sources

Attributes:

Name Type Description
gps datetime

Time read from GPS clock

camera datetime

Time read from camera clock (DateTimeOriginal)

Source code in geopic_tag_reader/reader.py
@dataclass
class TimeBySource:
    """All datetimes read from available sources

    Attributes:
        gps (datetime): Time read from GPS clock
        camera (datetime): Time read from camera clock (DateTimeOriginal)
    """

    gps: Optional[datetime.datetime] = None
    camera: Optional[datetime.datetime] = None

    def getBest(self) -> Optional[datetime.datetime]:
        """Get the best available datetime to use"""
        if self.gps is not None and self.camera is None:
            return self.gps
        elif self.gps is None and self.camera is not None:
            return self.camera
        elif self.gps is None and self.camera is None:
            return None
        elif self.camera.microsecond > 0 and self.gps.microsecond == 0:  # type: ignore
            return self.camera
        else:
            return self.gps

getBest()

Get the best available datetime to use

Source code in geopic_tag_reader/reader.py
def getBest(self) -> Optional[datetime.datetime]:
    """Get the best available datetime to use"""
    if self.gps is not None and self.camera is None:
        return self.gps
    elif self.gps is None and self.camera is not None:
        return self.camera
    elif self.gps is None and self.camera is None:
        return None
    elif self.camera.microsecond > 0 and self.gps.microsecond == 0:  # type: ignore
        return self.camera
    else:
        return self.gps

decodeLatLon(data, group, _)

Reads GPS info from given group to get latitude/longitude as float coordinates

Source code in geopic_tag_reader/reader.py
def decodeLatLon(data: dict, group: str, _: Callable[[str], str]) -> Tuple[Optional[float], Optional[float], List[str]]:
    """Reads GPS info from given group to get latitude/longitude as float coordinates"""

    lat, lon = None, None
    warnings = []

    if isExifTagUsable(data, f"{group}.GPSLatitude", List[Fraction]) and isExifTagUsable(data, f"{group}.GPSLongitude", List[Fraction]):
        latRaw = decodeManyFractions(data[f"{group}.GPSLatitude"])
        if len(latRaw) == 3:
            if not isExifTagUsable(data, f"{group}.GPSLatitudeRef"):
                warnings.append(_("GPSLatitudeRef not found, assuming GPSLatitudeRef is North"))
                latRef = 1
            else:
                latRef = -1 if data[f"{group}.GPSLatitudeRef"].startswith("S") else 1
            lat = latRef * (float(latRaw[0]) + float(latRaw[1]) / 60 + float(latRaw[2]) / 3600)

            lonRaw = decodeManyFractions(data[f"{group}.GPSLongitude"])
            if len(lonRaw) != 3:
                raise InvalidExifException(_("Broken GPS coordinates in picture EXIF tags"))

            if not isExifTagUsable(data, f"{group}.GPSLongitudeRef"):
                warnings.append(_("GPSLongitudeRef not found, assuming GPSLongitudeRef is East"))
                lonRef = 1
            else:
                lonRef = -1 if data[f"{group}.GPSLongitudeRef"].startswith("W") else 1
            lon = lonRef * (float(lonRaw[0]) + float(lonRaw[1]) / 60 + float(lonRaw[2]) / 3600)

    if lat is None and lon is None:
        rawLat, rawLon = None, None
        if isExifTagUsable(data, f"{group}.GPSLatitude", float) and isExifTagUsable(data, f"{group}.GPSLongitude", float):
            rawLat = float(data[f"{group}.GPSLatitude"])
            rawLon = float(data[f"{group}.GPSLongitude"])
        elif isExifTagUsable(data, f"{group}.GPSLatitude", Fraction) and isExifTagUsable(data, f"{group}.GPSLongitude", Fraction):
            rawLat = float(Fraction(data[f"{group}.GPSLatitude"]))
            rawLon = float(Fraction(data[f"{group}.GPSLongitude"]))

        if rawLat and rawLon:
            latRef = 1
            if not isExifTagUsable(data, f"{group}.GPSLatitudeRef"):
                warnings.append(_("GPSLatitudeRef not found, assuming GPSLatitudeRef is North"))
            else:
                latRef = -1 if data[f"{group}.GPSLatitudeRef"].startswith("S") else 1

            lonRef = 1
            if not isExifTagUsable(data, f"{group}.GPSLongitudeRef"):
                warnings.append(_("GPSLongitudeRef not found, assuming GPSLongitudeRef is East"))
            else:
                lonRef = -1 if data[f"{group}.GPSLongitudeRef"].startswith("W") else 1

            lat = latRef * rawLat
            lon = lonRef * rawLon

    return (lat, lon, warnings)

decodeMakeModel(value)

Python 2/3 compatible decoding of make/model field.

Source code in geopic_tag_reader/reader.py
def decodeMakeModel(value) -> str:
    """Python 2/3 compatible decoding of make/model field."""
    if hasattr(value, "decode"):
        try:
            return value.decode("utf-8").replace("\x00", "")
        except UnicodeDecodeError:
            return value
    else:
        return value.replace("\x00", "")

decodeManyFractions(value)

Try to decode a list of fractions, separated by spaces

Source code in geopic_tag_reader/reader.py
def decodeManyFractions(value: str) -> List[Fraction]:
    """Try to decode a list of fractions, separated by spaces"""

    try:
        vals = [Fraction(v.strip()) for v in value.split(" ")]
        if len([True for v in vals if v.denominator == 0]) > 0:
            raise InvalidFractionException()
        return vals

    except:
        raise InvalidFractionException()

isExifTagUsable(exif, tag, expectedType=str)

Is a given EXIF tag usable (not null and not an empty string)

Parameters:

Name Type Description Default
exif dict

The EXIF tags

required
tag str

The tag to check

required
expectedType class

The expected data type

str

Returns:

Name Type Description
bool bool

True if not empty

Source code in geopic_tag_reader/reader.py
def isExifTagUsable(exif, tag, expectedType: Any = str) -> bool:
    """Is a given EXIF tag usable (not null and not an empty string)

    Args:
        exif (dict): The EXIF tags
        tag (str): The tag to check
        expectedType (class): The expected data type

    Returns:
        bool: True if not empty
    """

    try:
        if not tag in exif:
            return False
        elif expectedType == List[Fraction]:
            return isValidManyFractions(exif[tag])
        elif expectedType == Fraction:
            try:
                Fraction(exif[tag])
                return True
            except:
                return False
        elif expectedType == datetime.time:
            try:
                datetime.time.fromisoformat(exif[tag])
                return True
            except:
                return False
        elif expectedType == datetime.tzinfo:
            try:
                datetime.datetime.fromisoformat(f"2020-01-01T00:00:00{exif[tag]}")
                return True
            except:
                return False
        elif not (expectedType in [float, int] or isinstance(exif[tag], expectedType)):
            return False
        elif not (expectedType != str or len(exif[tag].strip().replace("\x00", "")) > 0):
            return False
        elif not (expectedType not in [float, int] or float(exif[tag]) is not None):
            return False
        else:
            return True
    except ValueError:
        return False

readPictureMetadata(picture, lang_code='en')

Extracts metadata from picture file

Parameters:

Name Type Description Default
picture bytes

Picture file

required
lang_code str

Language code for translating error labels

'en'

Returns:

Name Type Description
GeoPicTags GeoPicTags

Extracted metadata from picture

Source code in geopic_tag_reader/reader.py
def readPictureMetadata(picture: bytes, lang_code: str = "en") -> GeoPicTags:
    """Extracts metadata from picture file

    Args:
        picture (bytes): Picture file
        lang_code (str): Language code for translating error labels

    Returns:
        GeoPicTags: Extracted metadata from picture
    """

    _ = i18n_init(lang_code)
    warnings = []
    img = pyexiv2.ImageData(picture)
    data = {}
    data.update(img.read_exif())
    data.update(img.read_iptc())
    data.update(img.read_xmp())
    width = img.get_pixel_width()
    height = img.get_pixel_height()

    imgComment = img.read_comment()
    if imgComment is not None and len(imgComment.strip()) > 0:
        data["Exif.Photo.UserComment"] = imgComment
    img.close()

    # Read Mapillary tags
    if "Exif.Image.ImageDescription" in data:
        # Check if data can be read
        imgDesc = data["Exif.Image.ImageDescription"]
        try:
            imgDescJson = json.loads(imgDesc)
            data.update(imgDescJson)
        except:
            pass

    # Sanitize charset information
    for k, v in data.items():
        if isinstance(v, str):
            data[k] = re.sub(r"charset=[^\s]+", "", v).strip()

    # Parse latitude/longitude
    lat, lon, llw = decodeLatLon(data, "Exif.GPSInfo", _)
    if len(llw) > 0:
        warnings.extend(llw)

    if lat is None:
        lat, lon, llw = decodeLatLon(data, "Xmp.exif", _)
        if len(llw) > 0:
            warnings.extend(llw)

    if lat is None and isExifTagUsable(data, "MAPLatitude", float) and isExifTagUsable(data, "MAPLongitude", float):
        lat = float(data["MAPLatitude"])
        lon = float(data["MAPLongitude"])

    # Check coordinates validity
    if lat is not None and (lat < -90 or lat > 90):
        raise InvalidExifException(_("Read latitude is out of WGS84 bounds (should be in [-90, 90])"))
    if lon is not None and (lon < -180 or lon > 180):
        raise InvalidExifException(_("Read longitude is out of WGS84 bounds (should be in [-180, 180])"))

    # Parse GPS date/time
    gpsTs, llw = decodeGPSDateTime(data, "Exif.GPSInfo", _, lat, lon)

    if len(llw) > 0:
        warnings.extend(llw)

    if gpsTs is None:
        gpsTs, llw = decodeGPSDateTime(data, "Xmp.exif", _, lat, lon)
        if len(llw) > 0:
            warnings.extend(llw)

    if gpsTs is None and isExifTagUsable(data, "MAPGpsTime"):
        try:
            year, month, day, hour, minutes, seconds, milliseconds = [int(dp) for dp in data["MAPGpsTime"].split("_")]
            gpsTs = datetime.datetime(
                year,
                month,
                day,
                hour,
                minutes,
                seconds,
                milliseconds * 1000,
                tzinfo=datetime.timezone.utc,
            )

        except Exception as e:
            warnings.append(_("Skipping Mapillary date/time as it was not recognized: {v}").format(v=data["MAPGpsTime"]))

    # Parse camera date/time
    cameraTs = None
    for exifGroup, dtField, subsecField in [
        ("Exif.Photo", "DateTimeOriginal", "SubSecTimeOriginal"),
        ("Exif.Image", "DateTimeOriginal", "SubSecTimeOriginal"),
        ("Exif.Image", "DateTime", "SubSecTimeOriginal"),
        ("Xmp.GPano", "SourceImageCreateTime", "SubSecTimeOriginal"),
        ("Xmp.exif", "DateTimeOriginal", "SubsecTimeOriginal"),  # Case matters
    ]:
        if cameraTs is None:
            cameraTs, llw = decodeDateTimeOriginal(data, exifGroup, dtField, subsecField, _, lat, lon)
            if len(llw) > 0:
                warnings.extend(llw)

        if cameraTs is not None:
            break
    tsSources = TimeBySource(gps=gpsTs, camera=cameraTs) if gpsTs or cameraTs else None
    d = tsSources.getBest() if tsSources is not None else None

    # GPS Heading
    heading = None
    if isExifTagUsable(data, "Exif.GPSInfo.GPSImgDirection", Fraction):
        heading = int(round(float(Fraction(data["Exif.GPSInfo.GPSImgDirection"]))))

    elif "MAPCompassHeading" in data and isExifTagUsable(data["MAPCompassHeading"], "TrueHeading", float):
        heading = int(round(float(data["MAPCompassHeading"]["TrueHeading"])))

    # Yaw / Pitch / roll
    yaw = None
    pitch = None
    roll = None
    exifYPRFields = {
        "yaw": ["Xmp.Camera.Yaw", "Xmp.GPano.PoseHeadingDegrees"],
        "pitch": ["Xmp.Camera.Pitch", "Xmp.GPano.PosePitchDegrees"],
        "roll": ["Xmp.Camera.Roll", "Xmp.GPano.PoseRollDegrees"],
    }
    for ypr in exifYPRFields:
        for exifTag in exifYPRFields[ypr]:
            foundValue = None
            # Look for float or fraction
            if isExifTagUsable(data, exifTag, float):
                foundValue = float(data[exifTag])
            elif isExifTagUsable(data, exifTag, Fraction):
                foundValue = float(Fraction(data[exifTag]))

            # Save found value
            if foundValue is not None:
                if ypr == "yaw" and yaw is None:
                    yaw = foundValue
                elif ypr == "pitch" and pitch is None:
                    pitch = foundValue
                elif ypr == "roll" and roll is None:
                    roll = foundValue

    # Make and model
    make = data.get("Exif.Image.Make") or data.get("MAPDeviceMake")
    model = data.get("Exif.Image.Model") or data.get("MAPDeviceModel")

    if make is not None:
        make = decodeMakeModel(make).strip()

    if model is not None:
        model = decodeMakeModel(model).strip()

    if make is not None and model is not None and model.startswith(make) and len(model) > len(make):
        model = model.replace(make, "").strip()

    # Focal length
    focalLength = None
    if isExifTagUsable(data, "Exif.Image.FocalLength", Fraction):
        focalLength = float(Fraction(data["Exif.Image.FocalLength"]))
    elif isExifTagUsable(data, "Exif.Photo.FocalLength", Fraction):
        focalLength = float(Fraction(data["Exif.Photo.FocalLength"]))

    # Cropped pano data
    crop = None
    if (
        isExifTagUsable(data, "Xmp.GPano.FullPanoWidthPixels", int)
        and isExifTagUsable(data, "Xmp.GPano.FullPanoHeightPixels", int)
        and isExifTagUsable(data, "Xmp.GPano.CroppedAreaImageWidthPixels", int)
        and isExifTagUsable(data, "Xmp.GPano.CroppedAreaImageHeightPixels", int)
        and isExifTagUsable(data, "Xmp.GPano.CroppedAreaLeftPixels", int)
        and isExifTagUsable(data, "Xmp.GPano.CroppedAreaTopPixels", int)
    ):
        fw = int(data["Xmp.GPano.FullPanoWidthPixels"])
        fh = int(data["Xmp.GPano.FullPanoHeightPixels"])
        w = int(data["Xmp.GPano.CroppedAreaImageWidthPixels"])
        h = int(data["Xmp.GPano.CroppedAreaImageHeightPixels"])
        l = int(data["Xmp.GPano.CroppedAreaLeftPixels"])
        t = int(data["Xmp.GPano.CroppedAreaTopPixels"])

        if fw > w or fh > h:
            crop = CropValues(fw, fh, w, h, l, t)

    elif (
        isExifTagUsable(data, "Xmp.GPano.CroppedAreaImageWidthPixels", int)
        or isExifTagUsable(data, "Xmp.GPano.CroppedAreaImageHeightPixels", int)
        or isExifTagUsable(data, "Xmp.GPano.CroppedAreaLeftPixels", int)
        or isExifTagUsable(data, "Xmp.GPano.CroppedAreaTopPixels", int)
    ):
        raise InvalidExifException("EXIF tags contain partial cropped area metadata")

    # Type
    pic_type = None
    # 360° based on GPano EXIF tag
    if isExifTagUsable(data, "Xmp.GPano.ProjectionType"):
        pic_type = data["Xmp.GPano.ProjectionType"]
    # 360° based on known models
    elif camera.is_360(make, model, width, height):
        pic_type = "equirectangular"
    # Flat by default
    else:
        pic_type = "flat"

    # Altitude
    altitude = None
    if isExifTagUsable(data, "Exif.GPSInfo.GPSAltitude", Fraction):
        altitude_raw = int(round(float(Fraction(data["Exif.GPSInfo.GPSAltitude"]))))
        ref = -1 if data.get("Exif.GPSInfo.GPSAltitudeRef") == "1" else 1
        altitude = altitude_raw * ref

    errors = []
    missing_fields = set()
    if not lat or not lon:
        errors.append(_("No GPS coordinates or broken coordinates in picture EXIF tags"))
        if not lat:
            missing_fields.add("lat")
        if not lon:
            missing_fields.add("lon")
    if d is None:
        errors.append(_("No valid date in picture EXIF tags"))
        missing_fields.add("datetime")

    if errors:
        if len(errors) > 1:
            listOfErrors = _("The picture is missing mandatory metadata:")
            errorSep = "\n\t- "
            listOfErrors += errorSep + errorSep.join(errors)
        else:
            listOfErrors = errors[0]

        raise PartialExifException(
            listOfErrors,
            missing_fields,
            PartialGeoPicTags(
                lat,
                lon,
                d,
                heading,
                pic_type,
                make,
                model,
                focalLength,
                crop,
                exif=data,
                tagreader_warnings=warnings,
                altitude=altitude,
                pitch=pitch,
                roll=roll,
                yaw=yaw,
                ts_by_source=tsSources,
            ),
        )

    assert lon and lat and d  # at this point all those fields cannot be null
    return GeoPicTags(
        lat,
        lon,
        d,
        heading,
        pic_type,
        make,
        model,
        focalLength,
        crop,
        exif=data,
        tagreader_warnings=warnings,
        altitude=altitude,
        pitch=pitch,
        roll=roll,
        yaw=yaw,
        ts_by_source=tsSources,
    )

Writer

Warning

To use this module, you need to install the write-exif dependency:

pip install -e .[write-exif]

DirectionRef

Bases: str, Enum

Indicates the reference for giving the direction of the image when it is captured.

Source code in geopic_tag_reader/writer.py
class DirectionRef(str, Enum):
    """Indicates the reference for giving the direction of the image when it is captured."""

    magnetic_north = "M"
    true_north = "T"

UnsupportedExifTagException

Bases: Exception

Exception for invalid key in additional tags

Source code in geopic_tag_reader/writer.py
class UnsupportedExifTagException(Exception):
    """Exception for invalid key in additional tags"""

    def __init__(self, msg):
        super().__init__(msg)

format_offset(offset)

Format offset for OffsetTimeOriginal. Format is like "+02:00" for paris offset

format_offset(timedelta(hours=5, minutes=45)) '+05:45' format_offset(timedelta(hours=-3)) '-03:00'

Source code in geopic_tag_reader/writer.py
def format_offset(offset: Optional[timedelta]) -> str:
    """Format offset for OffsetTimeOriginal. Format is like "+02:00" for paris offset
    >>> format_offset(timedelta(hours=5, minutes=45))
    '+05:45'
    >>> format_offset(timedelta(hours=-3))
    '-03:00'
    """
    if offset is None:
        return "+00:00"
    offset_hour, remainer = divmod(offset.total_seconds(), 3600)
    return f"{'+' if offset_hour >= 0 else '-'}{int(abs(offset_hour)):02}:{int(remainer/60):02}"

localize_capture_time(metadata, img_metadata)

Localize a datetime in the timezone of the picture If the picture does not contains GPS position, the datetime will not be modified.

Source code in geopic_tag_reader/writer.py
def localize_capture_time(metadata: PictureMetadata, img_metadata: pyexiv2.ImageData) -> datetime:
    """
    Localize a datetime in the timezone of the picture
    If the picture does not contains GPS position, the datetime will not be modified.
    """
    assert metadata.capture_time
    dt = metadata.capture_time

    if metadata.longitude is not None and metadata.latitude is not None:
        # if the coord have been overrided, read it instead of the picture's
        lon = metadata.longitude
        lat = metadata.latitude
    else:
        exif = img_metadata.read_exif()
        try:
            raw_lon = exif["Exif.GPSInfo.GPSLongitude"]
            lon_ref = exif.get("Exif.GPSInfo.GPSLongitudeRef", "E")
            raw_lat = exif["Exif.GPSInfo.GPSLatitude"]
            lat_ref = exif.get("Exif.GPSInfo.GPSLatitudeRef", "N")
            lon = _from_dms(raw_lon) * (1 if lon_ref == "E" else -1)
            lat = _from_dms(raw_lat) * (1 if lat_ref == "N" else -1)
        except KeyError:
            return metadata.capture_time  # canot localize, returning same date

    if not lon or not lat:
        return dt  # canot localize, returning same date

    tz_name = tz_finder.timezone_at(lng=lon, lat=lat)
    if not tz_name:
        return dt  # cannot find timezone, returning same date

    tz = pytz.timezone(tz_name)

    return tz.localize(dt)

writePictureMetadata(picture, metadata, lang_code='en')

Override exif metadata on raw picture and return updated bytes

Source code in geopic_tag_reader/writer.py
def writePictureMetadata(picture: bytes, metadata: PictureMetadata, lang_code: str = "en") -> bytes:
    """
    Override exif metadata on raw picture and return updated bytes
    """

    _ = i18n_init(lang_code)

    if not metadata.has_change():
        return picture

    img = pyexiv2.ImageData(picture)

    updated_exif: Dict[str, Any] = {}
    updated_xmp: Dict[str, Any] = {}

    if metadata.capture_time:
        if metadata.capture_time.tzinfo is None:
            metadata.capture_time = localize_capture_time(metadata, img)
        updated_exif.update(_date_exif_tags(metadata.capture_time))

    if metadata.latitude is not None:
        updated_exif["Exif.GPSInfo.GPSLatitudeRef"] = "N" if metadata.latitude > 0 else "S"
        updated_exif["Exif.GPSInfo.GPSLatitude"] = _to_exif_dms(metadata.latitude)

    if metadata.longitude is not None:
        updated_exif["Exif.GPSInfo.GPSLongitudeRef"] = "E" if metadata.longitude > 0 else "W"
        updated_exif["Exif.GPSInfo.GPSLongitude"] = _to_exif_dms(metadata.longitude)

    if metadata.picture_type is not None:
        if metadata.picture_type == PictureType.equirectangular:
            # only add GPano tags for equirectangular pictures
            updated_xmp["Xmp.GPano.ProjectionType"] = metadata.picture_type.value
            updated_xmp["Xmp.GPano.UsePanoramaViewer"] = True
        else:
            # remove GPano tags for flat picture
            updated_xmp["Xmp.GPano.ProjectionType"] = None
            updated_xmp["Xmp.GPano.UsePanoramaViewer"] = None

    if metadata.altitude is not None:
        updated_exif["Exif.GPSInfo.GPSAltitude"] = _fraction(abs(metadata.altitude))
        updated_exif["Exif.GPSInfo.GPSAltitudeRef"] = 0 if metadata.altitude >= 0 else 1

    if metadata.direction is not None:
        direction = _fraction(abs(metadata.direction.value % 360.0))
        updated_exif["Exif.GPSInfo.GPSImgDirection"] = direction
        updated_exif["Exif.GPSInfo.GPSImgDirectionRef"] = metadata.direction.ref.value
        # also write GPano tag
        updated_xmp["Xmp.GPano.PoseHeadingDegrees"] = direction

    if metadata.additional_exif:
        for k, v in metadata.additional_exif.items():
            if k.startswith("Xmp."):
                updated_xmp.update({k: v})
            elif k.startswith("Exif."):
                updated_exif.update({k: v})
            else:
                raise UnsupportedExifTagException(_("Unsupported key in additional tags ({k})").format(k=k))

    if updated_exif:
        img.modify_exif(updated_exif)
    if updated_xmp:
        img.modify_xmp(updated_xmp)

    return img.get_bytes()

Sequence

Picture dataclass

Source code in geopic_tag_reader/sequence.py
@dataclass
class Picture:
    filename: str
    metadata: GeoPicTags

    def distance_to(self, other) -> float:
        """Computes distance in meters based on Haversine formula"""
        R = 6371000
        phi1 = math.radians(self.metadata.lat)
        phi2 = math.radians(other.metadata.lat)
        delta_phi = math.radians(other.metadata.lat - self.metadata.lat)
        delta_lambda = math.radians(other.metadata.lon - self.metadata.lon)
        a = math.sin(delta_phi / 2.0) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2.0) ** 2
        c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
        distance = R * c
        return distance

distance_to(other)

Computes distance in meters based on Haversine formula

Source code in geopic_tag_reader/sequence.py
def distance_to(self, other) -> float:
    """Computes distance in meters based on Haversine formula"""
    R = 6371000
    phi1 = math.radians(self.metadata.lat)
    phi2 = math.radians(other.metadata.lat)
    delta_phi = math.radians(other.metadata.lat - self.metadata.lat)
    delta_lambda = math.radians(other.metadata.lon - self.metadata.lon)
    a = math.sin(delta_phi / 2.0) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2.0) ** 2
    c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
    distance = R * c
    return distance

Sequence dataclass

Source code in geopic_tag_reader/sequence.py
@dataclass
class Sequence:
    pictures: List[Picture]

    def from_ts(self) -> Optional[datetime.datetime]:
        """Start date/time of this sequence"""

        if len(self.pictures) == 0:
            return None
        return self.pictures[0].metadata.ts

    def to_ts(self) -> Optional[datetime.datetime]:
        """End date/time of this sequence"""

        if len(self.pictures) == 0:
            return None
        return self.pictures[-1].metadata.ts

    def delta_with(self, otherSeq) -> Optional[Tuple[datetime.timedelta, float]]:
        """
        Delta between the end of this sequence and the start of the other one.
        Returns a tuple (timedelta, distance in meters)
        """

        if len(self.pictures) == 0 or len(otherSeq.pictures) == 0:
            return None

        return (otherSeq.from_ts() - self.to_ts(), otherSeq.pictures[0].distance_to(self.pictures[-1]))  # type: ignore

delta_with(otherSeq)

Delta between the end of this sequence and the start of the other one. Returns a tuple (timedelta, distance in meters)

Source code in geopic_tag_reader/sequence.py
def delta_with(self, otherSeq) -> Optional[Tuple[datetime.timedelta, float]]:
    """
    Delta between the end of this sequence and the start of the other one.
    Returns a tuple (timedelta, distance in meters)
    """

    if len(self.pictures) == 0 or len(otherSeq.pictures) == 0:
        return None

    return (otherSeq.from_ts() - self.to_ts(), otherSeq.pictures[0].distance_to(self.pictures[-1]))  # type: ignore

from_ts()

Start date/time of this sequence

Source code in geopic_tag_reader/sequence.py
def from_ts(self) -> Optional[datetime.datetime]:
    """Start date/time of this sequence"""

    if len(self.pictures) == 0:
        return None
    return self.pictures[0].metadata.ts

to_ts()

End date/time of this sequence

Source code in geopic_tag_reader/sequence.py
def to_ts(self) -> Optional[datetime.datetime]:
    """End date/time of this sequence"""

    if len(self.pictures) == 0:
        return None
    return self.pictures[-1].metadata.ts

dispatch_pictures(pictures, sortMethod=None, mergeParams=None, splitParams=None)

Dispatches a set of pictures into many sequences. This function both sorts, de-duplicates and splits in sequences all your pictures.

Parameters

pictures : set of pictures to dispatch sortMethod : strategy for sorting pictures mergeParams : conditions for considering two pictures as duplicates splitParams : conditions for considering two sequences as distinct

Returns

DispatchReport : clean sequences, duplicates pictures and split reasons

Source code in geopic_tag_reader/sequence.py
def dispatch_pictures(
    pictures: List[Picture],
    sortMethod: Optional[SortMethod] = None,
    mergeParams: Optional[MergeParams] = None,
    splitParams: Optional[SplitParams] = None,
) -> DispatchReport:
    """
    Dispatches a set of pictures into many sequences.
    This function both sorts, de-duplicates and splits in sequences all your pictures.

    Parameters
    ----------
    pictures : set of pictures to dispatch
    sortMethod : strategy for sorting pictures
    mergeParams : conditions for considering two pictures as duplicates
    splitParams : conditions for considering two sequences as distinct

    Returns
    -------
    DispatchReport : clean sequences, duplicates pictures and split reasons
    """

    # Sort
    myPics = sort_pictures(pictures, sortMethod)

    # De-duplicate
    (myPics, dupsPics) = find_duplicates(myPics, mergeParams)

    # Split in sequences
    (mySeqs, splits) = split_in_sequences(myPics, splitParams)

    return DispatchReport(mySeqs, dupsPics if len(dupsPics) > 0 else None, splits if len(splits) > 0 else None)

find_duplicates(pictures, params=None)

Finds too similar pictures. Note that input list should be properly sorted.

Parameters

pictures : list of sorted pictures to check params : parameters used to consider two pictures as a duplicate

Returns

(Non-duplicates pictures, Duplicates pictures)

Source code in geopic_tag_reader/sequence.py
def find_duplicates(pictures: List[Picture], params: Optional[MergeParams] = None) -> Tuple[List[Picture], List[Picture]]:
    """
    Finds too similar pictures.
    Note that input list should be properly sorted.

    Parameters
    ----------
    pictures : list of sorted pictures to check
    params : parameters used to consider two pictures as a duplicate

    Returns
    -------
    (Non-duplicates pictures, Duplicates pictures)
    """

    if params is None or not params.is_merge_needed():
        return (pictures, [])

    nonDups: List[Picture] = []
    dups: List[Picture] = []
    lastNonDuplicatedPicId = 0

    for i, currentPic in enumerate(pictures):
        if i == 0:
            nonDups.append(currentPic)
            continue

        prevPic = pictures[lastNonDuplicatedPicId]

        if prevPic.metadata is None or currentPic.metadata is None:
            nonDups.append(currentPic)
            continue

        # Compare distance
        dist = prevPic.distance_to(currentPic)

        if dist <= params.maxDistance:  # type: ignore
            # Compare angle (if available on both images)
            if params.maxRotationAngle is not None and prevPic.metadata.heading is not None and currentPic.metadata.heading is not None:
                deltaAngle = abs(currentPic.metadata.heading - prevPic.metadata.heading)

                if deltaAngle <= params.maxRotationAngle:
                    dups.append(currentPic)
                else:
                    lastNonDuplicatedPicId = i
                    nonDups.append(currentPic)
            else:
                dups.append(currentPic)
        else:
            lastNonDuplicatedPicId = i
            nonDups.append(currentPic)

    return (nonDups, dups)

sort_pictures(pictures, method=SortMethod.time_asc)

Sorts pictures according to given strategy

Parameters

pictures : Picture[] List of pictures to sort method : SortMethod Sort logic to adopt (time-asc, time-desc, filename-asc, filename-desc)

Returns

Picture[] List of pictures, sorted

Source code in geopic_tag_reader/sequence.py
def sort_pictures(pictures: List[Picture], method: Optional[SortMethod] = SortMethod.time_asc) -> List[Picture]:
    """Sorts pictures according to given strategy

    Parameters
    ----------
    pictures : Picture[]
        List of pictures to sort
    method : SortMethod
        Sort logic to adopt (time-asc, time-desc, filename-asc, filename-desc)

    Returns
    -------
    Picture[]
        List of pictures, sorted
    """

    if method is None:
        method = SortMethod.time_asc

    if method not in [item.value for item in SortMethod]:
        raise Exception("Invalid sort strategy: " + str(method))

    # Get the sort logic
    strat, order = method.split("-")

    # Sort based on filename
    if strat == "filename":
        # Check if pictures can be sorted by numeric notation
        hasNonNumber = False
        for p in pictures:
            try:
                int(PurePath(p.filename or "").stem)
            except:
                hasNonNumber = True
                break

        def sort_fct(p):
            return PurePath(p.filename or "").stem if hasNonNumber else int(PurePath(p.filename or "").stem)

        pictures.sort(key=sort_fct)

    # Sort based on picture ts
    elif strat == "time":
        # Check if all pictures have GPS ts set
        missingGpsTs = next(
            (p for p in pictures if p.metadata is None or p.metadata.ts_by_source is None or p.metadata.ts_by_source.gps is None), None
        )
        if missingGpsTs:
            # Check if all pictures have camera ts set
            missingCamTs = next(
                (p for p in pictures if p.metadata is None or p.metadata.ts_by_source is None or p.metadata.ts_by_source.camera is None),
                None,
            )
            # Sort by best ts available
            if missingCamTs:
                pictures.sort(key=lambda p: p.metadata.ts.isoformat() if p.metadata is not None else "0000-00-00T00:00:00Z")
            # Sort by camera ts
            else:
                pictures.sort(
                    key=lambda p: (
                        p.metadata.ts_by_source.camera.isoformat(),  # type: ignore
                        p.metadata.ts_by_source.gps.isoformat() if p.metadata.ts_by_source.gps else "0000-00-00T00:00:00Z",  # type: ignore
                    )
                )
        # Sort by GPS ts
        else:
            pictures.sort(
                key=lambda p: (
                    p.metadata.ts_by_source.gps.isoformat(),  # type: ignore
                    p.metadata.ts_by_source.camera.isoformat() if p.metadata.ts_by_source.camera else "0000-00-00T00:00:00Z",  # type: ignore
                )
            )

    if order == "desc":
        pictures.reverse()

    return pictures

split_in_sequences(pictures, splitParams=SplitParams())

Split a list of pictures into many sequences. Note that this function expect pictures to be sorted and have their metadata set.

Parameters

pictures : Picture[] List of pictures to check, sorted and with metadata defined splitParams : SplitParams The parameters to define deltas between two pictures

Returns

List[Sequence] List of pictures splitted into smaller sequences

Source code in geopic_tag_reader/sequence.py
def split_in_sequences(pictures: List[Picture], splitParams: Optional[SplitParams] = SplitParams()) -> Tuple[List[Sequence], List[Split]]:
    """
    Split a list of pictures into many sequences.
    Note that this function expect pictures to be sorted and have their metadata set.

    Parameters
    ----------
    pictures : Picture[]
            List of pictures to check, sorted and with metadata defined
    splitParams : SplitParams
            The parameters to define deltas between two pictures

    Returns
    -------
    List[Sequence]
            List of pictures splitted into smaller sequences
    """

    # No split parameters given -> just return given pictures
    if splitParams is None or not splitParams.is_split_needed():
        return ([Sequence(pictures)], [])

    sequences: List[Sequence] = []
    splits: List[Split] = []
    currentPicList: List[Picture] = []

    for pic in pictures:
        if len(currentPicList) == 0:  # No checks for 1st pic
            currentPicList.append(pic)
        else:
            lastPic = currentPicList[-1]

            # Missing metadata -> skip
            if lastPic.metadata is None or pic.metadata is None:
                currentPicList.append(pic)
                continue

            # Time delta
            timeDelta = lastPic.metadata.ts - pic.metadata.ts
            if (
                lastPic.metadata.ts_by_source
                and lastPic.metadata.ts_by_source.gps
                and pic.metadata.ts_by_source
                and pic.metadata.ts_by_source.gps
            ):
                timeDelta = lastPic.metadata.ts_by_source.gps - pic.metadata.ts_by_source.gps
            elif (
                lastPic.metadata.ts_by_source
                and lastPic.metadata.ts_by_source.camera
                and pic.metadata.ts_by_source
                and pic.metadata.ts_by_source.camera
            ):
                timeDelta = lastPic.metadata.ts_by_source.camera - pic.metadata.ts_by_source.camera
            timeOutOfDelta = False if splitParams.maxTime is None else (abs(timeDelta)).total_seconds() > splitParams.maxTime

            # Distance delta
            distance = lastPic.distance_to(pic)
            distanceOutOfDelta = False if splitParams.maxDistance is None else distance > splitParams.maxDistance

            # One of deltas maxed -> create new sequence
            if timeOutOfDelta or distanceOutOfDelta:
                sequences.append(Sequence(currentPicList))
                currentPicList = [pic]
                splits.append(Split(lastPic, pic, SplitReason.time if timeOutOfDelta else SplitReason.distance))

            # Otherwise, still in same sequence
            else:
                currentPicList.append(pic)

    sequences.append(Sequence(currentPicList))

    return (sequences, splits)

Camera

is_360(make=None, model=None, width=None, height=None)

Checks if given camera is equirectangular (360°) based on its make, model and dimensions (width, height).

is_360() False is_360("GoPro") False is_360("GoPro", "Max 360") True is_360("GoPro", "Max 360", "2048", "1024") True is_360("GoPro", "Max 360", "1024", "768") False is_360("RICOH", "THETA S", "5376", "2688") True

Source code in geopic_tag_reader/camera.py
def is_360(make: Optional[str] = None, model: Optional[str] = None, width: Optional[str] = None, height: Optional[str] = None) -> bool:
    """
    Checks if given camera is equirectangular (360°) based on its make, model and dimensions (width, height).

    >>> is_360()
    False
    >>> is_360("GoPro")
    False
    >>> is_360("GoPro", "Max 360")
    True
    >>> is_360("GoPro", "Max 360", "2048", "1024")
    True
    >>> is_360("GoPro", "Max 360", "1024", "768")
    False
    >>> is_360("RICOH", "THETA S", "5376", "2688")
    True
    """

    # Check make and model are defined
    if not make or not model:
        return False

    # Check width and height are equirectangular
    if not ((width is None or height is None) or int(width) == 2 * int(height)):
        return False

    # Find make
    matchMake = next((m for m in EQUIRECTANGULAR_MODELS.keys() if make.lower() == m.lower()), None)
    if matchMake is None:
        return False

    # Find model
    return any(model.lower().startswith(m.lower()) for m in EQUIRECTANGULAR_MODELS[matchMake])

Model