diff --git a/README.md b/README.md index 0c0b5415..0020f642 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ + [AlbumInfo](#albuminfo) + [FolderInfo](#folderinfo) + [PlaceInfo](#placeinfo) + + [ScoreInfo](#scoreinfo) + [Template Substitutions](#template-substitutions) + [Utility Functions](#utility-functions) * [Examples](#examples) @@ -1149,7 +1150,12 @@ photo.exiftool.setvalue("XMP:Title", "Title of photo") photo.exiftool.addvalues("IPTC:Keywords", "vacation", "beach") ``` -**Caution**: I caution against writing new EXIF data to photos in the Photos library because this will overwrite the original copy of the photo and could adversely affect how Photos behaves. `exiftool.as_dict()` is useful for getting access to all the photos information but if you want to write new EXIF data, I recommend you export the photo first then write the data. [PhotoInfo.export()](#export) does this if called with `exiftool=True`. +**Caution**: I caution against writing new EXIF data to photos in the Photos library because this will overwrite the original copy of the photo and could adversely affect how Photos behaves. `exiftool.as_dict()` is useful for getting access to all the photos information but if you want to write new EXIF data, I recommend you export the photo first then write the data. [PhotoInfo.export()](#export) does this if called with `exiftool=True`. + +#### `score` +Returns a [ScoreInfo](#scoreinfo) data class object which provides access to the computed aesthetic scores for each photo. + +**Note**: Valid only for Photos 5; returns None for earlier Photos versions. #### `json()` Returns a JSON representation of all photo info @@ -1395,6 +1401,45 @@ PostalAddress(street='3700 Wailea Alanui Dr', sub_locality=None, city='Kihei', s >>> photo.place.address.postal_code '96753' ``` +### ScoreInfo +[PhotoInfo.score](#score) returns a ScoreInfo object that exposes the computed aesthetic scores for each photo (**Photos 5 only**). I have not yet reverse engineered the meaning of each score. The `overall` score seems to the most useful and appears to be a composite of the other scores. The following score properties are currently available: + +```python +overall: float +curation: float +promotion: float +highlight_visibility: float +behavioral: float +failure: float +harmonious_color: float +immersiveness: float +interaction: float +interesting_subject: float +intrusive_object_presence: float +lively_color: float +low_light: float +noise: float +pleasant_camera_tilt: float +pleasant_composition: float +pleasant_lighting: float +pleasant_pattern: float +pleasant_perspective: float +pleasant_post_processing: float +pleasant_reflection: float +pleasant_symmetry: float +sharply_focused_subject: float +tastefully_blurred: float +well_chosen_subject: float +well_framed_subject: float +well_timed_shot: float +``` + +Example: find your "best" photo of food +```python +>>> import osxphotos +>>> photos = osxphotos.PhotosDB().photos() +>>> best_food_photo = sorted([p for p in photos if "food" in p.labels_normalized], key=lambda p: p.score.overall, reverse=True)[0] +``` ### Template Substitutions diff --git a/osxphotos/_version.py b/osxphotos/_version.py index 3576b712..38364642 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,3 +1,3 @@ """ version info """ -__version__ = "0.29.18" +__version__ = "0.29.19" diff --git a/osxphotos/photoinfo/__init__.py b/osxphotos/photoinfo/__init__.py index 73b31bc1..e6c5624b 100644 --- a/osxphotos/photoinfo/__init__.py +++ b/osxphotos/photoinfo/__init__.py @@ -6,4 +6,5 @@ PhotosDB.photos() returns a list of PhotoInfo objects from ._photoinfo_exifinfo import ExifInfo from ._photoinfo_export import ExportResults +from ._photoinfo_scoreinfo import ScoreInfo from .photoinfo import PhotoInfo diff --git a/osxphotos/photoinfo/_photoinfo_scoreinfo.py b/osxphotos/photoinfo/_photoinfo_scoreinfo.py new file mode 100644 index 00000000..c5a84f43 --- /dev/null +++ b/osxphotos/photoinfo/_photoinfo_scoreinfo.py @@ -0,0 +1,119 @@ +""" PhotoInfo methods to expose computed score info from the library """ + +import logging +from dataclasses import dataclass + +from .._constants import _PHOTOS_4_VERSION + + +@dataclass(frozen=True) +class ScoreInfo: + """ Computed photo score info associated with a photo from the Photos library """ + + overall: float + curation: float + promotion: float + highlight_visibility: float + behavioral: float + failure: float + harmonious_color: float + immersiveness: float + interaction: float + interesting_subject: float + intrusive_object_presence: float + lively_color: float + low_light: float + noise: float + pleasant_camera_tilt: float + pleasant_composition: float + pleasant_lighting: float + pleasant_pattern: float + pleasant_perspective: float + pleasant_post_processing: float + pleasant_reflection: float + pleasant_symmetry: float + sharply_focused_subject: float + tastefully_blurred: float + well_chosen_subject: float + well_framed_subject: float + well_timed_shot: float + + +@property +def score(self): + """ Computed score information for a photo + + Returns: + ScoreInfo instance + """ + + if self._db._db_version <= _PHOTOS_4_VERSION: + logging.debug(f"score not implemented for this database version") + return None + + try: + return self._scoreinfo # pylint: disable=access-member-before-definition + except AttributeError: + try: + scores = self._db._db_scoreinfo_uuid[self.uuid] + self._scoreinfo = ScoreInfo( + overall=scores["overall_aesthetic"], + curation=scores["curation"], + promotion=scores["promotion"], + highlight_visibility=scores["highlight_visibility"], + behavioral=scores["behavioral"], + failure=scores["failure"], + harmonious_color=scores["harmonious_color"], + immersiveness=scores["immersiveness"], + interaction=scores["interaction"], + interesting_subject=scores["interesting_subject"], + intrusive_object_presence=scores["intrusive_object_presence"], + lively_color=scores["lively_color"], + low_light=scores["low_light"], + noise=scores["noise"], + pleasant_camera_tilt=scores["pleasant_camera_tilt"], + pleasant_composition=scores["pleasant_composition"], + pleasant_lighting=scores["pleasant_lighting"], + pleasant_pattern=scores["pleasant_pattern"], + pleasant_perspective=scores["pleasant_perspective"], + pleasant_post_processing=scores["pleasant_post_processing"], + pleasant_reflection=scores["pleasant_reflection"], + pleasant_symmetry=scores["pleasant_symmetry"], + sharply_focused_subject=scores["sharply_focused_subject"], + tastefully_blurred=scores["tastefully_blurred"], + well_chosen_subject=scores["well_chosen_subject"], + well_framed_subject=scores["well_framed_subject"], + well_timed_shot=scores["well_timed_shot"], + ) + return self._scoreinfo + except KeyError: + self._scoreinfo = ScoreInfo( + overall=0.0, + curation=0.0, + promotion=0.0, + highlight_visibility=0.0, + behavioral=0.0, + failure=0.0, + harmonious_color=0.0, + immersiveness=0.0, + interaction=0.0, + interesting_subject=0.0, + intrusive_object_presence=0.0, + lively_color=0.0, + low_light=0.0, + noise=0.0, + pleasant_camera_tilt=0.0, + pleasant_composition=0.0, + pleasant_lighting=0.0, + pleasant_pattern=0.0, + pleasant_perspective=0.0, + pleasant_post_processing=0.0, + pleasant_reflection=0.0, + pleasant_symmetry=0.0, + sharply_focused_subject=0.0, + tastefully_blurred=0.0, + well_chosen_subject=0.0, + well_framed_subject=0.0, + well_timed_shot=0.0, + ) + return self._scoreinfo diff --git a/osxphotos/photoinfo/photoinfo.py b/osxphotos/photoinfo/photoinfo.py index 26a07d71..f962a9c6 100644 --- a/osxphotos/photoinfo/photoinfo.py +++ b/osxphotos/photoinfo/photoinfo.py @@ -55,6 +55,7 @@ class PhotoInfo: _xmp_sidecar, ExportResults, ) + from ._photoinfo_scoreinfo import score, ScoreInfo def __init__(self, db=None, uuid=None, info=None): self._uuid = uuid @@ -664,6 +665,8 @@ class PhotoInfo: date_modified_iso = ( self.date_modified.isoformat() if self.date_modified else None ) + exif = str(self.exif_info) if self.exif_info else None + score = str(self.score) if self.score else None info = { "uuid": self.uuid, @@ -704,6 +707,9 @@ class PhotoInfo: "has_raw": self.has_raw, "uti_raw": self.uti_raw, "path_raw": self.path_raw, + "place": self.place, + "exif": exif, + "score": score, } return yaml.dump(info, sort_keys=False) @@ -716,6 +722,7 @@ class PhotoInfo: folders = {album.title: album.folder_names for album in self.album_info} exif = dataclasses.asdict(self.exif_info) if self.exif_info else {} place = self.place.as_dict() if self.place else {} + score = dataclasses.asdict(self.score) if self.score else {} pic = { "uuid": self.uuid, @@ -761,6 +768,7 @@ class PhotoInfo: "path_raw": self.path_raw, "place": place, "exif": exif, + "score": score, } return json.dumps(pic) diff --git a/osxphotos/photosdb/_photosdb_process_scoreinfo.py b/osxphotos/photosdb/_photosdb_process_scoreinfo.py new file mode 100644 index 00000000..b004cb56 --- /dev/null +++ b/osxphotos/photosdb/_photosdb_process_scoreinfo.py @@ -0,0 +1,145 @@ +""" Methods for PhotosDB to add Photos 5 photo score info + ref: https://simonwillison.net/2020/May/21/dogsheep-photos/ +""" + +import logging + +from .._constants import _PHOTOS_4_VERSION +from ..utils import _open_sql_file + +""" + This module should be imported in the class defintion of PhotosDB in photosdb.py + Do not import this module directly + This module adds the following method to PhotosDB: + _process_scoreinfo: process photo score info + + The following data structures are added to PhotosDB + self._db_scoreinfo_uuid + + These methods only work on Photos 5 databases. Will print warning on earlier library versions. +""" + + +def _process_scoreinfo(self): + """ Process computed photo scores + Note: Only works on Photos version == 5.0 + """ + + # _db_scoreinfo_uuid is dict in form {uuid: {score values}} + self._db_scoreinfo_uuid = {} + + if self._db_version <= _PHOTOS_4_VERSION: + raise NotImplementedError( + f"search info not implemented for this database version" + ) + else: + _process_scoreinfo_5(self) + + +def _process_scoreinfo_5(photosdb): + """ Process computed photo scores for Photos 5 databases + + Args: + photosdb: an OSXPhotosDB instance + """ + + db = photosdb._tmp_db + + (conn, cursor) = _open_sql_file(db) + + result = cursor.execute( + """ + SELECT + ZGENERICASSET.ZUUID, + ZGENERICASSET.ZOVERALLAESTHETICSCORE, + ZGENERICASSET.ZCURATIONSCORE, + ZGENERICASSET.ZPROMOTIONSCORE, + ZGENERICASSET.ZHIGHLIGHTVISIBILITYSCORE, + ZCOMPUTEDASSETATTRIBUTES.ZBEHAVIORALSCORE, + ZCOMPUTEDASSETATTRIBUTES.ZFAILURESCORE, + ZCOMPUTEDASSETATTRIBUTES.ZHARMONIOUSCOLORSCORE, + ZCOMPUTEDASSETATTRIBUTES.ZIMMERSIVENESSSCORE, + ZCOMPUTEDASSETATTRIBUTES.ZINTERACTIONSCORE, + ZCOMPUTEDASSETATTRIBUTES.ZINTERESTINGSUBJECTSCORE, + ZCOMPUTEDASSETATTRIBUTES.ZINTRUSIVEOBJECTPRESENCESCORE, + ZCOMPUTEDASSETATTRIBUTES.ZLIVELYCOLORSCORE, + ZCOMPUTEDASSETATTRIBUTES.ZLOWLIGHT, + ZCOMPUTEDASSETATTRIBUTES.ZNOISESCORE, + ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTCAMERATILTSCORE, + ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTCOMPOSITIONSCORE, + ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTLIGHTINGSCORE, + ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPATTERNSCORE, + ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPERSPECTIVESCORE, + ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPOSTPROCESSINGSCORE, + ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTREFLECTIONSSCORE, + ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTSYMMETRYSCORE, + ZCOMPUTEDASSETATTRIBUTES.ZSHARPLYFOCUSEDSUBJECTSCORE, + ZCOMPUTEDASSETATTRIBUTES.ZTASTEFULLYBLURREDSCORE, + ZCOMPUTEDASSETATTRIBUTES.ZWELLCHOSENSUBJECTSCORE, + ZCOMPUTEDASSETATTRIBUTES.ZWELLFRAMEDSUBJECTSCORE, + ZCOMPUTEDASSETATTRIBUTES.ZWELLTIMEDSHOTSCORE + FROM ZGENERICASSET + JOIN ZCOMPUTEDASSETATTRIBUTES ON ZCOMPUTEDASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK + """ + ) + + # 0 ZGENERICASSET.ZUUID, + # 1 ZGENERICASSET.ZOVERALLAESTHETICSCORE, + # 2 ZGENERICASSET.ZCURATIONSCORE, + # 3 ZGENERICASSET.ZPROMOTIONSCORE, + # 4 ZGENERICASSET.ZHIGHLIGHTVISIBILITYSCORE, + # 5 ZCOMPUTEDASSETATTRIBUTES.ZBEHAVIORALSCORE, + # 6 ZCOMPUTEDASSETATTRIBUTES.ZFAILURESCORE, + # 7 ZCOMPUTEDASSETATTRIBUTES.ZHARMONIOUSCOLORSCORE, + # 8 ZCOMPUTEDASSETATTRIBUTES.ZIMMERSIVENESSSCORE, + # 9 ZCOMPUTEDASSETATTRIBUTES.ZINTERACTIONSCORE, + # 10 ZCOMPUTEDASSETATTRIBUTES.ZINTERESTINGSUBJECTSCORE, + # 11 ZCOMPUTEDASSETATTRIBUTES.ZINTRUSIVEOBJECTPRESENCESCORE, + # 12 ZCOMPUTEDASSETATTRIBUTES.ZLIVELYCOLORSCORE, + # 13 ZCOMPUTEDASSETATTRIBUTES.ZLOWLIGHT, + # 14 ZCOMPUTEDASSETATTRIBUTES.ZNOISESCORE, + # 15 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTCAMERATILTSCORE, + # 16 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTCOMPOSITIONSCORE, + # 17 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTLIGHTINGSCORE, + # 18 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPATTERNSCORE, + # 19 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPERSPECTIVESCORE, + # 20 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPOSTPROCESSINGSCORE, + # 21 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTREFLECTIONSSCORE, + # 22 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTSYMMETRYSCORE, + # 23 ZCOMPUTEDASSETATTRIBUTES.ZSHARPLYFOCUSEDSUBJECTSCORE, + # 24 ZCOMPUTEDASSETATTRIBUTES.ZTASTEFULLYBLURREDSCORE, + # 25 ZCOMPUTEDASSETATTRIBUTES.ZWELLCHOSENSUBJECTSCORE, + # 26 ZCOMPUTEDASSETATTRIBUTES.ZWELLFRAMEDSUBJECTSCORE, + # 27 ZCOMPUTEDASSETATTRIBUTES.ZWELLTIMEDSHOTSCORE + + for row in result: + uuid = row[0] + scores = {"uuid": uuid} + scores["overall_aesthetic"] = row[1] + scores["curation"] = row[2] + scores["promotion"] = row[3] + scores["highlight_visibility"] = row[4] + scores["behavioral"] = row[5] + scores["failure"] = row[6] + scores["harmonious_color"] = row[7] + scores["immersiveness"] = row[8] + scores["interaction"] = row[9] + scores["interesting_subject"] = row[10] + scores["intrusive_object_presence"] = row[11] + scores["lively_color"] = row[12] + scores["low_light"] = row[13] + scores["noise"] = row[14] + scores["pleasant_camera_tilt"] = row[15] + scores["pleasant_composition"] = row[16] + scores["pleasant_lighting"] = row[17] + scores["pleasant_pattern"] = row[18] + scores["pleasant_perspective"] = row[19] + scores["pleasant_post_processing"] = row[20] + scores["pleasant_reflection"] = row[21] + scores["pleasant_symmetry"] = row[22] + scores["sharply_focused_subject"] = row[23] + scores["tastefully_blurred"] = row[24] + scores["well_chosen_subject"] = row[25] + scores["well_framed_subject"] = row[26] + scores["well_timed_shot"] = row[27] + photosdb._db_scoreinfo_uuid[uuid] = scores diff --git a/osxphotos/photosdb/_photosdb_process_searchinfo.py b/osxphotos/photosdb/_photosdb_process_searchinfo.py index 89cbc259..9c19a344 100644 --- a/osxphotos/photosdb/_photosdb_process_searchinfo.py +++ b/osxphotos/photosdb/_photosdb_process_searchinfo.py @@ -102,7 +102,7 @@ def _process_searchinfo(self): # 8: groups.lookup_identifier for row in c: - uuid = ints_to_uuid(row[1],row[2]) + uuid = ints_to_uuid(row[1], row[2]) # strings have null character appended, so strip it record = {} record["uuid"] = uuid @@ -123,13 +123,9 @@ def _process_searchinfo(self): category = record["category"] try: - _db_searchinfo_categories[category].append( - record["normalized_string"] - ) + _db_searchinfo_categories[category].append(record["normalized_string"]) except KeyError: - _db_searchinfo_categories[category] = [ - record["normalized_string"] - ] + _db_searchinfo_categories[category] = [record["normalized_string"]] if category == SEARCH_CATEGORY_LABEL: label = record["content_string"] @@ -198,6 +194,7 @@ def labels_normalized_as_dict(self): # The following method is not imported into PhotosDB + @lru_cache(maxsize=128) def ints_to_uuid(uuid_0, uuid_1): """ convert two signed ints into a UUID strings diff --git a/osxphotos/photosdb/photosdb.py b/osxphotos/photosdb/photosdb.py index 24d525e2..0041eb29 100644 --- a/osxphotos/photosdb/photosdb.py +++ b/osxphotos/photosdb/photosdb.py @@ -64,6 +64,7 @@ class PhotosDB: labels_as_dict, labels_normalized_as_dict, ) + from ._photosdb_process_scoreinfo import _process_scoreinfo def __init__(self, *dbfile_, dbfile=None): """ create a new PhotosDB object @@ -1862,6 +1863,9 @@ class PhotosDB: # process exif info self._process_exifinfo() + # process computed scores + self._process_scoreinfo() + # done processing, dump debug data if requested if _debug(): logging.debug("Faces (_dbfaces_uuid):") diff --git a/tests/test_score_info.py b/tests/test_score_info.py new file mode 100644 index 00000000..b9d04cd5 --- /dev/null +++ b/tests/test_score_info.py @@ -0,0 +1,97 @@ +""" Test ScoreInfo """ + +from math import isclose +import pytest + +from osxphotos.photoinfo import ScoreInfo + +PHOTOS_DB_5 = "tests/Test-10.15.5.photoslibrary" +PHOTOS_DB_4 = "tests/Test-10.14.6.photoslibrary" + +SCORE_DICT = { + "4D521201-92AC-43E5-8F7C-59BC41C37A96": ScoreInfo( + overall=0.470703125, + curation=0.5, + promotion=0.0, + highlight_visibility=0.03816793893129771, + behavioral=0.0, + failure=-0.0006928443908691406, + harmonious_color=0.017852783203125, + immersiveness=0.003086090087890625, + interaction=0.019999999552965164, + interesting_subject=-0.0885009765625, + intrusive_object_presence=-0.037872314453125, + lively_color=0.10540771484375, + low_light=0.00824737548828125, + noise=-0.015655517578125, + pleasant_camera_tilt=-0.006256103515625, + pleasant_composition=0.028564453125, + pleasant_lighting=-0.00439453125, + pleasant_pattern=0.09088134765625, + pleasant_perspective=0.11859130859375, + pleasant_post_processing=0.00698089599609375, + pleasant_reflection=-0.01523590087890625, + pleasant_symmetry=0.01242828369140625, + sharply_focused_subject=0.08538818359375, + tastefully_blurred=0.022125244140625, + well_chosen_subject=0.05596923828125, + well_framed_subject=0.5986328125, + well_timed_shot=0.0134124755859375, + ), + "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4": ScoreInfo( + overall=0.853515625, + curation=0.75, + promotion=0.0, + highlight_visibility=0.05725190839694656, + behavioral=0.0, + failure=-0.0004916191101074219, + harmonious_color=0.382080078125, + immersiveness=0.0133209228515625, + interaction=0.03999999910593033, + interesting_subject=0.1632080078125, + intrusive_object_presence=-0.00966644287109375, + lively_color=0.44091796875, + low_light=0.01322174072265625, + noise=-0.0026721954345703125, + pleasant_camera_tilt=0.028045654296875, + pleasant_composition=0.33642578125, + pleasant_lighting=0.46142578125, + pleasant_pattern=0.1944580078125, + pleasant_perspective=0.494384765625, + pleasant_post_processing=0.4970703125, + pleasant_reflection=0.00910186767578125, + pleasant_symmetry=0.00930023193359375, + sharply_focused_subject=0.52490234375, + tastefully_blurred=0.63916015625, + well_chosen_subject=0.64208984375, + well_framed_subject=0.485595703125, + well_timed_shot=0.01531219482421875, + ), +} + + +@pytest.fixture +def photosdb(): + import osxphotos + + return osxphotos.PhotosDB(dbfile=PHOTOS_DB_5) + + +def test_score_info_v5(photosdb): + """ test score """ + # use math.isclose to compare floats + # on MacOS x64 these can probably compared for equality but would possibly + # fail if osxphotos ever ported to other platforms + for uuid in SCORE_DICT: + photo = photosdb.photos(uuid=[uuid], movies=True)[0] + for attr in photo.score.__dict__: + assert isclose(getattr(photo.score, attr), getattr(SCORE_DICT[uuid], attr)) + + +def test_score_info_v4(): + """ test version 4, score should be None """ + import osxphotos + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_4) + for photo in photosdb.photos(): + assert photo.score is None