Last active
February 23, 2024 22:53
-
-
Save RhetTbull/41cc85e5bdeb30f761147ce32fba5c94 to your computer and use it in GitHub Desktop.
Access images from Apple Photos and associated metadata. Uses PyObjC to call native PhotoKit framekwork to access the user's Photos library.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" Use Apple PhotoKit via PyObjC bridge to download and save a photo | |
from users's Photos Library | |
Copyright Rhet Turnbull, 2020. Released under MIT License. | |
Required pyobjc >= 6.2 see: https://pypi.org/project/pyobjc/ """ | |
import platform | |
import logging | |
import sys | |
import CoreServices | |
import Foundation | |
import LaunchServices | |
import objc | |
import Photos | |
import Quartz | |
# NOTE: This requires user have granted access to the terminal (e.g. Terminal.app or iTerm) | |
# to access Photos. This should happen automatically the first time it's called. I've | |
# not figured out how to get the call to requestAuthorization_ to actually work in the case | |
# where Terminal doesn't automatically ask (e.g. if you use tcctutil to reset terminal priveleges) | |
# In the case where permission to use Photos was removed or reset, it looks like you also need | |
# to remove permission to for Full Disk Access then re-run the script in order for Photos to | |
# re-ask for permission | |
# pylint: disable=no-member | |
PHOTOS_VERSION_ORIGINAL = Photos.PHImageRequestOptionsVersionOriginal | |
PHOTOS_VERSION_CURRENT = Photos.PHImageRequestOptionsVersionCurrent | |
def get_os_version(): | |
# returns tuple of int containing OS version | |
# e.g. 10.13.6 = (10, 13, 6) | |
version = platform.mac_ver()[0].split(".") | |
if len(version) == 2: | |
(ver, major) = version | |
minor = "0" | |
elif len(version) == 3: | |
(ver, major, minor) = version | |
else: | |
raise ( | |
ValueError( | |
f"Could not parse version string: {platform.mac_ver()} {version}" | |
) | |
) | |
return (int(ver), int(major), int(minor)) | |
class PhotoKitError(Exception): | |
"""Base class for exceptions in this module.""" | |
pass | |
class PhotoKitFetchFailed(PhotoKitError): | |
"""Exception raised for errors in the input. | |
Attributes: | |
expression -- input expression in which the error occurred | |
message -- explanation of the error | |
""" | |
pass | |
# def __init__(self, expression, message): | |
# self.expression = expression | |
# self.message = message | |
def get_preferred_extension(uti): | |
""" get preferred extension for a UTI type | |
uti: UTI str, e.g. 'public.jpeg' | |
returns: preferred extension as str """ | |
# reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc | |
ext = CoreServices.UTTypeCopyPreferredTagWithClass( | |
uti, CoreServices.kUTTagClassFilenameExtension | |
) | |
return ext | |
class ImageData: | |
""" Simple class to hold the data passed to the handler for | |
requestImageDataAndOrientationForAsset_options_resultHandler_ """ | |
def __init__(self): | |
self.metadata = None | |
self.uti = None | |
self.image_data = None | |
self.info = None | |
self.orientation = None | |
class PhotoAsset: | |
""" PhotoKit PHAsset representation """ | |
def __init__(self, uuid): | |
""" uuid: universally unique identifier for photo in the Photo library """ | |
self.uuid = uuid | |
# pylint: disable=no-member | |
options = Photos.PHContentEditingInputRequestOptions.alloc().init() | |
options.setNetworkAccessAllowed_(True) | |
# check authorization status | |
auth_status = self._authorize() | |
if auth_status != Photos.PHAuthorizationStatusAuthorized: | |
sys.exit( | |
f"Could not get authorizaton to use Photos: auth_status = {auth_status}" | |
) | |
# get image manager and request options | |
self._manager = Photos.PHCachingImageManager.defaultManager() | |
try: | |
self._phasset = self._fetch(uuid) | |
except PhotoKitFetchFailed as e: | |
logging.warning(f"Failed to fetch PHAsset for UUID={uuid}") | |
raise e | |
def _authorize(self): | |
(ver, major, minor) = get_os_version() | |
auth_status = 0 | |
if major < 16: | |
auth_status = Photos.PHPhotoLibrary.authorizationStatus() | |
if auth_status != Photos.PHAuthorizationStatusAuthorized: | |
# it seems the first try fails after Terminal prompts user for access so try again | |
for _ in range(2): | |
Photos.PHPhotoLibrary.requestAuthorization_(self._auth_status) | |
auth_status = Photos.PHPhotoLibrary.authorizationStatus() | |
if auth_status == Photos.PHAuthorizationStatusAuthorized: | |
break | |
else: | |
# requestAuthorization deprecated in 10.16/11.0 | |
# but requestAuthorizationForAccessLevel not yet implemented in pyobjc | |
# https://developer.apple.com/documentation/photokit/phphotolibrary/3616053-requestauthorizationforaccesslev?language=objc | |
auth_status = Photos.PHPhotoLibrary.authorizationStatus() | |
if auth_status != Photos.PHAuthorizationStatusAuthorized: | |
# it seems the first try fails after Terminal prompts user for access so try again | |
for _ in range(2): | |
Photos.PHPhotoLibrary.requestAuthorization_(self._auth_status) | |
auth_status = Photos.PHPhotoLibrary.authorizationStatus() | |
if auth_status == Photos.PHAuthorizationStatusAuthorized: | |
break | |
return auth_status | |
def _fetch(self, uuid): | |
""" fetch a PHAsset with uuid = uuid """ | |
# pylint: disable=no-member | |
fetch_options = Photos.PHFetchOptions.alloc().init() | |
fetch_result = Photos.PHAsset.fetchAssetsWithLocalIdentifiers_options_( | |
[self.uuid], fetch_options | |
) | |
if fetch_result and fetch_result.count() == 1: | |
phasset = fetch_result.objectAtIndex_(0) | |
return phasset | |
else: | |
raise PhotoKitFetchFailed(f"Fetch did not return result for uuid {uuid}") | |
def request_image_data(self, version=PHOTOS_VERSION_ORIGINAL): | |
""" request image data and metadata for self._phasset | |
version: which version to request | |
PHOTOS_VERSION_ORIGINAL (default), request original highest fidelity version | |
PHOTOS_VERSION_CURRENT: request current version with all edits """ | |
# reference: https://developer.apple.com/documentation/photokit/phimagemanager/3237282-requestimagedataandorientationfo?language=objc | |
if version not in [PHOTOS_VERSION_CURRENT, PHOTOS_VERSION_ORIGINAL]: | |
raise ValueError("Invalid value for version") | |
# pylint: disable=no-member | |
options_request = Photos.PHImageRequestOptions.alloc().init() | |
options_request.setNetworkAccessAllowed_(True) | |
options_request.setSynchronous_(True) | |
options_request.setVersion_(version) | |
requestdata = ImageData() | |
handler = self._make_result_handle(requestdata) | |
self._manager.requestImageDataAndOrientationForAsset_options_resultHandler_( | |
self._phasset, options_request, handler | |
) | |
self._imagedata = requestdata | |
return requestdata | |
def has_adjustments(self): | |
""" Check to see if a PHAsset has adjustment data associated with it | |
Returns False if no adjustments, True if any adjustments """ | |
# reference: https://developer.apple.com/documentation/photokit/phassetresource/1623988-assetresourcesforasset?language=objc | |
adjustment_resources = Photos.PHAssetResource.assetResourcesForAsset_( | |
self._phasset | |
) | |
for idx in range(adjustment_resources.count()): | |
if ( | |
adjustment_resources.objectAtIndex_(idx).type() | |
== Photos.PHAssetResourceTypeAdjustmentData | |
): | |
return True | |
return False | |
def _auth_status(self, status): | |
""" Handler for requestAuthorization_ """ | |
# This doesn't actually get called but requestAuthorization needs a callable handler | |
# The Terminal will handle the actual authorization when called | |
pass | |
def _make_result_handle(self, data): | |
""" Returns handler function to use with | |
requestImageDataAndOrientationForAsset_options_resultHandler_ | |
data: Fetchdata class to hold resulting metadata | |
returns: handler function | |
Following call to requestImageDataAndOrientationForAsset_options_resultHandler_, | |
data will hold data from the fetch """ | |
def handler(imageData, dataUTI, orientation, info): | |
""" result handler for requestImageDataAndOrientationForAsset_options_resultHandler_ | |
all returned by the request is set as properties of nonlocal data (Fetchdata object) """ | |
nonlocal data | |
options = {} | |
# pylint: disable=no-member | |
options[Quartz.kCGImageSourceShouldCache] = Foundation.kCFBooleanFalse | |
imgSrc = Quartz.CGImageSourceCreateWithData(imageData, options) | |
data.metadata = Quartz.CGImageSourceCopyPropertiesAtIndex( | |
imgSrc, 0, options | |
) | |
data.uti = dataUTI | |
data.orientation = orientation | |
data.info = info | |
data.image_data = imageData | |
return None | |
return handler | |
def main(): | |
try: | |
uuid = sys.argv[1] | |
except: | |
sys.exit("Must provide uuid as first argument") | |
phasset = PhotoAsset(uuid) | |
imagedata = phasset.request_image_data(version=PHOTOS_VERSION_ORIGINAL) | |
print(f"adjustments: {phasset.has_adjustments()}") | |
photo = phasset._phasset | |
print(f"mediaType: {photo.mediaType()}") | |
print(f"mediaSubtypes: {photo.mediaSubtypes()}") | |
print(f"sourceType: {photo.sourceType()}") | |
print(f"pixelWidth: {photo.pixelWidth()}") | |
print(f"pixelHeight: {photo.pixelHeight()}") | |
print(f"creationDate: {photo.creationDate()}") | |
print(f"modificationDate: {photo.modificationDate()}") | |
print(f"location: {photo.location()}") | |
print(f"favorite: {photo.isFavorite()}") | |
print(f"hidden: {photo.isHidden()}") | |
# pylint: disable=unsubscriptable-object | |
print(f"metadata = {imagedata.metadata}") | |
print(f"uti = {imagedata.uti}") | |
print(f"url = {imagedata.info['PHImageFileURLKey']}") | |
print(f"degraded = {imagedata.info['PHImageResultIsDegradedKey']}") | |
print(f"orientation = {imagedata.orientation}") | |
# write the file | |
# TODO: add a export() method | |
ext = get_preferred_extension(imagedata.uti) | |
outfile = f"testimage.{ext}" | |
print(f"Writing image to {outfile}") | |
fd = open(outfile, "wb") | |
fd.write(imagedata.image_data) | |
fd.close() | |
# get the edited version | |
if phasset.has_adjustments(): | |
imagedata = phasset.request_image_data(version=PHOTOS_VERSION_CURRENT) | |
ext = get_preferred_extension(imagedata.uti) | |
outfile = f"testimage_current.{ext}" | |
print(f"Writing image to {outfile}") | |
fd = open(outfile, "wb") | |
fd.write(imagedata.image_data) | |
fd.close() | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment