Compare commits

..

7 Commits

Author SHA1 Message Date
Rhet Turnbull
44a1e3e7a7 Better exception handling for AdjustmentsInfo 2021-02-20 12:28:19 -08:00
Rhet Turnbull
6c84e476cc Updated CHANGELOG.md, [skip ci] 2021-02-20 11:35:15 -08:00
Rhet Turnbull
14fbe5e068 Merge pull request #383 from RhetTbull/all-contributors/add-neilpa
docs: add neilpa as a contributor
2021-02-20 11:05:19 -08:00
allcontributors[bot]
ebac9d0bfb docs: update .all-contributorsrc [skip ci] 2021-02-20 19:04:05 +00:00
allcontributors[bot]
29716c5272 docs: update README.md [skip ci] 2021-02-20 19:04:04 +00:00
Rhet Turnbull
fbe8229103 Version bump 2021-02-20 11:01:58 -08:00
Rhet Turnbull
5ee6affc05 Added AdjustmentsInfo, #150, #379 2021-02-20 11:01:08 -08:00
10 changed files with 459 additions and 36 deletions

View File

@@ -175,6 +175,15 @@
"contributions": [
"doc"
]
},
{
"login": "neilpa",
"name": "Neil Pankey",
"avatar_url": "https://avatars.githubusercontent.com/u/42419?v=4",
"profile": "https://neilpa.me",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,

View File

@@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.40.18](https://github.com/RhetTbull/osxphotos/compare/v0.40.17...v0.40.18)
> 20 February 2021
- docs: add neilpa as a contributor [`#383`](https://github.com/RhetTbull/osxphotos/pull/383)
- Added AdjustmentsInfo, #150, #379 [`5ee6aff`](https://github.com/RhetTbull/osxphotos/commit/5ee6affc0525db1975cb5095f62494ef10d92f7e)
- docs: update .all-contributorsrc [skip ci] [`ebac9d0`](https://github.com/RhetTbull/osxphotos/commit/ebac9d0bfb43f59f046aacdd0290d1fcd29a3b5e)
- docs: update README.md [skip ci] [`29716c5`](https://github.com/RhetTbull/osxphotos/commit/29716c52726a4e699c03d43ecc67db57f55b36f8)
- Version bump [`fbe8229`](https://github.com/RhetTbull/osxphotos/commit/fbe822910370652975ab83b82344169df4c3027c)
#### [v0.40.17](https://github.com/RhetTbull/osxphotos/compare/v0.40.16...v0.40.17)
> 18 February 2021
- Updated docs for --ignore-signature, #286 [`e5f1c29`](https://github.com/RhetTbull/osxphotos/commit/e5f1c299742fcfa0a855a33df7b266aa2c39e48b)
- Added depth_state to _info [`b3a7869`](https://github.com/RhetTbull/osxphotos/commit/b3a7869bd3cc13e40cb3f68ff8caf12edda9a49c)
#### [v0.40.16](https://github.com/RhetTbull/osxphotos/compare/v0.40.14...v0.40.16)
> 14 February 2021
- Write description to ITPC:CaptionAbstract (#380) [`4b7a53f`](https://github.com/RhetTbull/osxphotos/commit/4b7a53faa8d7ff2e941e7653554f61bcbd416fc9)
- Removed orientation from XMP, #378 [`70848e1`](https://github.com/RhetTbull/osxphotos/commit/70848e1ff6def928b052271b47c1697c23a8c73f)
- Added image orientation bug to Known Bugs [`1316866`](https://github.com/RhetTbull/osxphotos/commit/1316866dc47486ac61db8903d2d7d006f2598a77)
#### [v0.40.14](https://github.com/RhetTbull/osxphotos/compare/v0.40.13...v0.40.14)
> 12 February 2021

View File

@@ -4,7 +4,7 @@
[![tests](https://github.com/RhetTbull/osxphotos/workflows/Tests/badge.svg)](https://github.com/RhetTbull/osxphotos/workflows/Tests/badge.svg)
![PyPI - Python Version](https://img.shields.io/pypi/pyversions/osxphotos)
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-18-orange.svg?style=flat)](#contributors)
[![All Contributors](https://img.shields.io/badge/all_contributors-19-orange.svg?style=flat)](#contributors)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
OSXPhotos provides the ability to interact with and query Apple's Photos.app library on macOS. You can query the Photos library database — for example, file name, file path, and metadata such as keywords/tags, persons/faces, albums, etc. You can also easily export both the original and edited photos.
@@ -31,6 +31,7 @@ OSXPhotos provides the ability to interact with and query Apple's Photos.app lib
+ [FaceInfo](#faceinfo)
+ [CommentInfo](#commentinfo)
+ [LikeInfo](#likeinfo)
+ [AdjustmentsInfo](#adjustmentsinfo)
+ [Raw Photos](#raw-photos)
+ [Template Substitutions](#template-substitutions)
+ [Utility Functions](#utility-functions)
@@ -1563,7 +1564,7 @@ Returns height of the photo in pixels. If image has been edited, returns height
Returns width of the photo in pixels. If image has been edited, returns width of the edited image, otherwise returns width of the original image. See also [original_width](#original_width).
#### `orientation`
Returns EXIF orientation value of the photo as integer. If image has been edited, returns orientation of the edited image, otherwise returns orientation of the original image. See also [original_orientation](#original_orientation).
Returns EXIF orientation value of the photo as integer. If image has been edited, returns orientation of the edited image, otherwise returns orientation of the original image. See also [original_orientation](#original_orientation). If orientation cannot be determined, returns 0 (this happens if osxphotos cannot decode the adjustment info for an edited image).
#### `original_height`
Returns height of the original photo in pixels. See also [height](#height).
@@ -1583,6 +1584,9 @@ Returns `True` if the original image file is missing on disk, otherwise `False`.
#### `hasadjustments`
Returns `True` if the picture has been edited, otherwise `False`
#### `adjustments`
On Photos 5+, returns an [AdjustmentsInfo](#adjustmentsinfo) object representing the adjustments (edits) to the photo or None if there are no adjustments. On earlier versions of Photos, always returns None.
#### `external_edit`
Returns `True` if the picture was edited in an external editor (outside Photos.app), otherwise `False`
@@ -2381,9 +2385,26 @@ Returns a JSON representation of the FaceInfo instance.
[PhotoInfo.likes](#likes) returns a list of LikeInfo objects for "likes" on shared photos. (Photos 5/MacOS 10.15+ only). The list of LikeInfo objects will be sorted in ascending order by date like was made. LikeInfo contains the following fields:
- `datetime`: `datetime.datetime`, date/time like was made
- `user`: `str`, name of user who made the like
- `user`: `str`, name of user who made the like
- `ismine`: `bool`, True if like was made by person who owns the Photos library being operated on
### AdjustmentsInfo
[PhotoInfo.adjustments](#adjustments) returns an AdjustmentsInfo object, if the photo has adjustments, or `None` if the photo does not have adjusments. AdjustmentsInfo has the following properties and methods:
- `plist`: The adjustments plist file maintained by Photos as a dict.
- `data`: The raw, undecoded adjustments info as binary blob.
- `editor`: The editor bundle ID of the app which made the edits, e.g. `com.apple.photos`.
- `format_id`: The format identifier set by the app which made the edits, e.g. `com.apple.photos`.
- `base_version`: Version info set by the app which made the edits.
- `format_version`: Version info set by the app which made the edits.
- `timestamp`: Time stamp of the adjustment as a timezone-aware datetime.datetime object; None if no timestamp is set.
- `adjustments`: a list of dicts containing information about the decoded adjustments to the photo or None if adjustments could not be decoded. AdjustmentsInfo can decode adjustments made by Photos but cannot decode adjustments made by external plugins or apps.
- `adj_metadata`: a dict containing additional data about the photo decoded from the adjustment data.
- `adj_orientation`: the EXIF orientation of the edited photo decoded from the adjustment metadata.
- `adj_format_version`: version for adjustments format decoded from the adjustment data.
- `adj_version_info`: version info for the application which made the adjustments to the photo decoded from the adjustments data.
- `asdict()`: dict representation of the AdjustmentsInfo object; contains all properties with exception of `plist`.
### Raw Photos
Handling raw photos in `osxphotos` requires a bit of extra work. Raw photos in Photos can be imported in two different ways: 1) a single raw photo with no associated JPEG image is imported 2) a raw+JPEG pair is imported -- two separate images with same file stem (e.g. `IMG_0001.CR2` and `IMG_001.JPG`) are imported.
@@ -2631,6 +2652,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center"><a href="https://github.com/narensankar0529"><img src="https://avatars3.githubusercontent.com/u/74054766?v=4?s=75" width="75px;" alt=""/><br /><sub><b>narensankar0529</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/issues?q=author%3Anarensankar0529" title="Bug reports">🐛</a> <a href="#userTesting-narensankar0529" title="User Testing">📓</a></td>
<td align="center"><a href="https://github.com/martinhrpi"><img src="https://avatars2.githubusercontent.com/u/19407684?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Martin</b></sub></a><br /><a href="#research-martinhrpi" title="Research">🔬</a> <a href="#userTesting-martinhrpi" title="User Testing">📓</a></td>
<td align="center"><a href="https://github.com/davidjroos"><img src="https://avatars.githubusercontent.com/u/15630844?v=4?s=75" width="75px;" alt=""/><br /><sub><b>davidjroos </b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=davidjroos" title="Documentation">📖</a></td>
<td align="center"><a href="https://neilpa.me"><img src="https://avatars.githubusercontent.com/u/42419?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Neil Pankey</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=neilpa" title="Code">💻</a></td>
</tr>
</table>

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.40.17"
__version__ = "0.40.19"

View File

@@ -0,0 +1,174 @@
""" 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 adjustments
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)"""
try:
return self._adjustments["adjustments"] if self._adjustments else []
except KeyError:
return []
@property
def adj_metadata(self):
"""Metadata dictionary or None if adjustment data could not be decoded"""
try:
return self._adjustments["metadata"] if self._adjustments else None
except KeyError:
return 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"""
try:
return self._adjustments["formatVersion"] if self._adjustments else None
except KeyError:
return None
@property
def adj_version_info(self):
"""version info for adjustments data or None if adjustments data could not be decoded"""
try:
return self._adjustments["versionInfo"] if self._adjustments else None
except KeyError:
return 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}')"

View 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:

View File

@@ -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):

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -637,3 +637,18 @@ def test_is_reference(photosdb):
assert photo.isreference
photo = photosdb.get_photo(UUID_NOT_REFERENCE)
assert not photo.isreference
def test_adjustments(photosdb):
""" test adjustments/AdjustmentsInfo (not implemented for 10.14) """
from osxphotos.adjustmentsinfo import AdjustmentsInfo
photo = photosdb.get_photo(UUID_DICT["has_adjustments"])
assert photo.adjustments is None
def test_no_adjustments(photosdb):
""" test adjustments when photo has no adjusments"""
photo = photosdb.get_photo(UUID_DICT["no_adjustments"])
assert photo.adjustments is None