# standard imports from fractions import Fraction import typing # bsie imports from bsie.utils import bsfs, node, ns # inner-module imports from .. import base # exports __all__: typing.Sequence[str] = ( 'Exif', ) ## code ## def _gps_to_dec(coords: typing.Tuple[float, float, float]) -> float: """Convert GPS coordinates from exif to float.""" # unpack args deg, min, sec = coords # pylint: disable=redefined-builtin # min # convert to float deg = float(Fraction(deg)) min = float(Fraction(min)) sec = float(Fraction(sec)) if float(sec) > 0: # format is deg+min+sec return (float(deg) * 3600 + float(min) * 60 + float(sec)) / 3600 # format is deg+min return float(deg) + float(min) / 60 class Exif(base.Extractor): """Extract information from EXIF/IPTC tags of an image file.""" CONTENT_READER = 'bsie.reader.exif.Exif' def __init__(self): super().__init__(bsfs.schema.from_string(base.SCHEMA_PREAMBLE + ''' #bse:t_capture rdfs:subClassOf bsfs:Predicate ; # rdfs:domain bsn:Entity ; # rdfs:range xsd:float ; # bsfs:unique "true"^^xsd:boolean . bse:exposure rdfs:subClassOf bsfs:Predicate ; rdfs:domain bsn:Entity ; rdfs:range xsd:float ; bsfs:unique "true"^^xsd:boolean . bse:aperture rdfs:subClassOf bsfs:Predicate ; rdfs:domain bsn:Entity ; rdfs:range xsd:float ; bsfs:unique "true"^^xsd:boolean . bse:iso rdfs:subClassOf bsfs:Predicate ; rdfs:domain bsn:Entity ; rdfs:range xsd:integer ; bsfs:unique "true"^^xsd:boolean . bse:focal_length rdfs:subClassOf bsfs:Predicate ; rdfs:domain bsn:Entity ; rdfs:range xsd:float ; bsfs:unique "true"^^xsd:boolean . bse:width rdfs:subClassOf bsfs:Predicate ; rdfs:domain bsn:Entity ; rdfs:range xsd:integer ; bsfs:unique "true"^^xsd:boolean . bse:height rdfs:subClassOf bsfs:Predicate ; rdfs:domain bsn:Entity ; rdfs:range xsd:integer ; bsfs:unique "true"^^xsd:boolean . bse:orientation rdfs:subClassOf bsfs:Predicate ; rdfs:domain bsn:Entity ; rdfs:range xsd:integer ; bsfs:unique "true"^^xsd:boolean . bse:orientation_label rdfs:subClassOf bsfs:Predicate ; rdfs:domain bsn:Entity ; rdfs:range xsd:string ; bsfs:unique "true"^^xsd:boolean . bse:altitude rdfs:subClassOf bsfs:Predicate ; rdfs:domain bsn:Entity ; rdfs:range xsd:float ; bsfs:unique "true"^^xsd:boolean . bse:latitude rdfs:subClassOf bsfs:Predicate ; rdfs:domain bsn:Entity ; rdfs:range xsd:float ; bsfs:unique "true"^^xsd:boolean . bse:longitude rdfs:subClassOf bsfs:Predicate ; rdfs:domain bsn:Entity ; rdfs:range xsd:float ; bsfs:unique "true"^^xsd:boolean . ''')) # initialize mapping from predicate to callback self._callmap = { #self.schema.predicate(ns.bse.t_capture): self._date, self.schema.predicate(ns.bse.exposure): self._exposure, self.schema.predicate(ns.bse.aperture): self._aperture, self.schema.predicate(ns.bse.iso): self._iso, self.schema.predicate(ns.bse.focal_length): self._focal_length, self.schema.predicate(ns.bse.width): self._width, self.schema.predicate(ns.bse.height): self._height, self.schema.predicate(ns.bse.orientation): self._orientation, self.schema.predicate(ns.bse.orientation_label): self._orientation_label, self.schema.predicate(ns.bse.altitude): self._altitude, self.schema.predicate(ns.bse.latitude): self._latitude, self.schema.predicate(ns.bse.longitude): self._longitude, } def extract( self, subject: node.Node, content: dict, principals: typing.Iterable[bsfs.schema.Predicate], ) -> typing.Iterator[typing.Tuple[node.Node, bsfs.schema.Predicate, typing.Any]]: for pred in principals: # find callback clbk = self._callmap.get(pred) if clbk is None: continue # get value value = clbk(content) if value is None: continue # produce triple yield subject, pred, value #def _date(self, content: dict): # FIXME: Return type annotation # date_keys = ( # 'Exif.Photo.DateTimeOriginal', # 'Exif.Photo.DateTimeDigitized', # 'Exif.Image.DateTime', # ) # for key in date_keys: # if key in content: # dt = content[key].value # if dt.tzinfo is None: # dt = dt.replace(tzinfo=ttime.NoTimeZone) # return dt # return None ## photometrics def _exposure(self, content: dict) -> typing.Optional[float]: if 'Exif.Photo.ExposureTime' in content: return 1.0 / float(Fraction(content['Exif.Photo.ExposureTime'])) return None def _aperture(self, content: dict) -> typing.Optional[float]: if 'Exif.Photo.FNumber' in content: return float(Fraction(content['Exif.Photo.FNumber'])) return None def _iso(self, content: dict) -> typing.Optional[int]: if 'Exif.Photo.ISOSpeedRatings' in content: return int(content['Exif.Photo.ISOSpeedRatings']) return None def _focal_length(self, content: dict) -> typing.Optional[float]: if 'Exif.Photo.FocalLength' in content: return float(Fraction(content['Exif.Photo.FocalLength'])) return None ## image dimensions def _width(self, content: dict) -> typing.Optional[int]: # FIXME: consider orientation! if 'Exif.Photo.PixelXDimension' in content: return int(content['Exif.Photo.PixelXDimension']) return None def _height(self, content: dict) -> typing.Optional[int]: # FIXME: consider orientation! if 'Exif.Photo.PixelYDimension' in content: return int(content['Exif.Photo.PixelYDimension']) return None def _orientation(self, content: dict) -> typing.Optional[int]: if 'Exif.Image.Orientation' in content: return int(content['Exif.Image.Orientation']) return None def _orientation_label(self, content: dict) -> typing.Optional[str]: width = self._width(content) height = self._height(content) ori = self._orientation(content) if width is not None and height is not None and ori is not None: if ori <= 4: return 'landscape' if width >= height else 'portrait' return 'portrait' if width >= height else 'landscape' return None ## location def _altitude(self, content: dict) -> typing.Optional[float]: if 'Exif.GPSInfo.GPSAltitude' in content: return float(Fraction(content['Exif.GPSInfo.GPSAltitude'])) return None def _latitude(self, content: dict) -> typing.Optional[float]: if 'Exif.GPSInfo.GPSLatitude' in content: return _gps_to_dec(content['Exif.GPSInfo.GPSLatitude'].split()) return None def _longitude(self, content: dict) -> typing.Optional[float]: if 'Exif.GPSInfo.GPSLongitude' in content: return _gps_to_dec(content['Exif.GPSInfo.GPSLongitude'].split()) return None ## EOF ##