Source code for osxphotos.personinfo

""" PhotoInfo and FaceInfo classes to expose info about persons and faces in the Photos library """

import json
import logging
import math
from collections import namedtuple
from functools import cached_property

import osxphotos

__all__ = ["PersonInfo", "FaceInfo", "rotate_image_point"]

MWG_RS_Area = namedtuple("MWG_RS_Area", ["x", "y", "h", "w"])
MPRI_Reg_Rect = namedtuple("MPRI_Reg_Rect", ["x", "y", "h", "w"])


[docs]class PersonInfo: """Info about a person in the Photos library""" def __init__(self, db: "osxphotos.PhotosDB", pk: int): """Creates a new PersonInfo instance Arguments: db: instance of PhotosDB object pk: primary key value of person to initialize PersonInfo with Returns: PersonInfo instance """ self._db: "osxphotos.PhotosDB" = db self._pk: int = pk person = self._db._dbpersons_pk[pk] self.uuid = person["uuid"] self.name = person["fullname"] self.display_name = person["displayname"] self.keyface = person["keyface"] self.facecount = person["facecount"] @property def keyphoto(self): try: return self._keyphoto except AttributeError: person = self._db._dbpersons_pk[self._pk] if person["photo_uuid"]: try: key_photo = self._db.get_photo(person["photo_uuid"]) except IndexError: key_photo = None else: key_photo = None self._keyphoto = key_photo return self._keyphoto @property def photos(self): """Returns list of PhotoInfo objects associated with this person""" return self._db.photos_by_uuid(self._db._dbfaces_pk[self._pk]) @property def face_info(self): """Returns a list of FaceInfo objects associated with this person sorted by quality score Highest quality face is result[0] and lowest quality face is result[n] """ try: faces = self._db._db_faceinfo_person[self._pk] return sorted( [FaceInfo(db=self._db, pk=face) for face in faces], key=lambda face: face.quality, reverse=True, ) except KeyError: # no faces return [] @property def favorite(self): """Returns True if person is a favorite, False otherwise; Photos 5+ only; returns False on Photos <= 4""" return self._db._dbpersons_pk[self._pk]["type"] == 1 @property def sort_order(self): """Returns sort order of person; favorite persons are sorted before non-favorite persons"; Photos 5+ only; returns 0 on Photos <= 4""" return self._db._dbpersons_pk[self._pk]["manualorder"] @cached_property def feature_less(self): """Returns True if person has been marked as "Feature This Person Less" in Photos, False otherwise; Photos 8+ only; returns False on Photos <= 7""" if self._db.photos_version < 8: return False if results := self._db.execute( """ SELECT ZTYPE FROM ZUSERFEEDBACK WHERE ZPERSON = ? """, (self._pk,), ).fetchone(): return bool(results[0]) return False
[docs] def asdict(self): """Returns dictionary representation of class instance""" keyphoto = self.keyphoto.uuid if self.keyphoto is not None else None return { "uuid": self.uuid, "name": self.name, "displayname": self.display_name, "keyface": self.keyface, "facecount": self.facecount, "keyphoto": keyphoto, "favorite": self.favorite, "sort_order": self.sort_order, "feature_less": self.feature_less, }
[docs] def json(self): """Returns JSON representation of class instance""" return json.dumps(self.asdict())
def __str__(self): return f"PersonInfo(name={self.name}, display_name={self.display_name}, uuid={self.uuid}, facecount={self.facecount})" def __eq__(self, other): return ( all( getattr(self, field) == getattr(other, field) for field in ["_db", "_pk"] ) if isinstance(other, type(self)) else False ) def __ne__(self, other): return not self.__eq__(other)
[docs]class FaceInfo: """Info about a face in the Photos library""" def __init__(self, db=None, pk=None): """Creates a new FaceInfo instance Arguments: db: instance of PhotosDB object pk: primary key value of face to init the object with Returns: FaceInfo instance """ self._db = db self._pk = pk face = self._db._db_faceinfo_pk[pk] self._info = face self.uuid = face["uuid"] self.name = face["fullname"] self.asset_uuid = face["asset_uuid"] self._person_pk = face["person"] self.center_x = face["centerx"] self.center_y = face["centery"] self.size = face["size"] self.quality = face["quality"] self.source_width = face["sourcewidth"] self.source_height = face["sourceheight"] self.has_smile = face["has_smile"] self.manual = face["manual"] self.face_type = face["facetype"] self.age_type = face["agetype"] self.eye_makeup_type = face["eyemakeuptype"] self.eye_state = face["eyestate"] self.facial_hair_type = face["facialhairtype"] self.gender_type = face["gendertype"] self.glasses_type = face["glassestype"] self.hair_color_type = face["haircolortype"] self.intrash = face["intrash"] self.lip_makeup_type = face["lipmakeuptype"] self.smile_type = face["smiletype"] @property def center(self): """Coordinates, in PIL format, for center of face Returns: tuple of coordinates in form (x, y) """ return self._make_point((self.center_x, self.center_y)) @property def size_pixels(self): """Size of face in pixels (centered around center_x, center_y) Returns: size, in int pixels, of a circle drawn around the center of the face """ photo = self.photo size_reference = photo.width if photo.width > photo.height else photo.height return self.size * size_reference @property def person_info(self): """PersonInfo instance for person associated with this face""" try: return self._person except AttributeError: self._person = PersonInfo(db=self._db, pk=self._person_pk) return self._person @property def photo(self): """PhotoInfo instance associated with this face""" try: return self._photo except AttributeError: self._photo = self._db.get_photo(self.asset_uuid) if self._photo is None: logging.warning(f"Could not get photo for uuid: {self.asset_uuid}") return self._photo @property def mwg_rs_area(self): """Get coordinates for Metadata Working Group Region Area. Returns: MWG_RS_Area named tuple with x, y, h, w where: x = stArea:x y = stArea:y h = stArea:h w = stArea:w Reference: https://photo.stackexchange.com/questions/106410/how-does-xmp-define-the-face-region """ x, y = self.center_x, self.center_y x, y = self._fix_orientation((x, y)) if self.photo.orientation in [5, 6, 7, 8]: w = self.size_pixels / self.photo.height h = self.size_pixels / self.photo.width else: h = self.size_pixels / self.photo.height w = self.size_pixels / self.photo.width return MWG_RS_Area(x, y, h, w) @property def mpri_reg_rect(self): """Get coordinates for Microsoft Photo Region Rectangle. Returns: MPRI_Reg_Rect named tuple with x, y, h, w where: x = x coordinate of top left corner of rectangle y = y coordinate of top left corner of rectangle h = height of rectangle w = width of rectangle Reference: https://docs.microsoft.com/en-us/windows/win32/wic/-wic-people-tagging """ x, y = self.center_x, self.center_y x, y = self._fix_orientation((x, y)) if self.photo.orientation in [5, 6, 7, 8]: w = self.size_pixels / self.photo.width h = self.size_pixels / self.photo.height x = x - self.size_pixels / self.photo.height / 2 y = y - self.size_pixels / self.photo.width / 2 else: h = self.size_pixels / self.photo.width w = self.size_pixels / self.photo.height x = x - self.size_pixels / self.photo.width / 2 y = y - self.size_pixels / self.photo.height / 2 return MPRI_Reg_Rect(x, y, h, w)
[docs] def face_rect(self): """Get face rectangle coordinates for current version of the associated image If image has been edited, rectangle applies to edited version, otherwise original version Coordinates in format and reference frame used by PIL Returns: list [(x0, x1), (y0, y1)] of coordinates in reference frame used by PIL """ photo = self.photo size_reference = photo.width if photo.width > photo.height else photo.height radius = (self.size / 2) * size_reference x, y = self._make_point((self.center_x, self.center_y)) x0, y0 = x - radius, y - radius x1, y1 = x + radius, y + radius return [(x0, y0), (x1, y1)]
[docs] def roll_pitch_yaw(self): """Roll, pitch, yaw of face in radians as tuple""" info = self._info roll = 0 if info["roll"] is None else info["roll"] pitch = 0 if info["pitch"] is None else info["pitch"] yaw = 0 if info["yaw"] is None else info["yaw"] return (roll, pitch, yaw)
@property def roll(self): """Return roll angle in radians of the face region""" roll, _, _ = self.roll_pitch_yaw() return roll @property def pitch(self): """Return pitch angle in radians of the face region""" _, pitch, _ = self.roll_pitch_yaw() return pitch @property def yaw(self): """Return yaw angle in radians of the face region""" _, _, yaw = self.roll_pitch_yaw() return yaw def _fix_orientation(self, xy): """Translate an (x, y) tuple based on image orientation Arguments: xy: tuple of (x, y) coordinates for point to translate in format used by Photos (percent of height/width) Returns: (x, y) tuple of translated coordinates """ # Reference: https://github.com/neilpa/phace/blob/7594776480505d0c389688a42099c94ac5d34f3f/cmd/phace/draw.go#L79-L94 orientation = self.photo.orientation x, y = xy if orientation == 1: y = 1.0 - y elif orientation == 2: y = 1.0 - y x = 1.0 - x elif orientation == 3: x = 1.0 - x elif orientation == 4: pass elif orientation == 5: x, y = 1.0 - y, x elif orientation == 6: x, y = 1.0 - y, 1.0 - x elif orientation == 7: x, y = y, x y = 1.0 - y elif orientation == 8: x, y = y, x elif orientation == 0: # set by osxphotos if adjusted orientation cannot be read, assume it's 1 y = 1.0 - y else: logging.warning(f"Unhandled orientation: {orientation}") return (x, y) def _make_point(self, xy): """Translate an (x, y) tuple based on image orientation and convert to image coordinates Arguments: xy: tuple of (x, y) coordinates for point to translate in format used by Photos (percent of height/width) Returns: (x, y) tuple of translated coordinates in pixels in PIL format/reference frame """ # Reference: https://github.com/neilpa/phace/blob/7594776480505d0c389688a42099c94ac5d34f3f/cmd/phace/draw.go#L79-L94 orientation = self.photo.orientation x, y = self._fix_orientation(xy) dx = self.photo.width dy = self.photo.height if orientation in [5, 6, 7, 8]: dx, dy = dy, dx return (int(x * dx), int(y * dy)) def _make_point_with_rotation(self, xy): """Translate an (x, y) tuple based on image orientation and rotation and convert to image coordinates Arguments: xy: tuple of (x, y) coordinates for point to translate in format used by Photos (percent of height/width) Returns: (x, y) tuple of translated coordinates in pixels in PIL format/reference frame """ # convert to image coordinates x, y = self._make_point(xy) # rotate about center xmid, ymid = self.center roll, _, _ = self.roll_pitch_yaw() xr, yr = rotate_image_point(x, y, xmid, ymid, roll) return (int(xr), int(yr))
[docs] def asdict(self): """Returns dict representation of class instance""" roll, pitch, yaw = self.roll_pitch_yaw() return { "_pk": self._pk, "uuid": self.uuid, "name": self.name, "asset_uuid": self.asset_uuid, "_person_pk": self._person_pk, "center_x": self.center_x, "center_y": self.center_y, "center": self.center, "size": self.size, "face_rect": self.face_rect(), "mpri_reg_rect": self.mpri_reg_rect._asdict(), "mwg_rs_area": self.mwg_rs_area._asdict(), "roll": roll, "pitch": pitch, "yaw": yaw, "quality": self.quality, "source_width": self.source_width, "source_height": self.source_height, "has_smile": self.has_smile, "manual": self.manual, "face_type": self.face_type, "age_type": self.age_type, "eye_makeup_type": self.eye_makeup_type, "eye_state": self.eye_state, "facial_hair_type": self.facial_hair_type, "gender_type": self.gender_type, "glasses_type": self.glasses_type, "hair_color_type": self.hair_color_type, "intrash": self.intrash, "lip_makeup_type": self.lip_makeup_type, "smile_type": self.smile_type, }
[docs] def json(self): """Return JSON representation of FaceInfo instance""" return json.dumps(self.asdict())
def __str__(self): return f"FaceInfo(uuid={self.uuid}, center_x={self.center_x}, center_y = {self.center_y}, size={self.size}, person={self.name}, asset_uuid={self.asset_uuid})" def __repr__(self): return f"FaceInfo(db={self._db}, pk={self._pk})" def __eq__(self, other): if not isinstance(other, type(self)): return False return all( getattr(self, field) == getattr(other, field) for field in ["_db", "_pk"] ) def __ne__(self, other): return not self.__eq__(other)
def rotate_image_point(x, y, xmid, ymid, angle): """rotate image point about xm, ym by angle in radians Arguments: x: x coordinate of point to rotate y: y coordinate of point to rotate xmid: x coordinate of center point to rotate about ymid: y coordinate of center point to rotate about angle: angle in radians about which to coordinate, counter-clockwise is positive Returns: tuple of rotated points (xr, yr) """ # translate point relative to the mid point x = x - xmid y = y - ymid # rotate by angle and translate back # the photo coordinate system is downwards y is positive so # need to adjust the rotation accordingly cos_angle = math.cos(angle) sin_angle = math.sin(angle) xr = x * cos_angle + y * sin_angle + xmid yr = -x * sin_angle + y * cos_angle + ymid return (xr, yr)