Source code for madam.exif
import datetime
import io
import shutil
import tempfile
from collections.abc import Callable, Iterable, Mapping
from fractions import Fraction
from typing import IO, Any
import piexif
from bidict import bidict
from madam.core import MetadataProcessor, UnsupportedFormatError
from madam.mime import MimeType
def _convert_sequence(dec_enc: tuple[Callable, Callable]) -> tuple[Callable, Callable]:
return lambda exif_values: tuple(map(dec_enc[0], exif_values)), lambda values: list(map(dec_enc[1], values))
def _convert_first(dec_enc: tuple[Callable, Callable]) -> tuple[Callable, Callable]:
return lambda exif_values: dec_enc[0](exif_values[0]), lambda value: [dec_enc[1](value)]
def _convert_mapping(mapping: Mapping) -> tuple[Callable, Callable]:
bidi = bidict(mapping)
return lambda exif_value: bidi[exif_value], lambda value: bidi.inv[value]
[docs]
class ExifMetadataProcessor(MetadataProcessor):
"""
Represents a metadata processor for Exif metadata.
"""
supported_mime_types = {
MimeType('image/jpeg'),
MimeType('image/webp'),
}
metadata_to_exif = bidict(
{
'aperture': ('Exif', piexif.ExifIFD.ApertureValue),
'artist': ('0th', piexif.ImageIFD.Artist),
'brightness': ('Exif', piexif.ExifIFD.BrightnessValue),
'camera.manufacturer': ('0th', piexif.ImageIFD.Make),
'camera.model': ('0th', piexif.ImageIFD.Model),
'datetime_digitized': ('Exif', piexif.ExifIFD.DateTimeDigitized),
'datetime_original': ('Exif', piexif.ExifIFD.DateTimeOriginal),
'description': ('0th', piexif.ImageIFD.ImageDescription),
'exposure_time': ('Exif', piexif.ExifIFD.ExposureTime),
'firmware': ('0th', piexif.ImageIFD.Software),
'fnumber': ('Exif', piexif.ExifIFD.FNumber),
'focal_length': ('Exif', piexif.ExifIFD.FocalLength),
'focal_length_35mm': ('Exif', piexif.ExifIFD.FocalLengthIn35mmFilm),
'gps.altitude': ('GPS', piexif.GPSIFD.GPSAltitude),
'gps.altitude_ref': ('GPS', piexif.GPSIFD.GPSAltitudeRef),
'gps.latitude': ('GPS', piexif.GPSIFD.GPSLatitude),
'gps.latitude_ref': ('GPS', piexif.GPSIFD.GPSLatitudeRef),
'gps.longitude': ('GPS', piexif.GPSIFD.GPSLongitude),
'gps.longitude_ref': ('GPS', piexif.GPSIFD.GPSLongitudeRef),
'gps.map_datum': ('GPS', piexif.GPSIFD.GPSMapDatum),
'gps.speed': ('GPS', piexif.GPSIFD.GPSSpeed),
'gps.speed_ref': ('GPS', piexif.GPSIFD.GPSSpeedRef),
'gps.date_stamp': ('GPS', piexif.GPSIFD.GPSDateStamp),
'gps.time_stamp': ('GPS', piexif.GPSIFD.GPSTimeStamp),
'lens.manufacturer': ('Exif', piexif.ExifIFD.LensMake),
'lens.model': ('Exif', piexif.ExifIFD.LensModel),
'orientation': ('0th', piexif.ImageIFD.Orientation),
'shutter_speed': ('Exif', piexif.ExifIFD.ShutterSpeedValue),
'software': ('0th', piexif.ImageIFD.ProcessingSoftware),
}
)
__STRING = lambda exif_val: exif_val.decode('utf-8'), lambda value: value.encode('utf-8')
__INT = int, int
__RATIONAL = (
lambda exif_val: float(Fraction(*exif_val)),
lambda value: (Fraction(value).limit_denominator().numerator, Fraction(value).limit_denominator().denominator),
)
__DATE = (
lambda exif_val: datetime.datetime.strptime(exif_val.decode('utf-8'), '%Y:%m:%d').date(),
lambda value: value.strftime('%Y:%m:%d'),
)
__DATETIME = (
lambda exif_val: datetime.datetime.strptime(exif_val.decode('utf-8'), '%Y:%m:%d %H:%M:%S'),
lambda value: value.strftime('%Y:%m:%d %H:%M:%S').encode('utf-8'),
)
__TIME = (
lambda exif_val: datetime.time(*map(lambda v: round(float(Fraction(*v))), exif_val)),
lambda value: ((value.hour, 1), (value.minute, 1), (value.second, 1)),
)
converters: dict[str, tuple[Callable, Callable]] = {
'aperture': __RATIONAL,
'artist': __STRING,
'brightness': __RATIONAL,
'camera.manufacturer': __STRING,
'camera.model': __STRING,
'datetime_digitized': __DATETIME,
'datetime_original': __DATETIME,
'description': __STRING,
'exposure_time': __RATIONAL,
'firmware': __STRING,
'fnumber': __RATIONAL,
'focal_length': __RATIONAL,
'focal_length_35mm': __INT,
'gps.altitude': __RATIONAL,
'gps.altitude_ref': _convert_mapping({0: 'm_above_sea_level', 1: 'm_below_sea_level'}),
'gps.latitude': _convert_sequence(__RATIONAL),
'gps.latitude_ref': _convert_mapping({b'N': 'north', b'S': 'south'}),
'gps.longitude': _convert_sequence(__RATIONAL),
'gps.longitude_ref': _convert_mapping({b'E': 'east', b'W': 'west'}),
'gps.map_datum': __STRING,
'gps.speed': __RATIONAL,
'gps.speed_ref': _convert_mapping({b'K': 'km/h', b'M': 'mph', b'N': 'kn'}),
'gps.date_stamp': __DATE,
'gps.time_stamp': __TIME,
'lens.manufacturer': __STRING,
'lens.model': __STRING,
'orientation': __INT,
'shutter_speed': __RATIONAL,
'software': __STRING,
}
[docs]
def __init__(self, config: Mapping[str, Any] | None = None) -> None:
"""
Initializes a new `ExifMetadataProcessor`.
:param config: Mapping with settings
"""
super().__init__(config)
@property
def formats(self) -> Iterable[str]:
return {'exif'}
[docs]
def read(self, file: IO) -> Mapping[str, Mapping]:
with tempfile.NamedTemporaryFile(mode='wb') as tmp:
tmp.write(file.read())
tmp.flush()
try:
metadata = piexif.load(tmp.name)
except (piexif.InvalidImageDataError, ValueError):
raise UnsupportedFormatError('Unsupported file format.')
metadata_by_format = {}
for metadata_format in self.formats:
format_metadata: dict[str, Any] = {}
raw_fields: dict[str, Any] = {}
for ifd_key, ifd_values in metadata.items():
if not isinstance(ifd_values, dict):
continue
for exif_key, exif_value in ifd_values.items():
madam_key = ExifMetadataProcessor.metadata_to_exif.inv.get((ifd_key, exif_key))
if madam_key is None:
# Preserve unmapped fields for round-trip fidelity.
raw_fields[f'{ifd_key}.{exif_key}'] = exif_value
else:
convert_to_madam, _ = ExifMetadataProcessor.converters[madam_key]
format_metadata[madam_key] = convert_to_madam(exif_value)
if raw_fields:
format_metadata['_raw'] = raw_fields
if format_metadata:
metadata_by_format[metadata_format] = format_metadata
return metadata_by_format
[docs]
def strip(self, file: IO) -> IO:
result = io.BytesIO()
with tempfile.NamedTemporaryFile(mode='w+b') as tmp:
tmp.write(file.read())
tmp.flush()
try:
metadata = piexif.load(tmp.name)
if any(metadata.values()):
piexif.remove(tmp.name)
except (piexif.InvalidImageDataError, ValueError, UnboundLocalError):
raise UnsupportedFormatError('Unsupported file format.')
tmp.seek(0)
shutil.copyfileobj(tmp, result)
result.seek(0)
return result
[docs]
def combine(self, file: IO, metadata: Mapping[str, Mapping]) -> IO:
result = io.BytesIO()
with tempfile.NamedTemporaryFile(mode='w+b') as tmp:
tmp.write(file.read())
tmp.flush()
try:
exif_metadata = piexif.load(tmp.name)
except (piexif.InvalidImageDataError, ValueError):
raise UnsupportedFormatError('Unsupported essence format.')
for metadata_format, metadata_values in metadata.items():
if metadata_format not in self.formats:
raise UnsupportedFormatError(f'Metadata format {metadata_format!r} is not supported.')
for madam_key, madam_value in metadata_values.items():
if madam_key == '_raw':
# Write opaque raw fields back verbatim.
for raw_key, raw_value in madam_value.items():
ifd_key, exif_key_str = raw_key.split('.', 1)
exif_key_int = int(exif_key_str)
if ifd_key not in exif_metadata:
exif_metadata[ifd_key] = {}
exif_metadata[ifd_key][exif_key_int] = raw_value
continue
if madam_key not in ExifMetadataProcessor.metadata_to_exif:
continue
ifd_key, exif_key = ExifMetadataProcessor.metadata_to_exif[madam_key]
if ifd_key not in exif_metadata:
exif_metadata[ifd_key] = {}
_, convert_to_exif = ExifMetadataProcessor.converters[madam_key]
exif_metadata[ifd_key][exif_key] = convert_to_exif(madam_value)
try:
piexif.insert(piexif.dump(exif_metadata), tmp.name)
except (piexif.InvalidImageDataError, ValueError):
raise UnsupportedFormatError(f'Could not write metadata: {metadata!r}')
tmp.seek(0)
shutil.copyfileobj(tmp, result)
result.seek(0)
return result