162
osxphotos/adjustmentsinfo.py
Normal file
162
osxphotos/adjustmentsinfo.py
Normal file
@@ -0,0 +1,162 @@
|
||||
""" AdjustmentsInfo class to read adjustments data for photos edited in Apple's Photos.app
|
||||
In Catalina and Big Sur, the adjustments data (data about edits done to the photo)
|
||||
is stored in a plist file in
|
||||
~/Pictures/Photos Library.photoslibrary/resources/renders/X/UUID.plist
|
||||
where X is first character of the photo's UUID string and UUID is the full UUID,
|
||||
e.g.: ~/Pictures/Photos Library.photoslibrary/resources/renders/3/30362C1D-192F-4CCD-9A2A-968F436DC0DE.plist
|
||||
|
||||
Thanks to @neilpa who figured out how to decode this information:
|
||||
Reference: https://github.com/neilpa/photohack/issues/4
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import plistlib
|
||||
import zlib
|
||||
|
||||
from .datetime_utils import datetime_naive_to_utc
|
||||
|
||||
|
||||
class AdjustmentsDecodeError(Exception):
|
||||
"""Could not decode adjustments plist file"""
|
||||
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class AdjustmentsInfo:
|
||||
def __init__(self, plist_file):
|
||||
self._plist_file = plist_file
|
||||
self._plist = self._load_plist_file(plist_file)
|
||||
|
||||
self._base_version = self._plist.get("adjustmentBaseVersion", None)
|
||||
self._data = self._plist.get("adjustmentData", None)
|
||||
self._editor_bundle_id = self._plist.get("adjustmentEditorBundleID", None)
|
||||
self._format_identifier = self._plist.get("adjustmentFormatIdentifier", None)
|
||||
self._format_version = self._plist.get("adjustmentFormatVersion")
|
||||
self._timestamp = self._plist.get("adjustmentTimestamp", None)
|
||||
if self._timestamp and type(self._timestamp) == datetime.datetime:
|
||||
self._timestamp = datetime_naive_to_utc(self._timestamp)
|
||||
|
||||
try:
|
||||
self._adjustments = self._decode_adjustments_from_plist(self._plist)
|
||||
except Exception as e:
|
||||
self._adjustments = None
|
||||
|
||||
def _decode_adjustments_from_plist(self, plist):
|
||||
"""decode adjustmentData from Apple Photos adjusments
|
||||
|
||||
Args:
|
||||
plist: a plist dict as loaded by plistlib
|
||||
|
||||
Returns:
|
||||
decoded adjustmentsData as dict
|
||||
"""
|
||||
|
||||
return json.loads(
|
||||
zlib.decompress(plist["adjustmentData"], -zlib.MAX_WBITS).decode()
|
||||
)
|
||||
|
||||
def _load_plist_file(self, plist_file):
|
||||
"""Load plist file from disk
|
||||
|
||||
Args:
|
||||
plist_file: full path to plist file
|
||||
|
||||
Returns:
|
||||
plist as dict
|
||||
"""
|
||||
with open(str(plist_file), "rb") as fd:
|
||||
plist_dict = plistlib.load(fd)
|
||||
return plist_dict
|
||||
|
||||
@property
|
||||
def plist(self):
|
||||
"""The actual adjustments plist content as a dict """
|
||||
return self._plist
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
"""The raw adjustments data as a binary blob """
|
||||
return self._data
|
||||
|
||||
@property
|
||||
def editor(self):
|
||||
"""The editor bundle ID for app/plug-in which made the adjustments """
|
||||
return self._editor_bundle_id
|
||||
|
||||
@property
|
||||
def format_id(self):
|
||||
"""The value of the adjustmentFormatIdentifier field in the plist """
|
||||
return self._format_identifier
|
||||
|
||||
@property
|
||||
def base_version(self):
|
||||
"""Value of adjustmentBaseVersion field """
|
||||
return self._base_version
|
||||
|
||||
@property
|
||||
def format_version(self):
|
||||
"""The value of the adjustmentFormatVersion in the plist """
|
||||
return self._format_version
|
||||
|
||||
@property
|
||||
def timestamp(self):
|
||||
"""The time stamp of the adjustment as timezone aware datetime.datetime object or None if no timestamp """
|
||||
return self._timestamp
|
||||
|
||||
@property
|
||||
def adjustments(self):
|
||||
"""List of adjustment dictionaries (or empty list if none or could not be decoded)"""
|
||||
return self._adjustments["adjustments"] if self._adjustments else []
|
||||
|
||||
@property
|
||||
def adj_metadata(self):
|
||||
"""Metadata dictionary or None if adjustment data could not be decoded"""
|
||||
return self._adjustments["metadata"] if self._adjustments else None
|
||||
|
||||
@property
|
||||
def adj_orientation(self):
|
||||
"""EXIF orientation of image or 0 if none specified or None if adjustments could not be decoded"""
|
||||
try:
|
||||
return self._adjustments["metadata"]["orientation"]
|
||||
except KeyError:
|
||||
# no orientation field
|
||||
return 0
|
||||
except TypeError:
|
||||
# adjustments is None
|
||||
return 0
|
||||
|
||||
@property
|
||||
def adj_format_version(self):
|
||||
"""Format version for adjustments data (formatVersion field from adjustmentData) or None if adjustments could not be decoded"""
|
||||
return self._adjustments["formatVersion"] if self._adjustments else None
|
||||
|
||||
@property
|
||||
def adj_version_info(self):
|
||||
"""version info for adjustments data or None if adjustments data could not be decoded"""
|
||||
return self._adjustments["versionInfo"] if self._adjustments else None
|
||||
|
||||
def asdict(self):
|
||||
"""Returns all adjustments info as dictionary"""
|
||||
timestamp = self.timestamp
|
||||
if type(timestamp) == datetime.datetime:
|
||||
timestamp = timestamp.isoformat()
|
||||
|
||||
return {
|
||||
"data": self.data,
|
||||
"editor": self.editor,
|
||||
"format_id": self.format_id,
|
||||
"base_version": self.base_version,
|
||||
"format_version": self.format_version,
|
||||
"adjustments": self.adjustments,
|
||||
"metadata": self.adj_metadata,
|
||||
"orientation": self.adj_orientation,
|
||||
"adjustment_format_version": self.adj_format_version,
|
||||
"version_info": self.adj_version_info,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f"AdjustmentsInfo(plist_file='{self._plist_file}')"
|
||||
@@ -11,16 +11,15 @@ MPRI_Reg_Rect = namedtuple("MPRI_Reg_Rect", ["x", "y", "h", "w"])
|
||||
|
||||
|
||||
class PersonInfo:
|
||||
""" Info about a person in the Photos library
|
||||
"""
|
||||
"""Info about a person in the Photos library"""
|
||||
|
||||
def __init__(self, db=None, pk=None):
|
||||
""" Creates a new PersonInfo instance
|
||||
"""Creates a new PersonInfo instance
|
||||
|
||||
Arguments:
|
||||
db: instance of PhotosDB object
|
||||
pk: primary key value of person to initialize PersonInfo with
|
||||
|
||||
pk: primary key value of person to initialize PersonInfo with
|
||||
|
||||
Returns:
|
||||
PersonInfo instance
|
||||
"""
|
||||
@@ -57,8 +56,8 @@ class PersonInfo:
|
||||
|
||||
@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]
|
||||
"""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]
|
||||
@@ -103,16 +102,15 @@ class PersonInfo:
|
||||
|
||||
|
||||
class FaceInfo:
|
||||
""" Info about a face in the Photos library
|
||||
"""
|
||||
"""Info about a face in the Photos library"""
|
||||
|
||||
def __init__(self, db=None, pk=None):
|
||||
""" Creates a new FaceInfo instance
|
||||
"""Creates a new FaceInfo instance
|
||||
|
||||
Arguments:
|
||||
db: instance of PhotosDB object
|
||||
pk: primary key value of face to init the object with
|
||||
|
||||
pk: primary key value of face to init the object with
|
||||
|
||||
Returns:
|
||||
FaceInfo instance
|
||||
"""
|
||||
@@ -156,7 +154,7 @@ class FaceInfo:
|
||||
|
||||
@property
|
||||
def center(self):
|
||||
""" Coordinates, in PIL format, for center of face
|
||||
"""Coordinates, in PIL format, for center of face
|
||||
|
||||
Returns:
|
||||
tuple of coordinates in form (x, y)
|
||||
@@ -165,7 +163,7 @@ class FaceInfo:
|
||||
|
||||
@property
|
||||
def size_pixels(self):
|
||||
""" Size of face in pixels (centered around center_x, center_y)
|
||||
"""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
|
||||
@@ -176,7 +174,7 @@ class FaceInfo:
|
||||
|
||||
@property
|
||||
def mouth(self):
|
||||
""" Coordinates, in PIL format, for mouth position
|
||||
"""Coordinates, in PIL format, for mouth position
|
||||
|
||||
Returns:
|
||||
tuple of coordinates in form (x, y)
|
||||
@@ -185,7 +183,7 @@ class FaceInfo:
|
||||
|
||||
@property
|
||||
def left_eye(self):
|
||||
""" Coordinates, in PIL format, for left eye position
|
||||
"""Coordinates, in PIL format, for left eye position
|
||||
|
||||
Returns:
|
||||
tuple of coordinates in form (x, y)
|
||||
@@ -194,7 +192,7 @@ class FaceInfo:
|
||||
|
||||
@property
|
||||
def right_eye(self):
|
||||
""" Coordinates, in PIL format, for right eye position
|
||||
"""Coordinates, in PIL format, for right eye position
|
||||
|
||||
Returns:
|
||||
tuple of coordinates in form (x, y)
|
||||
@@ -223,7 +221,7 @@ class FaceInfo:
|
||||
|
||||
@property
|
||||
def mwg_rs_area(self):
|
||||
""" Get coordinates for Metadata Working Group Region Area.
|
||||
"""Get coordinates for Metadata Working Group Region Area.
|
||||
|
||||
Returns:
|
||||
MWG_RS_Area named tuple with x, y, h, w where:
|
||||
@@ -249,7 +247,7 @@ class FaceInfo:
|
||||
|
||||
@property
|
||||
def mpri_reg_rect(self):
|
||||
""" Get coordinates for Microsoft Photo Region Rectangle.
|
||||
"""Get coordinates for Microsoft Photo Region Rectangle.
|
||||
|
||||
Returns:
|
||||
MPRI_Reg_Rect named tuple with x, y, h, w where:
|
||||
@@ -278,7 +276,7 @@ class FaceInfo:
|
||||
return MPRI_Reg_Rect(x, y, h, w)
|
||||
|
||||
def face_rect(self):
|
||||
""" Get face rectangle coordinates for current version of the associated image
|
||||
"""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
|
||||
|
||||
@@ -321,12 +319,12 @@ class FaceInfo:
|
||||
return yaw
|
||||
|
||||
def _fix_orientation(self, xy):
|
||||
""" Translate an (x, y) tuple based on image orientation
|
||||
"""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
|
||||
"""
|
||||
@@ -350,21 +348,24 @@ class FaceInfo:
|
||||
elif orientation == 7:
|
||||
x, y = y, x
|
||||
y = 1.0 - y
|
||||
elif orientation ==8:
|
||||
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
|
||||
"""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
|
||||
"""
|
||||
@@ -379,13 +380,13 @@ class FaceInfo:
|
||||
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
|
||||
"""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
|
||||
"""
|
||||
@@ -472,14 +473,14 @@ class FaceInfo:
|
||||
|
||||
|
||||
def rotate_image_point(x, y, xmid, ymid, angle):
|
||||
""" rotate image point about xm, ym by angle in radians
|
||||
"""rotate image point about xm, ym by angle in radians
|
||||
|
||||
Arguments:
|
||||
x: x coordinate of point to rotate
|
||||
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,
|
||||
angle: angle in radians about which to coordinate,
|
||||
counter-clockwise is positive
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -27,6 +27,7 @@ from .._constants import (
|
||||
_PHOTOS_5_SHARED_PHOTO_PATH,
|
||||
_PHOTOS_5_VERSION,
|
||||
)
|
||||
from ..adjustmentsinfo import AdjustmentsInfo
|
||||
from ..albuminfo import AlbumInfo, ImportInfo
|
||||
from ..personinfo import FaceInfo, PersonInfo
|
||||
from ..phototemplate import PhotoTemplate
|
||||
@@ -510,6 +511,30 @@ class PhotoInfo:
|
||||
""" True if picture has adjustments / edits """
|
||||
return self._info["hasAdjustments"] == 1
|
||||
|
||||
@property
|
||||
def adjustments(self):
|
||||
""" Returns AdjustmentsInfo class for adjustment data or None if no adjustments; Photos 5+ only """
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return None
|
||||
|
||||
if self.hasadjustments:
|
||||
try:
|
||||
return self._adjustmentinfo
|
||||
except AttributeError:
|
||||
library = self._db._library_path
|
||||
directory = self._uuid[0] # first char of uuid
|
||||
plist_file = (
|
||||
pathlib.Path(library)
|
||||
/ "resources"
|
||||
/ "renders"
|
||||
/ directory
|
||||
/ f"{self._uuid}.plist"
|
||||
)
|
||||
if not plist_file.is_file():
|
||||
return None
|
||||
self._adjustmentinfo = AdjustmentsInfo(plist_file)
|
||||
return self._adjustmentinfo
|
||||
|
||||
@property
|
||||
def external_edit(self):
|
||||
""" Returns True if picture was edited outside of Photos using external editor """
|
||||
@@ -823,8 +848,19 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def orientation(self):
|
||||
""" returns EXIF orientation of the current photo version as int """
|
||||
return self._info["orientation"]
|
||||
""" returns EXIF orientation of the current photo version as int or 0 if current orientation cannot be determined """
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return self._info["orientation"]
|
||||
|
||||
# For Photos 5+, try to get the adjusted orientation
|
||||
if self.hasadjustments:
|
||||
if self.adjustments:
|
||||
return self.adjustments.adj_orientation
|
||||
else:
|
||||
# can't reliably determine orientation for edited photo if adjustmentinfo not available
|
||||
return 0
|
||||
else:
|
||||
return self._info["orientation"]
|
||||
|
||||
@property
|
||||
def original_height(self):
|
||||
|
||||
Reference in New Issue
Block a user