Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0ec8620c7 | ||
|
|
10156e34b5 | ||
|
|
a714ae0af0 | ||
|
|
fc416ea0b7 | ||
|
|
2628c1f2d2 | ||
|
|
e482c3915a | ||
|
|
6baeae7ddd | ||
|
|
bea770b322 | ||
|
|
840e9937be | ||
|
|
002fce8e93 | ||
|
|
ef32b1e9bc | ||
|
|
6f29cda99f | ||
|
|
9fc4f76219 | ||
|
|
65b84ad345 | ||
|
|
cf4dca10c0 | ||
|
|
27040d1604 | ||
|
|
b91a9828fa | ||
|
|
8c10b61e90 | ||
|
|
b7f4b739de | ||
|
|
f8e62d8f5e | ||
|
|
da551036f9 | ||
|
|
d52b387a29 | ||
|
|
927e25911e | ||
|
|
6688d1ff64 | ||
|
|
3526881ec8 | ||
|
|
3f19276c5c | ||
|
|
091e7b8f2e | ||
|
|
1ef518cc3e | ||
|
|
a934b692ab | ||
|
|
9d820a0557 | ||
|
|
fcff8ec5f8 | ||
|
|
dfcbfa725a |
6
.github/workflows/pythonpackage.yml
vendored
@@ -1,6 +1,6 @@
|
||||
name: Python package
|
||||
name: Tests
|
||||
|
||||
on: [push]
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
python-version: [3.8]
|
||||
python-version: [3.7, 3.8]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
40
CHANGELOG.md
@@ -4,6 +4,46 @@ 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.30.13](https://github.com/RhetTbull/osxphotos/compare/v0.30.12...v0.30.13)
|
||||
|
||||
> 23 July 2020
|
||||
|
||||
- This reverts commit b7f4b739de978991def8ae2dca0f4e4b2881f56d, reversing [`#191`](https://github.com/RhetTbull/osxphotos/pull/191)
|
||||
- Fix findfiles not to fail on missing/invalid dir [`#192`](https://github.com/RhetTbull/osxphotos/pull/192)
|
||||
- Revert "Fix FileExistsError when filename differs only in case and export-as-hardlink (Bug#133)" [`#191`](https://github.com/RhetTbull/osxphotos/pull/191)
|
||||
- Fix FileExistsError when filename differs only in case and export-as-hardlink (Bug#133) [`#190`](https://github.com/RhetTbull/osxphotos/pull/190)
|
||||
- Fix findfiles not to fail on missing/invalid dir [`8c10b61`](https://github.com/RhetTbull/osxphotos/commit/8c10b61e90abbcfdff472bad4bb760558c7b850c)
|
||||
- Revert "Fix FileExistsError when filename differs only in case and export-as-hardlink (Bug#133)" [`f8e62d8`](https://github.com/RhetTbull/osxphotos/commit/f8e62d8f5ed26814f02383426237fd4c99a7ad04)
|
||||
- Fix FileExistsError when filename differs only in case and export-as-hardlink [`d52b387`](https://github.com/RhetTbull/osxphotos/commit/d52b387a294e68ebf0580a202ea70b97205560ef)
|
||||
- Version bump for bug fix [`cf4dca1`](https://github.com/RhetTbull/osxphotos/commit/cf4dca10c02d5f3f6132ab1572a698379b667e48)
|
||||
|
||||
#### [v0.30.12](https://github.com/RhetTbull/osxphotos/compare/v0.30.10...v0.30.12)
|
||||
|
||||
> 18 July 2020
|
||||
|
||||
- Implemented PersonInfo, closes #181 [`#181`](https://github.com/RhetTbull/osxphotos/issues/181)
|
||||
- Updated dependencies, now supports py36, py37, py38 [`6688d1f`](https://github.com/RhetTbull/osxphotos/commit/6688d1ff6491f2e7e155946b265ef8b5d8929441)
|
||||
- Update README.md [`3526881`](https://github.com/RhetTbull/osxphotos/commit/3526881ec872cc009b0d8936f366afcfff166d42)
|
||||
|
||||
#### [v0.30.10](https://github.com/RhetTbull/osxphotos/compare/v0.30.9...v0.30.10)
|
||||
|
||||
> 6 July 2020
|
||||
|
||||
- Bug fix for empty albums [`1ef518c`](https://github.com/RhetTbull/osxphotos/commit/1ef518cc3e9efbe9d4c16aa3d36c6dc6db86798e)
|
||||
|
||||
#### [v0.30.9](https://github.com/RhetTbull/osxphotos/compare/v0.30.7...v0.30.9)
|
||||
|
||||
> 6 July 2020
|
||||
|
||||
- Refactored person processing to enable implementation of #181 [`fcff8ec`](https://github.com/RhetTbull/osxphotos/commit/fcff8ec5f8286b28e7d8559b40b5808a7b59cc15)
|
||||
- AlbumInfo.photos now returns photos in album sort order [`9d820a0`](https://github.com/RhetTbull/osxphotos/commit/9d820a0557944340d0c664a6c3497d138c6100d5)
|
||||
|
||||
#### [v0.30.7](https://github.com/RhetTbull/osxphotos/compare/v0.30.6...v0.30.7)
|
||||
|
||||
> 4 July 2020
|
||||
|
||||
- Bug fix for keywords, persons in deleted photos [`df75a05`](https://github.com/RhetTbull/osxphotos/commit/df75a05645a88b31daa411f960d99ade71efc908)
|
||||
|
||||
#### [v0.30.6](https://github.com/RhetTbull/osxphotos/compare/v0.30.5...v0.30.6)
|
||||
|
||||
> 3 July 2020
|
||||
|
||||
165
README.md
@@ -18,6 +18,8 @@
|
||||
+ [FolderInfo](#folderinfo)
|
||||
+ [PlaceInfo](#placeinfo)
|
||||
+ [ScoreInfo](#scoreinfo)
|
||||
+ [PersonInfo](#personinfo)
|
||||
+ [FaceInfo](#faceinfo)
|
||||
+ [Template Substitutions](#template-substitutions)
|
||||
+ [Utility Functions](#utility-functions)
|
||||
* [Examples](#examples)
|
||||
@@ -35,9 +37,9 @@ OSXPhotos provides the ability to interact with and query Apple's Photos.app lib
|
||||
|
||||
## Supported operating systems
|
||||
|
||||
Only works on MacOS (aka Mac OS X). Tested on MacOS 10.12.6 / Photos 2.0, 10.13.6 / Photos 3.0, MacOS 10.14.5, 10.14.6 / Photos 4.0, MacOS 10.15.1 - 10.15.5 / Photos 5.0.
|
||||
Only works on MacOS (aka Mac OS X). Tested on MacOS 10.12.6 / Photos 2.0, 10.13.6 / Photos 3.0, MacOS 10.14.5, 10.14.6 / Photos 4.0, MacOS 10.15.1 - 10.15.6 / Photos 5.0.
|
||||
|
||||
Requires python >= 3.8. You can probably get this to run with Python 3.6 or 3.7 (see notes [below](#Installation-instructions)) but only 3.8+ is officially supported.
|
||||
Requires python >= 3.7.
|
||||
|
||||
This package will read Photos databases for any supported version on any supported OS version. E.g. you can read a database created with Photos 4.0 on MacOS 10.14 on a machine running MacOS 10.12.
|
||||
|
||||
@@ -48,11 +50,7 @@ OSXPhotos uses setuptools, thus simply run:
|
||||
|
||||
python3 setup.py install
|
||||
|
||||
If you're using python 3.6 or 3.7, you'll need to do this first to get around an issue with bpylist2:
|
||||
|
||||
pip install -r requirements.txt
|
||||
|
||||
You can also install directly from [pypi](https://pypi.org/) but you must use python >= 3.8 to avoid an error with bpylist2. The package currently works fine with python 3.6 or 3.7 but I know of no way to get `pip` to install the right dependencies.
|
||||
You can also install directly from [pypi](https://pypi.org/):
|
||||
|
||||
pip install osxphotos
|
||||
|
||||
@@ -140,6 +138,9 @@ Options:
|
||||
searches top level folders (e.g. does not
|
||||
look at subfolders)
|
||||
--uuid UUID Search for photos with UUID(s).
|
||||
--uuid-from-file FILE Search for photos with UUID(s) loaded from
|
||||
FILE. Format is a single UUID per line.
|
||||
Lines preceeded with # are ignored.
|
||||
--title TITLE Search for TITLE in title of photo.
|
||||
--no-title Search for photos with no title.
|
||||
--description DESC Search for DESC in description of photo.
|
||||
@@ -790,7 +791,15 @@ Returns a list names of top level folder names in the database.
|
||||
persons = photosdb.persons
|
||||
```
|
||||
|
||||
Returns a list of the persons (faces) found in the Photos library
|
||||
Returns a list of the person names (faces) found in the Photos library. **Note**: It is of course possible to have more than one person with the same name, e.g. "Maria Smith", in the database. `persons` assumes these are the same person and will list only one person named "Maria Smith". If you need more information about persons in the database, see [person_info](#dbpersoninfo).
|
||||
|
||||
#### <a name="dbpersoninfo">`person_info`</a>
|
||||
```python
|
||||
# assumes photosdb is a PhotosDB object (see above)
|
||||
person_info = photosdb.person_info
|
||||
```
|
||||
|
||||
Returns a list of [PersonInfo](#personinfo) objects representing persons who appear in photos in the database.
|
||||
|
||||
#### `keywords_as_dict`
|
||||
```python
|
||||
@@ -806,7 +815,8 @@ Returns a dictionary of keywords found in the Photos library where key is the ke
|
||||
persons_dict = photosdb.persons_as_dict
|
||||
```
|
||||
|
||||
Returns a dictionary of persons (faces) found in the Photos library where key is the person name and value is the count of how many times that person appears in the library (ie. how many photos are tagged with the person). Resulting dictionary is in reverse sorted order (e.g. person who appears in the most photos is listed first).
|
||||
Returns a dictionary of persons (faces) found in the Photos library where key is the person name and value is the count of how many times that person appears in the library (ie. how many photos are tagged with the person). Resulting dictionary is in reverse sorted order (e.g. person who appears in the most photos is listed first). **Note**: It is of course possible to have more than one person with the same name, e.g. "Maria Smith", in the database. `persons_as_dict` assumes these are the same person and will list only one person named "Maria Smith". If you need more information about persons in the database, see [person_info](#dbpersoninfo).
|
||||
|
||||
|
||||
#### `albums_as_dict`
|
||||
```python
|
||||
@@ -892,8 +902,7 @@ for row in results:
|
||||
|
||||
conn.close()
|
||||
```
|
||||
|
||||
#### ` photos(keywords=None, uuid=None, persons=None, albums=None, images=True, movies=True, from_date=None, to_date=None, intrash=False)`
|
||||
#### <A name="photos">`photos(keywords=None, uuid=None, persons=None, albums=None, images=True, movies=True, from_date=None, to_date=None, intrash=False)`</a>
|
||||
|
||||
```python
|
||||
# assumes photosdb is a PhotosDB object (see above)
|
||||
@@ -929,6 +938,8 @@ photos = photosdb.photos(
|
||||
- ```to_date```: datetime.datetime; if provided, finds photos where creation date <= to_date; default is None
|
||||
- ```intrash```: if True, finds only photos in the "Recently Deleted" or trash folder, if False does not find any photos in the trash; default is False
|
||||
|
||||
See also [get_photo()](#getphoto) which is much faster for retrieving a single photo.
|
||||
|
||||
If more than one of (keywords, uuid, persons, albums,from_date, to_date) is provided, they are treated as "and" criteria. E.g.
|
||||
|
||||
Finds all photos with (keyword = "wedding" or "birthday") and (persons = "Juan Rodriguez")
|
||||
@@ -1003,6 +1014,9 @@ For example, in my library, Photos says I have 19,386 photos and 474 movies. Ho
|
||||
>>>
|
||||
```
|
||||
|
||||
#### <a name="getphoto">`get_photo(uuid)`</A>
|
||||
Returns a single PhotoInfo instance for photo with UUID matching `uuid` or None if no photo is found matching `uuid`. If you know the UUID of a photo, `get_photo()` is much faster than `photos`. See also [photos()](#photos).
|
||||
|
||||
|
||||
### PhotoInfo
|
||||
PhotosDB.photos() returns a list of PhotoInfo objects. Each PhotoInfo object represents a single photo in the Photos library.
|
||||
@@ -1040,6 +1054,12 @@ Returns a list of [AlbumInfo](#AlbumInfo) objects representing the albums the ph
|
||||
#### `persons`
|
||||
Returns a list of the names of the persons in the photo
|
||||
|
||||
#### <a name="photopersoninfo">`person_info`</a>
|
||||
Returns a list of [PersonInfo](#personinfo) objects representing persons in the photo. Each PersonInfo object is associated with one or more FaceInfo objects.
|
||||
|
||||
#### <a name="photofaceinfo">`face_info`</a>
|
||||
Returns a list of [FaceInfo](#faceinfo) objects representing faces in the photo. Each face is associated with the a PersonInfo object.
|
||||
|
||||
#### `path`
|
||||
Returns the absolute path to the photo on disk as a string. **Note**: this returns the path to the *original* unedited file (see [hasadjustments](#hasadjustments)). If the file is missing on disk, path=`None` (see [ismissing](#ismissing)).
|
||||
|
||||
@@ -1353,8 +1373,8 @@ Returns the universally unique identifier (uuid) of the album. This is how Phot
|
||||
#### `title`
|
||||
Returns the title or name of the album.
|
||||
|
||||
#### `photos`
|
||||
Returns a list of [PhotoInfo](#PhotoInfo) objects representing each photo contained in the album.
|
||||
#### <a name="albumphotos">`photos`</a>
|
||||
Returns a list of [PhotoInfo](#PhotoInfo) objects representing each photo contained in the album sorted in the same order as in Photos. (e.g. if photos were manually sorted in the Photos albums, photos returned by `photos` will be in same order as they appear in the Photos album)
|
||||
|
||||
#### `folder_list`
|
||||
Returns a hierarchical list of [FolderInfo](#FolderInfo) objects representing the folders the album is contained in. For example, if album "AlbumInFolder" is in SubFolder2 of Folder1 as illustrated below, would return a list of `FolderInfo` objects representing ["Folder1", "SubFolder2"]
|
||||
@@ -1528,6 +1548,118 @@ Example: find your "best" photo of food
|
||||
>>> best_food_photo = sorted([p for p in photos if "food" in p.labels_normalized], key=lambda p: p.score.overall, reverse=True)[0]
|
||||
```
|
||||
|
||||
### PersonInfo
|
||||
[PhotosDB.person_info](#dbpersoninfo) and [PhotoInfo.person_info](#photopersoninfo) return a list of PersonInfo objects represents persons in the database and in a photo, respectively. The PersonInfo class has the following properties and methods.
|
||||
|
||||
#### `name`
|
||||
Returns the full name of the person represented in the photo. For example, "Maria Smith".
|
||||
|
||||
#### `display_name`
|
||||
Returns the display name of the person represented in the photo. For example, "Maria".
|
||||
|
||||
#### `uuid`
|
||||
Returns the UUID of the person as stored in the Photos library database.
|
||||
|
||||
#### `keyphoto`
|
||||
Returns a PhotoInfo instance for the photo designated as the key photo for the person. This is the Photos uses to display the person's face thumbnail in Photos' "People" view.
|
||||
|
||||
#### `facecount`
|
||||
Returns a count of how many times this person appears in images in the database.
|
||||
|
||||
#### <a name="personphotos">`photos`</a>
|
||||
Returns a list of PhotoInfo objects representing all photos the person appears in.
|
||||
|
||||
#### <a name="personfaceinfo">`face_info`</a>
|
||||
Returns a list of [FaceInfo](#faceinfo) objects associated with this person sorted by quality score. Highest quality face is result[0] and lowest quality face is result[n].
|
||||
|
||||
#### `json()`
|
||||
Returns a json string representation of the PersonInfo instance.
|
||||
|
||||
### FaceInfo
|
||||
[PhotoInfo.face_info](#photofaceinfo) return a list of FaceInfo objects representing detected faces in a photo. The FaceInfo class has the following properties and methods.
|
||||
|
||||
#### `uuid`
|
||||
UUID of the face.
|
||||
|
||||
#### `name`
|
||||
Full name of the person represented by the face or None if person hasn't been given a name in Photos. This is a shortcut for `FaceInfo.person_info.name`.
|
||||
|
||||
#### `asset_uuid`
|
||||
UUID of the photo this face is associated with.
|
||||
|
||||
#### `person_info`
|
||||
[PersonInfo](#personinfo) object associated with this face.
|
||||
|
||||
#### `photo`
|
||||
[PhotoInfo](#photoinfo) object representing the photo that contains this face.
|
||||
|
||||
#### `face_rect()`
|
||||
Returns list of x, y coordinates as tuples `[(x0, y0), (x1, y1)]` representing the corners of rectangular region that contains the face. Coordinates are in same format and [reference frame](https://pillow.readthedocs.io/en/stable/handbook/concepts.html#coordinate-system) as used by [Pillow](https://pypi.org/project/Pillow/) imaging library. **Note**: face_rect() and all other properties/methods that return coordinates refer to the *current version* of the image. E.g. if the image has been edited ([`PhotoInfo.hasadjustments`](#hasadjustments)), these refer to [`PhotoInfo.path_edited`](#pathedited). If the image has no adjustments, these coordinates refer to the original photo ([`PhotoInfo.path`](#path)).
|
||||
|
||||
#### `center`
|
||||
Coordinates as (x, y) tuple for the center of the detected face.
|
||||
|
||||
#### `mouth`
|
||||
Coordinates as (x, y) tuple for the mouth of the detected face.
|
||||
|
||||
#### `left_eye`
|
||||
Coordinates as (x, y) tuple for the left eye of the detected face.
|
||||
|
||||
#### `right_eye`
|
||||
Coordinates as (x, y) tuple for the right eye of the detected face.
|
||||
|
||||
#### `size_pixels`
|
||||
Diameter of detected face region in pixels.
|
||||
|
||||
#### `roll_pitch_yaw()`
|
||||
Roll, pitch, and yaw of face region in radians. Returns a tuple of (roll, pitch, yaw)
|
||||
|
||||
#### roll
|
||||
Roll of face region in radians.
|
||||
|
||||
#### pitch
|
||||
Pitch of face region in radians.
|
||||
|
||||
#### yaw
|
||||
Yaw of face region in radians.
|
||||
|
||||
#### `Additional properties`
|
||||
The following additional properties are also available but are not yet fully documented.
|
||||
|
||||
- `center_x`: x coordinate of center of face in Photos' internal reference frame
|
||||
- `center_y`: y coordinate of center of face in Photos' internal reference frame
|
||||
- `mouth_x`: x coordinate of mouth in Photos' internal reference frame
|
||||
- `mouth_y`: y coordinate of mouth in Photos' internal reference frame
|
||||
- `left_eye_x`: x coordinate of left eye in Photos' internal reference frame
|
||||
- `left_eye_y`: y coordinate of left eye in Photos' internal reference frame
|
||||
- `right_eye_x`: x coordinate of right eye in Photos' internal reference frame
|
||||
- `right_eye_y`: y coordinate of right eye in Photos' internal reference frame
|
||||
- `size`: size of face region in Photos' internal reference frame
|
||||
- `quality`: quality measure of detected face
|
||||
- `source_width`: width in pixels of photo
|
||||
- `source_height`: height in pixels of photo
|
||||
- `has_smile`:
|
||||
- `left_eye_closed`:
|
||||
- `right_eye_closed`:
|
||||
- `manual`:
|
||||
- `face_type`:
|
||||
- `age_type`:
|
||||
- `bald_type`:
|
||||
- `eye_makeup_type`:
|
||||
- `eye_state`:
|
||||
- `facial_hair_type`:
|
||||
- `gender_type`:
|
||||
- `glasses_type`:
|
||||
- `hair_color_type`:
|
||||
- `lip_makeup_type`:
|
||||
- `smile_type`:
|
||||
|
||||
#### `asdict()`
|
||||
Returns a dictionary representation of the FaceInfo instance.
|
||||
|
||||
#### `json()`
|
||||
Returns a JSON representation of the FaceInfo instance.
|
||||
|
||||
### Template Substitutions
|
||||
|
||||
The following substitutions are availabe for use with `PhotoInfo.render_template()`
|
||||
@@ -1692,10 +1824,11 @@ Testing against "real world" Photos libraries would be especially helpful. If y
|
||||
|
||||
## Known Bugs
|
||||
|
||||
My goal is make osxphotos as reliable and comprehensive as possible. The test suite currently has over 400 tests--but there are still some [bugs](https://github.com/RhetTbull/osxphotos/issues?q=is%3Aissue+is%3Aopen+label%3Abug) or incomplete features lurking. If you find bugs please open an [issue](https://github.com/RhetTbull/osxphotos/issues). Notable issues include:
|
||||
My goal is make osxphotos as reliable and comprehensive as possible. The test suite currently has over 600 tests--but there are still some [bugs](https://github.com/RhetTbull/osxphotos/issues?q=is%3Aissue+is%3Aopen+label%3Abug) or incomplete features lurking. If you find bugs please open an [issue](https://github.com/RhetTbull/osxphotos/issues). Notable issues include:
|
||||
|
||||
- RAW images imported to Photos with an associated jpeg preview are not handled correctly by osxphotos. osxphotos query and export will operate on the jpeg preview instead of the RAW image as will `PhotoInfo.path`. If the user selects "Use RAW as original" in Photos, the RAW image will be exported or operated on but the jpeg will be ignored. See [Issue #101](https://github.com/RhetTbull/osxphotos/issues/101) Note: Beta version of fix for this bug is implemented in the current version of osxphotos.
|
||||
- The `--download-missing` option for `osxphotos export` does not work correctly with burst images. It will download the primary image but not the other burst images. See [Issue #75](https://github.com/RhetTbull/osxphotos/issues/75)
|
||||
- Face coordinates (mouth, left eye, right eye) may not be correct for images where the head is tilted. See [Issue #196](https://github.com/RhetTbull/osxphotos/issues/196).
|
||||
- RAW images imported to Photos with an associated jpeg preview are not handled correctly by osxphotos. osxphotos query and export will operate on the jpeg preview instead of the RAW image as will `PhotoInfo.path`. If the user selects "Use RAW as original" in Photos, the RAW image will be exported or operated on but the jpeg will be ignored. See [Issue #101](https://github.com/RhetTbull/osxphotos/issues/101). Note: Beta version of fix for this bug is implemented in the current version of osxphotos.
|
||||
- The `--download-missing` option for `osxphotos export` does not work correctly with burst images. It will download the primary image but not the other burst images. See [Issue #75](https://github.com/RhetTbull/osxphotos/issues/75).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ def export(export_path, default_album, library_path, edited):
|
||||
exported = p.export(dest_dir, filename)
|
||||
click.echo(f"Exported {filename} to {exported}")
|
||||
else:
|
||||
click.echo(f"Skipping missing photo: {p.original_filename} in album {album}")
|
||||
click.echo(f"Skipping missing photo: {p.original_filename}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
83
examples/export_faces.py
Normal file
@@ -0,0 +1,83 @@
|
||||
""" Export all photos that contain a detected face and draw rectangles around each face
|
||||
photos with no persons/detected faces will not be export
|
||||
|
||||
This shows how to use the FaceInfo class and is useful for validating that FaceInfo is
|
||||
correctly handling faces.
|
||||
|
||||
To use this, you'll need to install Pillow:
|
||||
python3 -m pip install Pillow
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import click
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
import osxphotos
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("export-path", type=click.Path(exists=True))
|
||||
@click.option(
|
||||
"--uuid",
|
||||
metavar="UUID",
|
||||
help="Limit export to optional UUID(s)",
|
||||
required=False,
|
||||
multiple=True,
|
||||
)
|
||||
@click.option(
|
||||
"--library-path",
|
||||
metavar="PATH",
|
||||
help="Path to Photos library, default to last used library",
|
||||
default=None,
|
||||
)
|
||||
def export(export_path, library_path, uuid):
|
||||
""" export photos to export_path and draw faces """
|
||||
library_path = os.path.expanduser(library_path) if library_path else None
|
||||
if library_path is not None:
|
||||
photosdb = osxphotos.PhotosDB(library_path)
|
||||
else:
|
||||
photosdb = osxphotos.PhotosDB()
|
||||
|
||||
photos = photosdb.photos(uuid=uuid) if uuid else photosdb.photos(movies=False)
|
||||
for p in photos:
|
||||
if p.person_info and not p.ismissing:
|
||||
# has persons and not missing
|
||||
if "heic" in p.filename.lower():
|
||||
print(f"skipping heic image {p.filename}")
|
||||
continue
|
||||
print(f"exporting photo {p.original_filename}, uuid = {p.uuid}")
|
||||
export = p.export(export_path, p.original_filename, edited=p.hasadjustments)
|
||||
if export:
|
||||
im = Image.open(export[0])
|
||||
draw = ImageDraw.Draw(im)
|
||||
for face in p.face_info:
|
||||
coords = face.face_rect()
|
||||
draw.rectangle(coords, width=3)
|
||||
draw.ellipse(get_circle_points(face.center, 3), width=1)
|
||||
draw.text(face.mouth, "M", fill=(255, 255, 255, 255))
|
||||
draw.text(face.left_eye, "L", fill=(255, 255, 255, 255))
|
||||
draw.text(face.right_eye, "R", fill=(255, 255, 255, 255))
|
||||
im.save(export[0])
|
||||
else:
|
||||
print(f"no photos exported for {p.uuid}")
|
||||
|
||||
|
||||
def get_circle_points(xy, radius):
|
||||
""" Returns tuples of (x0, y0), (x1, y1) for a circle centered at x, y with radius
|
||||
|
||||
Arguments:
|
||||
xy: tuple of x, y coordinates
|
||||
radius: radius of circle to draw
|
||||
|
||||
Returns:
|
||||
[(x0, y0), (x1, y1)] for bounding box of circle centered at x, y
|
||||
"""
|
||||
x, y = xy
|
||||
x0, y0 = x - radius, y - radius
|
||||
x1, y1 = x + radius, y + radius
|
||||
return [(x0, y0), (x1, y1)]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
export() # pylint: disable=no-value-for-parameter
|
||||
@@ -58,5 +58,6 @@ if __name__ == "__main__":
|
||||
print("getting photos")
|
||||
tic = time.perf_counter()
|
||||
photos = photosdb.photos(images=True, movies=True)
|
||||
photos.extend(photosdb.photos(images=True, movies=True, intrash=True))
|
||||
toc = time.perf_counter()
|
||||
print(f"found {len(photos)} photos in {toc-tic} seconds")
|
||||
|
||||
@@ -77,6 +77,20 @@ def get_photos_db(*db_options):
|
||||
return None
|
||||
|
||||
|
||||
class DateTimeISO8601(click.ParamType):
|
||||
|
||||
name = "DATETIME"
|
||||
|
||||
def convert(self, value, param, ctx):
|
||||
try:
|
||||
return datetime.datetime.fromisoformat(value)
|
||||
except:
|
||||
self.fail(
|
||||
f"Invalid value for --{param.name}: invalid datetime format {value}. "
|
||||
"Valid format: YYYY-MM-DD[*HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]]]"
|
||||
)
|
||||
|
||||
|
||||
# Click CLI object & context settings
|
||||
class CLI_Obj:
|
||||
def __init__(self, db=None, json=False, debug=False):
|
||||
@@ -298,6 +312,15 @@ def query_options(f):
|
||||
multiple=True,
|
||||
help="Search for photos with UUID(s).",
|
||||
),
|
||||
o(
|
||||
"--uuid-from-file",
|
||||
metavar="FILE",
|
||||
default=None,
|
||||
multiple=False,
|
||||
help="Search for photos with UUID(s) loaded from FILE. "
|
||||
"Format is a single UUID per line. Lines preceeded with # are ignored.",
|
||||
type=click.Path(exists=True),
|
||||
),
|
||||
o(
|
||||
"--title",
|
||||
metavar="TITLE",
|
||||
@@ -445,13 +468,13 @@ def query_options(f):
|
||||
),
|
||||
o(
|
||||
"--from-date",
|
||||
help="Search by start item date, e.g. 2000-01-12T12:00:00 or 2000-12-31 (ISO 8601 w/o TZ).",
|
||||
type=click.DateTime(),
|
||||
help="Search by start item date, e.g. 2000-01-12T12:00:00, 2001-01-12T12:00:00-07:00, or 2000-12-31 (ISO 8601).",
|
||||
type=DateTimeISO8601(),
|
||||
),
|
||||
o(
|
||||
"--to-date",
|
||||
help="Search by end item date, e.g. 2000-01-12T12:00:00 or 2000-12-31 (ISO 8601 w/o TZ).",
|
||||
type=click.DateTime(),
|
||||
help="Search by end item date, e.g. 2000-01-12T12:00:00, 2001-01-12T12:00:00-07:00, or 2000-12-31 (ISO 8601).",
|
||||
type=DateTimeISO8601(),
|
||||
),
|
||||
]
|
||||
for o in options[::-1]:
|
||||
@@ -520,10 +543,14 @@ def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid):
|
||||
print("_dbkeywords_uuid:")
|
||||
pprint.pprint(photosdb._dbkeywords_uuid)
|
||||
elif attr == "persons":
|
||||
print("_dbfaces_person:")
|
||||
pprint.pprint(photosdb._dbfaces_person)
|
||||
print("_dbfaces_uuid:")
|
||||
pprint.pprint(photosdb._dbfaces_uuid)
|
||||
print("_dbfaces_pk:")
|
||||
pprint.pprint(photosdb._dbfaces_pk)
|
||||
print("_dbpersons_pk:")
|
||||
pprint.pprint(photosdb._dbpersons_pk)
|
||||
print("_dbpersons_fullname:")
|
||||
pprint.pprint(photosdb._dbpersons_fullname)
|
||||
elif attr == "photos":
|
||||
if uuid:
|
||||
for uuid_ in uuid:
|
||||
@@ -887,6 +914,7 @@ def query(
|
||||
album,
|
||||
folder,
|
||||
uuid,
|
||||
uuid_from_file,
|
||||
title,
|
||||
no_title,
|
||||
description,
|
||||
@@ -950,6 +978,7 @@ def query(
|
||||
album,
|
||||
folder,
|
||||
uuid,
|
||||
uuid_from_file,
|
||||
edited,
|
||||
external_edit,
|
||||
uti,
|
||||
@@ -994,6 +1023,12 @@ def query(
|
||||
if only_photos:
|
||||
ismovie = False
|
||||
|
||||
# load UUIDs if necessary and append to any uuids passed with --uuid
|
||||
if uuid_from_file:
|
||||
uuid_list = list(uuid) # Click option is a tuple
|
||||
uuid_list.extend(load_uuid_from_file(uuid_from_file))
|
||||
uuid = tuple(uuid_list)
|
||||
|
||||
# below needed for to make CliRunner work for testing
|
||||
cli_db = cli_obj.db if cli_obj is not None else None
|
||||
db = get_photos_db(*photos_library, db, cli_db)
|
||||
@@ -1243,6 +1278,7 @@ def export(
|
||||
album,
|
||||
folder,
|
||||
uuid,
|
||||
uuid_from_file,
|
||||
title,
|
||||
no_title,
|
||||
description,
|
||||
@@ -1325,7 +1361,7 @@ def export(
|
||||
VERBOSE = True if verbose_ else False
|
||||
|
||||
if not os.path.isdir(dest):
|
||||
sys.exit("DEST must be valid path")
|
||||
sys.exit(f"DEST {dest} must be valid path")
|
||||
|
||||
# sanity check input args
|
||||
exclusive = [
|
||||
@@ -1377,6 +1413,12 @@ def export(
|
||||
if only_photos:
|
||||
ismovie = False
|
||||
|
||||
# load UUIDs if necessary and append to any uuids passed with --uuid
|
||||
if uuid_from_file:
|
||||
uuid_list = list(uuid) # Click option is a tuple
|
||||
uuid_list.extend(load_uuid_from_file(uuid_from_file))
|
||||
uuid = tuple(uuid_list)
|
||||
|
||||
# below needed for to make CliRunner work for testing
|
||||
cli_db = cli_obj.db if cli_obj is not None else None
|
||||
db = get_photos_db(*photos_library, db, cli_db)
|
||||
@@ -2087,6 +2129,12 @@ def export_photo(
|
||||
)
|
||||
return ExportResults([], [], [], [], [])
|
||||
|
||||
results_exported = []
|
||||
results_new = []
|
||||
results_updated = []
|
||||
results_skipped = []
|
||||
results_exif_updated = []
|
||||
|
||||
filenames = get_filenames_from_template(photo, filename_template, original_name)
|
||||
for filename in filenames:
|
||||
verbose(f"Exporting {photo.filename} as {filename}")
|
||||
@@ -2109,11 +2157,6 @@ def export_photo(
|
||||
)
|
||||
|
||||
# export the photo to each path in dest_paths
|
||||
results_exported = []
|
||||
results_new = []
|
||||
results_updated = []
|
||||
results_skipped = []
|
||||
results_exif_updated = []
|
||||
for dest_path in dest_paths:
|
||||
export_results = photo.export2(
|
||||
dest_path,
|
||||
@@ -2335,5 +2378,32 @@ def find_files_in_branch(pathname, filename):
|
||||
return files
|
||||
|
||||
|
||||
def load_uuid_from_file(filename):
|
||||
""" Load UUIDs from file. Does not validate UUIDs.
|
||||
Format is 1 UUID per line, any line beginning with # is ignored.
|
||||
Whitespace is stripped.
|
||||
|
||||
Arguments:
|
||||
filename: file name of the file containing UUIDs
|
||||
|
||||
Returns:
|
||||
list of UUIDs or empty list of no UUIDs in file
|
||||
|
||||
Raises:
|
||||
FileNotFoundError if file does not exist
|
||||
"""
|
||||
|
||||
if not pathlib.Path(filename).is_file():
|
||||
raise FileNotFoundError(f"Could not find file {filename}")
|
||||
|
||||
uuid = []
|
||||
with open(filename, "r") as uuid_file:
|
||||
for line in uuid_file:
|
||||
line = line.strip()
|
||||
if len(line) and line[0] != "#":
|
||||
uuid.append(line)
|
||||
return uuid
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli() # pylint: disable=no-value-for-parameter
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.30.7"
|
||||
__version__ = "0.31.2"
|
||||
|
||||
@@ -48,8 +48,18 @@ class AlbumInfo:
|
||||
try:
|
||||
return self._photos
|
||||
except AttributeError:
|
||||
uuid = self._db._dbalbums_album[self._uuid]
|
||||
self._photos = self._db.photos(uuid=uuid)
|
||||
if self.uuid in self._db._dbalbums_album:
|
||||
uuid, sort_order = zip(*self._db._dbalbums_album[self.uuid])
|
||||
self._photos = self._db.photos(uuid=uuid)
|
||||
# PhotosDB.photos does not preserve order when passing in list of uuids
|
||||
# so need to build photo list one a time
|
||||
# sort uuids by sort order
|
||||
sorted_uuid = sorted(zip(sort_order, uuid))
|
||||
self._photos = [
|
||||
self._db.photos(uuid=[uuid])[0] for _, uuid in sorted_uuid
|
||||
]
|
||||
else:
|
||||
self._photos = []
|
||||
return self._photos
|
||||
|
||||
@property
|
||||
|
||||
57
osxphotos/datetime_utils.py
Normal file
@@ -0,0 +1,57 @@
|
||||
""" datetime utilities """
|
||||
|
||||
import datetime
|
||||
|
||||
def get_local_tz():
|
||||
""" return local timezone as datetime.timezone tzinfo """
|
||||
local_tz = (
|
||||
datetime.datetime.now(datetime.timezone(datetime.timedelta(0)))
|
||||
.astimezone()
|
||||
.tzinfo
|
||||
)
|
||||
return local_tz
|
||||
|
||||
|
||||
def datetime_remove_tz(dt):
|
||||
""" remove timezone from a datetime.datetime object
|
||||
dt: datetime.datetime object with tzinfo
|
||||
returns: dt without any timezone info (naive datetime object) """
|
||||
|
||||
if type(dt) != datetime.datetime:
|
||||
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||
|
||||
dt_new = dt.replace(tzinfo=None)
|
||||
return dt_new
|
||||
|
||||
|
||||
def datetime_has_tz(dt):
|
||||
""" return True if datetime dt has tzinfo else False
|
||||
dt: datetime.datetime
|
||||
returns True if dt is timezone aware, else False """
|
||||
|
||||
if type(dt) != datetime.datetime:
|
||||
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||
|
||||
if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def datetime_naive_to_local(dt):
|
||||
""" convert naive (timezone unaware) datetime.datetime
|
||||
to aware timezone in local timezone
|
||||
dt: datetime.datetime without timezone
|
||||
returns: datetime.datetime with local timezone """
|
||||
|
||||
if type(dt) != datetime.datetime:
|
||||
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||
|
||||
if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None:
|
||||
# has timezone info
|
||||
raise ValueError(
|
||||
"dt must be naive/timezone unaware: "
|
||||
f"{dt} has tzinfo {dt.tzinfo} and offset {dt.tizinfo.utcoffset(dt)}"
|
||||
)
|
||||
|
||||
dt_local = dt.replace(tzinfo=get_local_tz())
|
||||
return dt_local
|
||||
408
osxphotos/personinfo.py
Normal file
@@ -0,0 +1,408 @@
|
||||
""" PhotoInfo and FaceInfo classes to expose info about persons and faces in the Photos library """
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
|
||||
|
||||
class PersonInfo:
|
||||
""" Info about a person in the Photos library
|
||||
"""
|
||||
|
||||
def __init__(self, db=None, pk=None):
|
||||
""" 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 = db
|
||||
self._pk = 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 []
|
||||
|
||||
def json(self):
|
||||
""" Returns JSON representation of class instance """
|
||||
keyphoto = self.keyphoto.uuid if self.keyphoto is not None else None
|
||||
person = {
|
||||
"uuid": self.uuid,
|
||||
"name": self.name,
|
||||
"displayname": self.display_name,
|
||||
"keyface": self.keyface,
|
||||
"facecount": self.facecount,
|
||||
"keyphoto": keyphoto,
|
||||
}
|
||||
return json.dumps(person)
|
||||
|
||||
def __str__(self):
|
||||
return f"PersonInfo(name={self.name}, display_name={self.display_name}, uuid={self.uuid}, facecount={self.facecount})"
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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.mouth_x = face["mouthx"]
|
||||
self.mouth_y = face["mouthy"]
|
||||
self.left_eye_x = face["lefteyex"]
|
||||
self.left_eye_y = face["lefteyey"]
|
||||
self.right_eye_x = face["righteyex"]
|
||||
self.right_eye_y = face["righteyey"]
|
||||
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.left_eye_closed = face["left_eye_closed"]
|
||||
self.right_eye_closed = face["right_eye_closed"]
|
||||
self.manual = face["manual"]
|
||||
self.face_type = face["facetype"]
|
||||
self.age_type = face["agetype"]
|
||||
self.bald_type = face["baldtype"]
|
||||
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 mouth(self):
|
||||
""" Coordinates, in PIL format, for mouth position
|
||||
|
||||
Returns:
|
||||
tuple of coordinates in form (x, y)
|
||||
"""
|
||||
return self._make_point_with_rotation((self.mouth_x, self.mouth_y))
|
||||
|
||||
@property
|
||||
def left_eye(self):
|
||||
""" Coordinates, in PIL format, for left eye position
|
||||
|
||||
Returns:
|
||||
tuple of coordinates in form (x, y)
|
||||
"""
|
||||
return self._make_point_with_rotation((self.left_eye_x, self.left_eye_y))
|
||||
|
||||
@property
|
||||
def right_eye(self):
|
||||
""" Coordinates, in PIL format, for right eye position
|
||||
|
||||
Returns:
|
||||
tuple of coordinates in form (x, y)
|
||||
"""
|
||||
return self._make_point_with_rotation((self.right_eye_x, self.right_eye_y))
|
||||
|
||||
@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
|
||||
|
||||
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)]
|
||||
|
||||
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 _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 = xy
|
||||
dx = self.photo.width
|
||||
dy = self.photo.height
|
||||
if orientation in [1, 2]:
|
||||
y = 1.0 - y
|
||||
elif orientation in [3, 4]:
|
||||
x = 1.0 - x
|
||||
elif orientation in [5, 6]:
|
||||
x, y = 1.0 - y, 1.0 - x
|
||||
dx, dy = dy, dx
|
||||
elif orientation in [7, 8]:
|
||||
x, y = y, x
|
||||
dx, dy = dy, dx
|
||||
else:
|
||||
logging.warning(f"Unhandled orientation: {orientation}")
|
||||
|
||||
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))
|
||||
|
||||
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,
|
||||
"mouth_x": self.mouth_x,
|
||||
"mouth_y": self.mouth_y,
|
||||
"mouth": self.mouth,
|
||||
"left_eye_x": self.left_eye_x,
|
||||
"left_eye_y": self.left_eye_y,
|
||||
"left_eye": self.left_eye,
|
||||
"right_eye_x": self.right_eye_x,
|
||||
"right_eye_y": self.right_eye_y,
|
||||
"right_eye": self.right_eye,
|
||||
"size": self.size,
|
||||
"face_rect": self.face_rect(),
|
||||
"roll": roll,
|
||||
"pitch": pitch,
|
||||
"yaw": yaw,
|
||||
"quality": self.quality,
|
||||
"source_width": self.source_width,
|
||||
"source_height": self.source_height,
|
||||
"has_smile": self.has_smile,
|
||||
"left_eye_closed": self.left_eye_closed,
|
||||
"right_eye_closed": self.right_eye_closed,
|
||||
"manual": self.manual,
|
||||
"face_type": self.face_type,
|
||||
"age_type": self.age_type,
|
||||
"bald_type": self.bald_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,
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
|
||||
from ..exiftool import ExifTool, get_exiftool_path
|
||||
|
||||
|
||||
@property
|
||||
def exiftool(self):
|
||||
""" Returns an ExifTool object for the photo
|
||||
@@ -26,8 +27,9 @@ def exiftool(self):
|
||||
except FileNotFoundError:
|
||||
# get_exiftool_path raises FileNotFoundError if exiftool not found
|
||||
exiftool = None
|
||||
logging.warning(f"exiftool not in path; download and install from https://exiftool.org/")
|
||||
logging.warning(
|
||||
f"exiftool not in path; download and install from https://exiftool.org/"
|
||||
)
|
||||
|
||||
self._exiftool = exiftool
|
||||
return self._exiftool
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ from .._constants import (
|
||||
from .._export_db import ExportDBNoOp
|
||||
from ..exiftool import ExifTool
|
||||
from ..fileutil import FileUtil
|
||||
from ..utils import dd_to_dms_str
|
||||
from ..utils import dd_to_dms_str, findfiles
|
||||
|
||||
ExportResults = namedtuple(
|
||||
"ExportResults", ["exported", "new", "updated", "skipped", "exif_updated"]
|
||||
@@ -428,11 +428,10 @@ def export2(
|
||||
# dest will be file1 (1).jpeg even though file1.jpeg doesn't exist to prevent sidecar collision
|
||||
if not update and increment and not overwrite:
|
||||
count = 1
|
||||
glob_str = str(dest.parent / f"{dest.stem}*")
|
||||
dest_files = glob.glob(glob_str)
|
||||
dest_files = [pathlib.Path(f).stem for f in dest_files]
|
||||
dest_files = findfiles(f"{dest.stem}*", str(dest.parent))
|
||||
dest_files = [pathlib.Path(f).stem.lower() for f in dest_files]
|
||||
dest_new = dest.stem
|
||||
while dest_new in dest_files:
|
||||
while dest_new.lower() in dest_files:
|
||||
dest_new = f"{dest.stem} ({count})"
|
||||
count += 1
|
||||
dest = dest.parent / f"{dest_new}{dest.suffix}"
|
||||
|
||||
@@ -29,6 +29,7 @@ from .._constants import (
|
||||
_PHOTOS_5_SHARED_PHOTO_PATH,
|
||||
)
|
||||
from ..albuminfo import AlbumInfo
|
||||
from ..personinfo import FaceInfo, PersonInfo
|
||||
from ..phototemplate import PhotoTemplate
|
||||
from ..placeinfo import PlaceInfo4, PlaceInfo5
|
||||
from ..utils import _debug, _get_resource_loc, findfiles, get_preferred_uti_extension
|
||||
@@ -86,12 +87,8 @@ class PhotoInfo:
|
||||
@property
|
||||
def date(self):
|
||||
""" image creation date as timezone aware datetime object """
|
||||
imagedate = self._info["imageDate"]
|
||||
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
|
||||
delta = timedelta(seconds=seconds)
|
||||
tz = timezone(delta)
|
||||
return imagedate.astimezone(tz=tz)
|
||||
|
||||
return self._info["imageDate"]
|
||||
|
||||
@property
|
||||
def date_modified(self):
|
||||
""" image modification date as timezone aware datetime object
|
||||
@@ -339,7 +336,32 @@ class PhotoInfo:
|
||||
@property
|
||||
def persons(self):
|
||||
""" list of persons in picture """
|
||||
return self._info["persons"]
|
||||
return [self._db._dbpersons_pk[pk]["fullname"] for pk in self._info["persons"]]
|
||||
|
||||
@property
|
||||
def person_info(self):
|
||||
""" list of PersonInfo objects for person in picture """
|
||||
try:
|
||||
return self._personinfo
|
||||
except AttributeError:
|
||||
self._personinfo = [
|
||||
PersonInfo(db=self._db, pk=pk) for pk in self._info["persons"]
|
||||
]
|
||||
return self._personinfo
|
||||
|
||||
@property
|
||||
def face_info(self):
|
||||
""" list of FaceInfo objects for faces in picture """
|
||||
try:
|
||||
return self._faceinfo
|
||||
except AttributeError:
|
||||
try:
|
||||
faces = self._db._db_faceinfo_uuid[self._uuid]
|
||||
self._faceinfo = [FaceInfo(db=self._db, pk=pk) for pk in faces]
|
||||
except KeyError:
|
||||
# no faces
|
||||
self._faceinfo = []
|
||||
return self._faceinfo
|
||||
|
||||
@property
|
||||
def albums(self):
|
||||
|
||||
@@ -34,7 +34,7 @@ def _process_exifinfo_5(photosdb):
|
||||
photosdb: PhotosDB instance """
|
||||
|
||||
db = photosdb._tmp_db
|
||||
|
||||
|
||||
(conn, cursor) = _open_sql_file(db)
|
||||
|
||||
result = conn.execute(
|
||||
@@ -54,3 +54,5 @@ def _process_exifinfo_5(photosdb):
|
||||
if uuid in photosdb._db_exifinfo_uuid:
|
||||
logging.warning(f"duplicate exifinfo record found for uuid {uuid}")
|
||||
photosdb._db_exifinfo_uuid[uuid] = record
|
||||
|
||||
conn.close()
|
||||
|
||||
328
osxphotos/photosdb/_photosdb_process_faceinfo.py
Normal file
@@ -0,0 +1,328 @@
|
||||
""" Methods for PhotosDB to add Photos face info
|
||||
"""
|
||||
|
||||
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_faceinfo: process photo face info
|
||||
|
||||
The following data structures are added to PhotosDB
|
||||
self._db_faceinfo_pk: {pk: {faceinfo}}
|
||||
self._db_faceinfo_uuid: {photo uuid: [face pk]}
|
||||
self._db_faceinfo_person: {person_pk: [face_pk]}
|
||||
"""
|
||||
|
||||
|
||||
def _process_faceinfo(self):
|
||||
""" Process face information
|
||||
"""
|
||||
|
||||
self._db_faceinfo_pk = {}
|
||||
self._db_faceinfo_uuid = {}
|
||||
self._db_faceinfo_person = {}
|
||||
|
||||
if self._db_version <= _PHOTOS_4_VERSION:
|
||||
_process_faceinfo_4(self)
|
||||
else:
|
||||
_process_faceinfo_5(self)
|
||||
|
||||
|
||||
def _process_faceinfo_4(photosdb):
|
||||
""" Process face information for Photos 4 databases
|
||||
|
||||
Args:
|
||||
photosdb: an OSXPhotosDB instance
|
||||
"""
|
||||
db = photosdb._tmp_db
|
||||
|
||||
(conn, cursor) = _open_sql_file(db)
|
||||
|
||||
result = cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
RKFace.modelId,
|
||||
RKVersion.uuid,
|
||||
RKFace.uuid,
|
||||
RKPerson.name,
|
||||
RKFace.isInTrash,
|
||||
RKFace.personId,
|
||||
RKFace.imageModelId,
|
||||
RKFace.sourceWidth,
|
||||
RKFace.sourceHeight,
|
||||
RKFace.centerX,
|
||||
RKFace.centerY,
|
||||
RKFace.size,
|
||||
RKFace.leftEyeX,
|
||||
RKFace.leftEyeY,
|
||||
RKFace.rightEyeX,
|
||||
RKFace.rightEyeY,
|
||||
RKFace.mouthX,
|
||||
RKFace.mouthY,
|
||||
RKFace.hidden,
|
||||
RKFace.manual,
|
||||
RKFace.hasSmile,
|
||||
RKFace.isLeftEyeClosed,
|
||||
RKFace.isRightEyeClosed,
|
||||
RKFace.poseRoll,
|
||||
RKFace.poseYaw,
|
||||
RKFace.posePitch,
|
||||
RKFace.faceType,
|
||||
RKFace.qualityMeasure
|
||||
FROM
|
||||
RKFace
|
||||
JOIN RKPerson on RKPerson.modelId = RKFace.personId
|
||||
JOIN RKVersion on RKVersion.modelId = RKFace.imageModelId
|
||||
"""
|
||||
)
|
||||
|
||||
# 0 RKFace.modelId,
|
||||
# 1 RKVersion.uuid,
|
||||
# 2 RKFace.uuid,
|
||||
# 3 RKPerson.name,
|
||||
# 4 RKFace.isInTrash,
|
||||
# 5 RKFace.personId,
|
||||
# 6 RKFace.imageModelId,
|
||||
# 7 RKFace.sourceWidth,
|
||||
# 8 RKFace.sourceHeight,
|
||||
# 9 RKFace.centerX,
|
||||
# 10 RKFace.centerY,
|
||||
# 11 RKFace.size,
|
||||
# 12 RKFace.leftEyeX,
|
||||
# 13 RKFace.leftEyeY,
|
||||
# 14 RKFace.rightEyeX,
|
||||
# 15 RKFace.rightEyeY,
|
||||
# 16 RKFace.mouthX,
|
||||
# 17 RKFace.mouthY,
|
||||
# 18 RKFace.hidden,
|
||||
# 19 RKFace.manual,
|
||||
# 20 RKFace.hasSmile,
|
||||
# 21 RKFace.isLeftEyeClosed,
|
||||
# 22 RKFace.isRightEyeClosed,
|
||||
# 23 RKFace.poseRoll,
|
||||
# 24 RKFace.poseYaw,
|
||||
# 25 RKFace.posePitch,
|
||||
# 26 RKFace.faceType,
|
||||
# 27 RKFace.qualityMeasure
|
||||
|
||||
for row in result:
|
||||
modelid = row[0]
|
||||
asset_uuid = row[1]
|
||||
person_id = row[5]
|
||||
face = {}
|
||||
face["pk"] = modelid
|
||||
face["asset_uuid"] = asset_uuid
|
||||
face["uuid"] = row[2]
|
||||
face["person"] = person_id
|
||||
face["fullname"] = row[3]
|
||||
face["sourcewidth"] = row[7]
|
||||
face["sourceheight"] = row[8]
|
||||
face["centerx"] = row[9]
|
||||
face["centery"] = row[10]
|
||||
face["size"] = row[11]
|
||||
face["lefteyex"] = row[12]
|
||||
face["lefteyey"] = row[13]
|
||||
face["righteyex"] = row[14]
|
||||
face["righteyey"] = row[15]
|
||||
face["mouthx"] = row[16]
|
||||
face["mouthy"] = row[17]
|
||||
face["hidden"] = row[18]
|
||||
face["manual"] = row[19]
|
||||
face["has_smile"] = row[20]
|
||||
face["left_eye_closed"] = row[21]
|
||||
face["right_eye_closed"] = row[22]
|
||||
face["roll"] = row[23]
|
||||
face["yaw"] = row[24]
|
||||
face["pitch"] = row[25]
|
||||
face["facetype"] = row[26]
|
||||
face["quality"] = row[27]
|
||||
|
||||
# Photos 5 only
|
||||
face["agetype"] = None
|
||||
face["baldtype"] = None
|
||||
face["eyemakeuptype"] = None
|
||||
face["eyestate"] = None
|
||||
face["facialhairtype"] = None
|
||||
face["gendertype"] = None
|
||||
face["glassestype"] = None
|
||||
face["haircolortype"] = None
|
||||
face["intrash"] = None
|
||||
face["lipmakeuptype"] = None
|
||||
face["smiletype"] = None
|
||||
|
||||
photosdb._db_faceinfo_pk[modelid] = face
|
||||
|
||||
try:
|
||||
photosdb._db_faceinfo_uuid[asset_uuid].append(modelid)
|
||||
except KeyError:
|
||||
photosdb._db_faceinfo_uuid[asset_uuid] = [modelid]
|
||||
|
||||
try:
|
||||
photosdb._db_faceinfo_person[person_id].append(modelid)
|
||||
except KeyError:
|
||||
photosdb._db_faceinfo_person[person_id] = [modelid]
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
def _process_faceinfo_5(photosdb):
|
||||
""" Process face information for Photos 5 databases
|
||||
|
||||
Args:
|
||||
photosdb: an OSXPhotosDB instance
|
||||
"""
|
||||
|
||||
db = photosdb._tmp_db
|
||||
|
||||
(conn, cursor) = _open_sql_file(db)
|
||||
|
||||
result = cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
ZDETECTEDFACE.Z_PK,
|
||||
ZGENERICASSET.ZUUID,
|
||||
ZDETECTEDFACE.ZUUID,
|
||||
ZDETECTEDFACE.ZPERSON,
|
||||
ZPERSON.ZFULLNAME,
|
||||
ZDETECTEDFACE.ZAGETYPE,
|
||||
ZDETECTEDFACE.ZBALDTYPE,
|
||||
ZDETECTEDFACE.ZEYEMAKEUPTYPE,
|
||||
ZDETECTEDFACE.ZEYESSTATE,
|
||||
ZDETECTEDFACE.ZFACIALHAIRTYPE,
|
||||
ZDETECTEDFACE.ZGENDERTYPE,
|
||||
ZDETECTEDFACE.ZGLASSESTYPE,
|
||||
ZDETECTEDFACE.ZHAIRCOLORTYPE,
|
||||
ZDETECTEDFACE.ZHASSMILE,
|
||||
ZDETECTEDFACE.ZHIDDEN,
|
||||
ZDETECTEDFACE.ZISINTRASH,
|
||||
ZDETECTEDFACE.ZISLEFTEYECLOSED,
|
||||
ZDETECTEDFACE.ZISRIGHTEYECLOSED,
|
||||
ZDETECTEDFACE.ZLIPMAKEUPTYPE,
|
||||
ZDETECTEDFACE.ZMANUAL,
|
||||
ZDETECTEDFACE.ZQUALITYMEASURE,
|
||||
ZDETECTEDFACE.ZSMILETYPE,
|
||||
ZDETECTEDFACE.ZSOURCEHEIGHT,
|
||||
ZDETECTEDFACE.ZSOURCEWIDTH,
|
||||
ZDETECTEDFACE.ZBLURSCORE,
|
||||
ZDETECTEDFACE.ZCENTERX,
|
||||
ZDETECTEDFACE.ZCENTERY,
|
||||
ZDETECTEDFACE.ZLEFTEYEX,
|
||||
ZDETECTEDFACE.ZLEFTEYEY,
|
||||
ZDETECTEDFACE.ZMOUTHX,
|
||||
ZDETECTEDFACE.ZMOUTHY,
|
||||
ZDETECTEDFACE.ZPOSEYAW,
|
||||
ZDETECTEDFACE.ZQUALITY,
|
||||
ZDETECTEDFACE.ZRIGHTEYEX,
|
||||
ZDETECTEDFACE.ZRIGHTEYEY,
|
||||
ZDETECTEDFACE.ZROLL,
|
||||
ZDETECTEDFACE.ZSIZE,
|
||||
ZDETECTEDFACE.ZYAW,
|
||||
ZDETECTEDFACE.ZMASTERIDENTIFIER
|
||||
FROM ZDETECTEDFACE
|
||||
JOIN ZGENERICASSET ON ZGENERICASSET.Z_PK = ZDETECTEDFACE.ZASSET
|
||||
JOIN ZPERSON ON ZPERSON.Z_PK = ZDETECTEDFACE.ZPERSON;
|
||||
"""
|
||||
)
|
||||
|
||||
# 0 ZDETECTEDFACE.Z_PK
|
||||
# 1 ZGENERICASSET.ZUUID,
|
||||
# 2 ZDETECTEDFACE.ZUUID,
|
||||
# 3 ZDETECTEDFACE.ZPERSON,
|
||||
# 4 ZPERSON.ZFULLNAME,
|
||||
# 5 ZDETECTEDFACE.ZAGETYPE,
|
||||
# 6 ZDETECTEDFACE.ZBALDTYPE,
|
||||
# 7 ZDETECTEDFACE.ZEYEMAKEUPTYPE,
|
||||
# 8 ZDETECTEDFACE.ZEYESSTATE,
|
||||
# 9 ZDETECTEDFACE.ZFACIALHAIRTYPE,
|
||||
# 10 ZDETECTEDFACE.ZGENDERTYPE,
|
||||
# 11 ZDETECTEDFACE.ZGLASSESTYPE,
|
||||
# 12 ZDETECTEDFACE.ZHAIRCOLORTYPE,
|
||||
# 13 ZDETECTEDFACE.ZHASSMILE,
|
||||
# 14 ZDETECTEDFACE.ZHIDDEN,
|
||||
# 15 ZDETECTEDFACE.ZISINTRASH,
|
||||
# 16 ZDETECTEDFACE.ZISLEFTEYECLOSED,
|
||||
# 17 ZDETECTEDFACE.ZISRIGHTEYECLOSED,
|
||||
# 18 ZDETECTEDFACE.ZLIPMAKEUPTYPE,
|
||||
# 19 ZDETECTEDFACE.ZMANUAL,
|
||||
# 20 ZDETECTEDFACE.ZQUALITYMEASURE,
|
||||
# 21 ZDETECTEDFACE.ZSMILETYPE,
|
||||
# 22 ZDETECTEDFACE.ZSOURCEHEIGHT,
|
||||
# 23 ZDETECTEDFACE.ZSOURCEWIDTH,
|
||||
# 24 ZDETECTEDFACE.ZBLURSCORE,
|
||||
# 25 ZDETECTEDFACE.ZCENTERX,
|
||||
# 26 ZDETECTEDFACE.ZCENTERY,
|
||||
# 27 ZDETECTEDFACE.ZLEFTEYEX,
|
||||
# 28 ZDETECTEDFACE.ZLEFTEYEY,
|
||||
# 29 ZDETECTEDFACE.ZMOUTHX,
|
||||
# 30 ZDETECTEDFACE.ZMOUTHY,
|
||||
# 31 ZDETECTEDFACE.ZPOSEYAW,
|
||||
# 32 ZDETECTEDFACE.ZQUALITY,
|
||||
# 33 ZDETECTEDFACE.ZRIGHTEYEX,
|
||||
# 34 ZDETECTEDFACE.ZRIGHTEYEY,
|
||||
# 35 ZDETECTEDFACE.ZROLL,
|
||||
# 36 ZDETECTEDFACE.ZSIZE,
|
||||
# 37 ZDETECTEDFACE.ZYAW,
|
||||
# 38 ZDETECTEDFACE.ZMASTERIDENTIFIER
|
||||
|
||||
for row in result:
|
||||
pk = row[0]
|
||||
asset_uuid = row[1]
|
||||
person_pk = row[3]
|
||||
face = {}
|
||||
face["pk"] = pk
|
||||
face["asset_uuid"] = asset_uuid
|
||||
face["uuid"] = row[2]
|
||||
face["person"] = person_pk
|
||||
face["fullname"] = row[4]
|
||||
face["agetype"] = row[5]
|
||||
face["baldtype"] = row[6]
|
||||
face["eyemakeuptype"] = row[7]
|
||||
face["eyestate"] = row[8]
|
||||
face["facialhairtype"] = row[9]
|
||||
face["gendertype"] = row[10]
|
||||
face["glassestype"] = row[11]
|
||||
face["haircolortype"] = row[12]
|
||||
face["has_smile"] = row[13]
|
||||
face["hidden"] = row[14]
|
||||
face["intrash"] = row[15]
|
||||
face["left_eye_closed"] = row[16]
|
||||
face["right_eye_closed"] = row[17]
|
||||
face["lipmakeuptype"] = row[18]
|
||||
face["manual"] = row[19]
|
||||
face["smiletype"] = row[21]
|
||||
face["sourceheight"] = row[22]
|
||||
face["sourcewidth"] = row[23]
|
||||
face["facetype"] = None # Photos 4 only
|
||||
face["centerx"] = row[25]
|
||||
face["centery"] = row[26]
|
||||
face["lefteyex"] = row[27]
|
||||
face["lefteyey"] = row[28]
|
||||
face["mouthx"] = row[29]
|
||||
face["mouthy"] = row[30]
|
||||
face["quality"] = row[32]
|
||||
face["righteyex"] = row[33]
|
||||
face["righteyey"] = row[34]
|
||||
face["roll"] = row[35]
|
||||
face["size"] = row[36]
|
||||
face["yaw"] = row[37]
|
||||
face["pitch"] = 0.0 # not defined in Photos 5
|
||||
|
||||
photosdb._db_faceinfo_pk[pk] = face
|
||||
|
||||
try:
|
||||
photosdb._db_faceinfo_uuid[asset_uuid].append(pk)
|
||||
except KeyError:
|
||||
photosdb._db_faceinfo_uuid[asset_uuid] = [pk]
|
||||
|
||||
try:
|
||||
photosdb._db_faceinfo_person[person_pk].append(pk)
|
||||
except KeyError:
|
||||
photosdb._db_faceinfo_person[person_pk] = [pk]
|
||||
|
||||
conn.close()
|
||||
@@ -143,3 +143,5 @@ def _process_scoreinfo_5(photosdb):
|
||||
scores["well_framed_subject"] = row[26]
|
||||
scores["well_timed_shot"] = row[27]
|
||||
photosdb._db_scoreinfo_uuid[uuid] = scores
|
||||
|
||||
conn.close()
|
||||
@@ -147,7 +147,8 @@ def _process_searchinfo(self):
|
||||
"_db_searchinfo_labels_normalized: \n"
|
||||
+ pformat(self._db_searchinfo_labels_normalized)
|
||||
)
|
||||
|
||||
|
||||
conn.close()
|
||||
|
||||
@property
|
||||
def labels(self):
|
||||
|
||||
@@ -11,7 +11,7 @@ import platform
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pprint import pformat
|
||||
from shutil import copyfile
|
||||
|
||||
@@ -34,6 +34,8 @@ from .._constants import (
|
||||
)
|
||||
from .._version import __version__
|
||||
from ..albuminfo import AlbumInfo, FolderInfo
|
||||
from ..datetime_utils import datetime_has_tz, datetime_naive_to_local
|
||||
from ..personinfo import PersonInfo
|
||||
from ..photoinfo import PhotoInfo
|
||||
from ..utils import (
|
||||
_check_file_exists,
|
||||
@@ -44,7 +46,6 @@ from ..utils import (
|
||||
get_last_library_path,
|
||||
)
|
||||
|
||||
|
||||
# TODO: Add test for imageTimeZoneOffsetSeconds = None
|
||||
# TODO: Add test for __str__
|
||||
# TODO: Add special albums and magic albums
|
||||
@@ -55,6 +56,7 @@ class PhotosDB:
|
||||
|
||||
# import additional methods
|
||||
from ._photosdb_process_exif import _process_exifinfo
|
||||
from ._photosdb_process_faceinfo import _process_faceinfo
|
||||
from ._photosdb_process_searchinfo import (
|
||||
_process_searchinfo,
|
||||
labels,
|
||||
@@ -87,6 +89,7 @@ class PhotosDB:
|
||||
|
||||
# set up the data structures used to store all the Photo database info
|
||||
|
||||
# TODO: I don't think these keywords flags are actually used
|
||||
# if True, will treat persons as keywords when exporting metadata
|
||||
self.use_persons_as_keywords = False
|
||||
|
||||
@@ -122,17 +125,28 @@ class PhotosDB:
|
||||
# currently used to get information on RAW images
|
||||
self._dbphotos_master = {}
|
||||
|
||||
# Dict with information about all persons by person PK
|
||||
# key is person PK, value is dict with info about each person
|
||||
# e.g. {3: {"pk": 3, "fullname": "Maria Smith"...}}
|
||||
self._dbpersons_pk = {}
|
||||
|
||||
# Dict with information about all persons by person fullname
|
||||
# key is person PK, value is list of person PKs with fullname
|
||||
# there may be more than one person PK with the same fullname
|
||||
# e.g. {"Maria Smith": [1, 2]}
|
||||
self._dbpersons_fullname = {}
|
||||
|
||||
# Dict with information about all persons/photos by uuid
|
||||
# key is photo UUID, value is list of face names in that photo
|
||||
# key is photo UUID, value is list of person primary keys of persons in the photo
|
||||
# Note: Photos 5 identifies faces even if not given a name
|
||||
# and those are labeled by process_database as _UNKNOWN_
|
||||
# e.g. {'1EB2B765-0765-43BA-A90C-0D0580E6172C': ['Katie', '_UNKNOWN_', 'Suzy']}
|
||||
# e.g. {'1EB2B765-0765-43BA-A90C-0D0580E6172C': [1, 3, 5]}
|
||||
self._dbfaces_uuid = {}
|
||||
|
||||
# Dict with information about all persons/photos by person
|
||||
# key is person name, value is list of photo UUIDs
|
||||
# e.g. {'Maria': ['E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51']}
|
||||
self._dbfaces_person = {}
|
||||
# Dict with information about detected faces by person primary key
|
||||
# key is person pk, value is list of photo UUIDs
|
||||
# e.g. {3: ['E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51']}
|
||||
self._dbfaces_pk = {}
|
||||
|
||||
# Dict with information about all keywords/photos by uuid
|
||||
# key is photo uuid and value is list of keywords
|
||||
@@ -156,8 +170,8 @@ class PhotosDB:
|
||||
self._dbalbums_pk = {}
|
||||
|
||||
# Dict with information about all albums/photos by album
|
||||
# key is album UUID, value is list of photo UUIDs contained in that album
|
||||
# e.g. {'0C514A98-7B77-4E4F-801B-364B7B65EAFA': ['1EB2B765-0765-43BA-A90C-0D0580E6172C']}
|
||||
# key is album UUID, value is list of tuples of (photo UUID, sort order) contained in that album
|
||||
# e.g. {'0C514A98-7B77-4E4F-801B-364B7B65EAFA': [('1EB2B765-0765-43BA-A90C-0D0580E6172C', 1024)]}
|
||||
self._dbalbums_album = {}
|
||||
|
||||
# Dict with information about album details
|
||||
@@ -303,7 +317,13 @@ class PhotosDB:
|
||||
@property
|
||||
def persons_as_dict(self):
|
||||
""" return persons as dict of person, count in reverse sorted order (descending) """
|
||||
persons = {k: len(self._dbfaces_person[k]) for k in self._dbfaces_person.keys()}
|
||||
persons = {}
|
||||
for pk in self._dbfaces_pk:
|
||||
fullname = self._dbpersons_pk[pk]["fullname"]
|
||||
try:
|
||||
persons[fullname] += len(self._dbfaces_pk[pk])
|
||||
except KeyError:
|
||||
persons[fullname] = len(self._dbfaces_pk[pk])
|
||||
persons = dict(sorted(persons.items(), key=lambda kv: kv[1], reverse=True))
|
||||
return persons
|
||||
|
||||
@@ -352,9 +372,20 @@ class PhotosDB:
|
||||
@property
|
||||
def persons(self):
|
||||
""" return list of persons found in photos database """
|
||||
persons = self._dbfaces_person.keys()
|
||||
persons = {self._dbpersons_pk[k]["fullname"] for k in self._dbfaces_pk}
|
||||
return list(persons)
|
||||
|
||||
@property
|
||||
def person_info(self):
|
||||
""" return list of PersonInfo objects for each person in the photos database """
|
||||
try:
|
||||
return self._person_info
|
||||
except AttributeError:
|
||||
self._person_info = [
|
||||
PersonInfo(db=self, pk=pk) for pk in self._dbpersons_pk
|
||||
]
|
||||
return self._person_info
|
||||
|
||||
@property
|
||||
def folder_info(self):
|
||||
""" return list FolderInfo objects representing top-level folders in the photos database """
|
||||
@@ -536,41 +567,139 @@ class PhotosDB:
|
||||
|
||||
(conn, c) = _open_sql_file(self._tmp_db)
|
||||
|
||||
# Look for all combinations of persons and pictures
|
||||
# get info to associate persons with photos
|
||||
# then get detected faces in each photo and link to persons
|
||||
c.execute(
|
||||
""" select RKPerson.name, RKVersion.uuid from RKFace, RKPerson, RKVersion, RKMaster
|
||||
where RKFace.personID = RKperson.modelID and RKVersion.modelId = RKFace.ImageModelId
|
||||
and RKVersion.masterUuid = RKMaster.uuid
|
||||
""" SELECT
|
||||
RKPerson.modelID,
|
||||
RKPerson.uuid,
|
||||
RKPerson.name,
|
||||
RKPerson.faceCount,
|
||||
RKPerson.displayName,
|
||||
RKPerson.representativeFaceId
|
||||
FROM RKPerson
|
||||
"""
|
||||
)
|
||||
|
||||
# 0 RKPerson.modelID,
|
||||
# 1 RKPerson.uuid,
|
||||
# 2 RKPerson.name,
|
||||
# 3 RKPerson.faceCount,
|
||||
# 4 RKPerson.displayName
|
||||
# 5 RKPerson.representativeFaceId
|
||||
|
||||
for person in c:
|
||||
if person[0] is None:
|
||||
continue
|
||||
if not person[1] in self._dbfaces_uuid:
|
||||
self._dbfaces_uuid[person[1]] = []
|
||||
if not person[0] in self._dbfaces_person:
|
||||
self._dbfaces_person[person[0]] = []
|
||||
self._dbfaces_uuid[person[1]].append(person[0])
|
||||
self._dbfaces_person[person[0]].append(person[1])
|
||||
pk = person[0]
|
||||
fullname = person[2] if person[2] is not None else _UNKNOWN_PERSON
|
||||
self._dbpersons_pk[pk] = {
|
||||
"pk": pk,
|
||||
"uuid": person[1],
|
||||
"fullname": fullname,
|
||||
"facecount": person[3],
|
||||
"keyface": person[5],
|
||||
"displayname": person[4],
|
||||
"photo_uuid": None,
|
||||
"keyface_uuid": None,
|
||||
}
|
||||
try:
|
||||
self._dbpersons_fullname[fullname].append(pk)
|
||||
except KeyError:
|
||||
self._dbpersons_fullname[fullname] = [pk]
|
||||
|
||||
# get info on key face
|
||||
c.execute(
|
||||
""" SELECT
|
||||
RKPerson.modelID,
|
||||
RKPerson.representativeFaceId,
|
||||
RKVersion.uuid,
|
||||
RKFace.uuid
|
||||
FROM RKPerson, RKFace, RKVersion
|
||||
WHERE
|
||||
RKFace.modelId = RKPerson.representativeFaceId AND
|
||||
RKVersion.modelId = RKFace.ImageModelId
|
||||
"""
|
||||
)
|
||||
|
||||
# 0 RKPerson.modelID,
|
||||
# 1 RKPerson.representativeFaceId
|
||||
# 2 RKVersion.uuid,
|
||||
# 3 RKFace.uuid
|
||||
|
||||
for person in c:
|
||||
pk = person[0]
|
||||
try:
|
||||
self._dbpersons_pk[pk]["photo_uuid"] = person[2]
|
||||
self._dbpersons_pk[pk]["keyface_uuid"] = person[3]
|
||||
except KeyError:
|
||||
logging.debug(f"Unexpected KeyError _dbpersons_pk[{pk}]")
|
||||
|
||||
# get information on detected faces
|
||||
c.execute(
|
||||
""" SELECT
|
||||
RKPerson.modelID,
|
||||
RKVersion.uuid
|
||||
FROM
|
||||
RKFace, RKPerson, RKVersion, RKMaster
|
||||
WHERE
|
||||
RKFace.personID = RKperson.modelID AND
|
||||
RKVersion.modelId = RKFace.ImageModelId AND
|
||||
RKVersion.masterUuid = RKMaster.uuid
|
||||
"""
|
||||
)
|
||||
|
||||
# 0 RKPerson.modelID
|
||||
# 1 RKVersion.uuid
|
||||
|
||||
for face in c:
|
||||
pk = face[0]
|
||||
uuid = face[1]
|
||||
try:
|
||||
self._dbfaces_uuid[uuid].append(pk)
|
||||
except KeyError:
|
||||
self._dbfaces_uuid[uuid] = [pk]
|
||||
|
||||
try:
|
||||
self._dbfaces_pk[pk].append(uuid)
|
||||
except KeyError:
|
||||
self._dbfaces_pk[pk] = [uuid]
|
||||
|
||||
if _debug():
|
||||
logging.debug(f"Finished walking through persons")
|
||||
logging.debug(pformat(self._dbpersons_pk))
|
||||
logging.debug(pformat(self._dbpersons_fullname))
|
||||
logging.debug(pformat(self._dbfaces_pk))
|
||||
logging.debug(pformat(self._dbfaces_uuid))
|
||||
|
||||
# Get info on albums
|
||||
c.execute(
|
||||
""" select
|
||||
""" SELECT
|
||||
RKAlbum.uuid,
|
||||
RKVersion.uuid
|
||||
from RKAlbum, RKVersion, RKAlbumVersion
|
||||
where RKAlbum.modelID = RKAlbumVersion.albumId and
|
||||
RKAlbumVersion.versionID = RKVersion.modelId
|
||||
RKVersion.uuid,
|
||||
RKCustomSortOrder.orderNumber
|
||||
FROM RKVersion
|
||||
JOIN RKCustomSortOrder on RKCustomSortOrder.objectUuid = RKVersion.uuid
|
||||
JOIN RKAlbum on RKAlbum.uuid = RKCustomSortOrder.containerUuid
|
||||
"""
|
||||
)
|
||||
|
||||
# 0 RKAlbum.uuid,
|
||||
# 1 RKVersion.uuid,
|
||||
# 2 RKCustomSortOrder.orderNumber
|
||||
|
||||
for album in c:
|
||||
# store by uuid in _dbalbums_uuid and by album in _dbalbums_album
|
||||
if not album[1] in self._dbalbums_uuid:
|
||||
self._dbalbums_uuid[album[1]] = []
|
||||
if not album[0] in self._dbalbums_album:
|
||||
self._dbalbums_album[album[0]] = []
|
||||
self._dbalbums_uuid[album[1]].append(album[0])
|
||||
self._dbalbums_album[album[0]].append(album[1])
|
||||
album_uuid = album[0]
|
||||
photo_uuid = album[1]
|
||||
sort_order = album[2]
|
||||
try:
|
||||
self._dbalbums_uuid[photo_uuid].append(album_uuid)
|
||||
except KeyError:
|
||||
self._dbalbums_uuid[photo_uuid] = [album_uuid]
|
||||
|
||||
try:
|
||||
self._dbalbums_album[album_uuid].append((photo_uuid, sort_order))
|
||||
except KeyError:
|
||||
self._dbalbums_album[album_uuid] = [(photo_uuid, sort_order)]
|
||||
|
||||
# now get additional details about albums
|
||||
c.execute(
|
||||
@@ -824,15 +953,23 @@ class PhotosDB:
|
||||
except TypeError:
|
||||
self._dbphotos[uuid]["lastmodifieddate"] = None
|
||||
|
||||
self._dbphotos[uuid]["imageTimeZoneOffsetSeconds"] = row[9]
|
||||
|
||||
try:
|
||||
self._dbphotos[uuid]["imageDate"] = datetime.fromtimestamp(row[5] + td)
|
||||
imagedate = datetime.fromtimestamp(row[5] + td)
|
||||
seconds = self._dbphotos[uuid]["imageTimeZoneOffsetSeconds"] or 0
|
||||
delta = timedelta(seconds=seconds)
|
||||
tz = timezone(delta)
|
||||
self._dbphotos[uuid]["imageDate"] = imagedate.astimezone(tz=tz)
|
||||
except ValueError:
|
||||
self._dbphotos[uuid]["imageDate"] = datetime(1970, 1, 1)
|
||||
# sometimes imageDate is invalid so use 1 Jan 1970 in UTC as image date
|
||||
imagedate = datetime(1970, 1, 1)
|
||||
tz = timezone(timedelta(0))
|
||||
self._dbphotos[uuid]["imageDate"] = imagedate.astimezone(tz=tz)
|
||||
|
||||
self._dbphotos[uuid]["mainRating"] = row[6]
|
||||
self._dbphotos[uuid]["hasAdjustments"] = row[7]
|
||||
self._dbphotos[uuid]["hasKeywords"] = row[8]
|
||||
self._dbphotos[uuid]["imageTimeZoneOffsetSeconds"] = row[9]
|
||||
self._dbphotos[uuid]["volumeId"] = row[10]
|
||||
self._dbphotos[uuid]["imagePath"] = row[11]
|
||||
self._dbphotos[uuid]["extendedDescription"] = row[12]
|
||||
@@ -1199,6 +1336,9 @@ class PhotosDB:
|
||||
# done with the database connection
|
||||
conn.close()
|
||||
|
||||
# process faces
|
||||
self._process_faceinfo()
|
||||
|
||||
# add faces and keywords to photo data
|
||||
for uuid in self._dbphotos:
|
||||
# keywords
|
||||
@@ -1233,8 +1373,8 @@ class PhotosDB:
|
||||
logging.debug("Faces (_dbfaces_uuid):")
|
||||
logging.debug(pformat(self._dbfaces_uuid))
|
||||
|
||||
logging.debug("Faces by person (_dbfaces_person):")
|
||||
logging.debug(pformat(self._dbfaces_person))
|
||||
logging.debug("Persons (_dbpersons_pk):")
|
||||
logging.debug(pformat(self._dbpersons_pk))
|
||||
|
||||
logging.debug("Keywords by uuid (_dbkeywords_uuid):")
|
||||
logging.debug(pformat(self._dbkeywords_uuid))
|
||||
@@ -1295,8 +1435,12 @@ class PhotosDB:
|
||||
return folders
|
||||
|
||||
def _process_database5(self):
|
||||
""" process the Photos database to extract info """
|
||||
""" works on Photos version >= 5.0 """
|
||||
""" process the Photos database to extract info
|
||||
works on Photos version >= 5.0
|
||||
|
||||
This is a big hairy 700 line function that should probably be refactored
|
||||
but it works so don't touch it.
|
||||
"""
|
||||
|
||||
if _debug():
|
||||
logging.debug(f"_process_database5")
|
||||
@@ -1310,45 +1454,136 @@ class PhotosDB:
|
||||
if _debug():
|
||||
logging.debug(f"Getting information about persons")
|
||||
|
||||
# get info to associate persons with photos
|
||||
# then get detected faces in each photo and link to persons
|
||||
c.execute(
|
||||
"SELECT ZPERSON.ZFULLNAME, ZGENERICASSET.ZUUID "
|
||||
"FROM ZPERSON, ZDETECTEDFACE, ZGENERICASSET "
|
||||
"WHERE ZDETECTEDFACE.ZPERSON = ZPERSON.Z_PK AND ZDETECTEDFACE.ZASSET = ZGENERICASSET.Z_PK "
|
||||
""" SELECT
|
||||
ZPERSON.Z_PK,
|
||||
ZPERSON.ZPERSONUUID,
|
||||
ZPERSON.ZFULLNAME,
|
||||
ZPERSON.ZFACECOUNT,
|
||||
ZPERSON.ZKEYFACE,
|
||||
ZPERSON.ZDISPLAYNAME
|
||||
FROM ZPERSON
|
||||
"""
|
||||
)
|
||||
|
||||
# 0 ZPERSON.Z_PK,
|
||||
# 1 ZPERSON.ZPERSONUUID,
|
||||
# 2 ZPERSON.ZFULLNAME,
|
||||
# 3 ZPERSON.ZFACECOUNT,
|
||||
# 4 ZPERSON.ZKEYFACE,
|
||||
# 5 ZPERSON.ZDISPLAYNAME
|
||||
|
||||
for person in c:
|
||||
if person[0] is None:
|
||||
continue
|
||||
person_name = person[0] if person[0] != "" else _UNKNOWN_PERSON
|
||||
if not person[1] in self._dbfaces_uuid:
|
||||
self._dbfaces_uuid[person[1]] = []
|
||||
if not person_name in self._dbfaces_person:
|
||||
self._dbfaces_person[person_name] = []
|
||||
self._dbfaces_uuid[person[1]].append(person_name)
|
||||
self._dbfaces_person[person_name].append(person[1])
|
||||
pk = person[0]
|
||||
fullname = person[2] if person[2] != "" else _UNKNOWN_PERSON
|
||||
self._dbpersons_pk[pk] = {
|
||||
"pk": pk,
|
||||
"uuid": person[1],
|
||||
"fullname": fullname,
|
||||
"facecount": person[3],
|
||||
"keyface": person[4],
|
||||
"displayname": person[5],
|
||||
"photo_uuid": None,
|
||||
"keyface_uuid": None,
|
||||
}
|
||||
try:
|
||||
self._dbpersons_fullname[fullname].append(pk)
|
||||
except KeyError:
|
||||
self._dbpersons_fullname[fullname] = [pk]
|
||||
|
||||
# get info on keyface -- some photos have null keyface so can't do a single query
|
||||
# (at least not with my SQL skills)
|
||||
c.execute(
|
||||
""" SELECT
|
||||
ZPERSON.Z_PK,
|
||||
ZPERSON.ZKEYFACE,
|
||||
ZGENERICASSET.ZUUID,
|
||||
ZDETECTEDFACE.ZUUID
|
||||
FROM ZPERSON, ZDETECTEDFACE, ZGENERICASSET
|
||||
WHERE ZDETECTEDFACE.Z_PK = ZPERSON.ZKEYFACE AND
|
||||
ZDETECTEDFACE.ZASSET = ZGENERICASSET.Z_PK
|
||||
"""
|
||||
)
|
||||
|
||||
# 0 ZPERSON.Z_PK,
|
||||
# 1 ZPERSON.ZKEYFACE,
|
||||
# 2 ZGENERICASSET.ZUUID,
|
||||
# 3 ZDETECTEDFACE.ZUUID
|
||||
|
||||
for person in c:
|
||||
pk = person[0]
|
||||
try:
|
||||
self._dbpersons_pk[pk]["photo_uuid"] = person[2]
|
||||
self._dbpersons_pk[pk]["keyface_uuid"] = person[3]
|
||||
except KeyError:
|
||||
logging.debug(f"Unexpected KeyError _dbpersons_pk[{pk}]")
|
||||
|
||||
# get information on detected faces
|
||||
c.execute(
|
||||
""" SELECT
|
||||
ZPERSON.Z_PK,
|
||||
ZGENERICASSET.ZUUID
|
||||
FROM ZPERSON, ZDETECTEDFACE, ZGENERICASSET
|
||||
WHERE ZDETECTEDFACE.ZPERSON = ZPERSON.Z_PK AND
|
||||
ZDETECTEDFACE.ZASSET = ZGENERICASSET.Z_PK
|
||||
"""
|
||||
)
|
||||
|
||||
# 0 ZPERSON.Z_PK,
|
||||
# 1 ZGENERICASSET.ZUUID,
|
||||
|
||||
for face in c:
|
||||
pk = face[0]
|
||||
uuid = face[1]
|
||||
try:
|
||||
self._dbfaces_uuid[uuid].append(pk)
|
||||
except KeyError:
|
||||
self._dbfaces_uuid[uuid] = [pk]
|
||||
|
||||
try:
|
||||
self._dbfaces_pk[pk].append(uuid)
|
||||
except KeyError:
|
||||
self._dbfaces_pk[pk] = [uuid]
|
||||
|
||||
if _debug():
|
||||
logging.debug(f"Finished walking through persons")
|
||||
logging.debug(pformat(self._dbfaces_person))
|
||||
logging.debug(self._dbfaces_uuid)
|
||||
logging.debug(pformat(self._dbpersons_pk))
|
||||
logging.debug(pformat(self._dbpersons_fullname))
|
||||
logging.debug(pformat(self._dbfaces_pk))
|
||||
logging.debug(pformat(self._dbfaces_uuid))
|
||||
|
||||
# get details about albums
|
||||
c.execute(
|
||||
"SELECT ZGENERICALBUM.ZUUID, ZGENERICASSET.ZUUID "
|
||||
"FROM ZGENERICASSET "
|
||||
"JOIN Z_26ASSETS ON Z_26ASSETS.Z_34ASSETS = ZGENERICASSET.Z_PK "
|
||||
"JOIN ZGENERICALBUM ON ZGENERICALBUM.Z_PK = Z_26ASSETS.Z_26ALBUMS "
|
||||
""" SELECT
|
||||
ZGENERICALBUM.ZUUID,
|
||||
ZGENERICASSET.ZUUID,
|
||||
Z_26ASSETS.Z_FOK_34ASSETS
|
||||
FROM ZGENERICASSET
|
||||
JOIN Z_26ASSETS ON Z_26ASSETS.Z_34ASSETS = ZGENERICASSET.Z_PK
|
||||
JOIN ZGENERICALBUM ON ZGENERICALBUM.Z_PK = Z_26ASSETS.Z_26ALBUMS
|
||||
"""
|
||||
)
|
||||
|
||||
# 0 ZGENERICALBUM.ZUUID,
|
||||
# 1 ZGENERICASSET.ZUUID,
|
||||
# 2 Z_26ASSETS.Z_FOK_34ASSETS
|
||||
|
||||
for album in c:
|
||||
# store by uuid in _dbalbums_uuid and by album in _dbalbums_album
|
||||
album_uuid = album[0]
|
||||
photo_uuid = album[1]
|
||||
sort_order = album[2]
|
||||
try:
|
||||
self._dbalbums_uuid[album[1]].append(album[0])
|
||||
self._dbalbums_uuid[photo_uuid].append(album_uuid)
|
||||
except KeyError:
|
||||
self._dbalbums_uuid[album[1]] = [album[0]]
|
||||
self._dbalbums_uuid[photo_uuid] = [album_uuid]
|
||||
|
||||
try:
|
||||
self._dbalbums_album[album[0]].append(album[1])
|
||||
self._dbalbums_album[album_uuid].append((photo_uuid, sort_order))
|
||||
except KeyError:
|
||||
self._dbalbums_album[album[0]] = [album[1]]
|
||||
self._dbalbums_album[album_uuid] = [(photo_uuid, sort_order)]
|
||||
|
||||
# now get additional details about albums
|
||||
c.execute(
|
||||
@@ -1483,15 +1718,15 @@ class PhotosDB:
|
||||
ZGENERICASSET.ZCLOUDBATCHPUBLISHDATE,
|
||||
ZGENERICASSET.ZKIND,
|
||||
ZGENERICASSET.ZUNIFORMTYPEIDENTIFIER,
|
||||
ZGENERICASSET.ZAVALANCHEUUID,
|
||||
ZGENERICASSET.ZAVALANCHEPICKTYPE,
|
||||
ZGENERICASSET.ZAVALANCHEUUID,
|
||||
ZGENERICASSET.ZAVALANCHEPICKTYPE,
|
||||
ZGENERICASSET.ZKINDSUBTYPE,
|
||||
ZGENERICASSET.ZCUSTOMRENDEREDVALUE,
|
||||
ZADDITIONALASSETATTRIBUTES.ZCAMERACAPTUREDEVICE,
|
||||
ZGENERICASSET.ZCLOUDASSETGUID,
|
||||
ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA,
|
||||
ZGENERICASSET.ZMOMENT,
|
||||
ZADDITIONALASSETATTRIBUTES.ZORIGINALRESOURCECHOICE,
|
||||
ZADDITIONALASSETATTRIBUTES.ZORIGINALRESOURCECHOICE,
|
||||
ZGENERICASSET.ZTRASHEDSTATE,
|
||||
ZGENERICASSET.ZHEIGHT,
|
||||
ZGENERICASSET.ZWIDTH,
|
||||
@@ -1562,12 +1797,20 @@ class PhotosDB:
|
||||
except TypeError:
|
||||
info["lastmodifieddate"] = None
|
||||
|
||||
try:
|
||||
info["imageDate"] = datetime.fromtimestamp(row[5] + td)
|
||||
except ValueError:
|
||||
info["imageDate"] = datetime(1970, 1, 1)
|
||||
|
||||
info["imageTimeZoneOffsetSeconds"] = row[6]
|
||||
|
||||
try:
|
||||
imagedate = datetime.fromtimestamp(row[5] + td)
|
||||
seconds = info["imageTimeZoneOffsetSeconds"] or 0
|
||||
delta = timedelta(seconds=seconds)
|
||||
tz = timezone(delta)
|
||||
info["imageDate"] = imagedate.astimezone(tz=tz)
|
||||
except ValueError:
|
||||
# sometimes imageDate is invalid so use 1 Jan 1970 in UTC as image date
|
||||
imagedate = datetime(1970, 1, 1)
|
||||
tz = timezone(timedelta(0))
|
||||
info["imageDate"] = imagedate.astimezone(tz=tz)
|
||||
|
||||
info["hidden"] = row[9]
|
||||
info["favorite"] = row[10]
|
||||
info["originalFilename"] = row[3]
|
||||
@@ -1907,6 +2150,9 @@ class PhotosDB:
|
||||
# close connection and remove temporary files
|
||||
conn.close()
|
||||
|
||||
# process face info
|
||||
self._process_faceinfo()
|
||||
|
||||
# process search info
|
||||
self._process_searchinfo()
|
||||
|
||||
@@ -1921,8 +2167,8 @@ class PhotosDB:
|
||||
logging.debug("Faces (_dbfaces_uuid):")
|
||||
logging.debug(pformat(self._dbfaces_uuid))
|
||||
|
||||
logging.debug("Faces by person (_dbfaces_person):")
|
||||
logging.debug(pformat(self._dbfaces_person))
|
||||
logging.debug("Persons (_dbpersons_pk):")
|
||||
logging.debug(pformat(self._dbpersons_pk))
|
||||
|
||||
logging.debug("Keywords by uuid (_dbkeywords_uuid):")
|
||||
logging.debug(pformat(self._dbkeywords_uuid))
|
||||
@@ -2207,23 +2453,29 @@ class PhotosDB:
|
||||
to_date=None,
|
||||
intrash=False,
|
||||
):
|
||||
"""
|
||||
Return a list of PhotoInfo objects
|
||||
""" Return a list of PhotoInfo objects
|
||||
If called with no args, returns the entire database of photos
|
||||
If called with args, returns photos matching the args (e.g. keywords, persons, etc.)
|
||||
If more than one arg, returns photos matching all the criteria (e.g. keywords AND persons)
|
||||
If more than one keyword, uuid, persons, albums is passed, they are treated as "OR" criteria
|
||||
e.g. keywords=["wedding","vacation"] returns photos matching either keyword
|
||||
keywords: list of keywords to search for
|
||||
uuid: list of UUIDs to search for
|
||||
persons: list of persons to search for
|
||||
albums: list of album names to search for
|
||||
images: if True, returns image files, if False, does not return images; default is True
|
||||
movies: if True, returns movie files, if False, does not return movies; default is True
|
||||
from_date: return photos with creation date >= from_date (datetime.datetime object, default None)
|
||||
to_date: return photos with creation date <= to_date (datetime.datetime object, default None)
|
||||
intrash: if True, returns only images in "Recently deleted items" folder,
|
||||
if False returns only photos that aren't deleted; default is False
|
||||
from_date and to_date may be either naive or timezone-aware datetime.datetime objects.
|
||||
If naive, timezone will be assumed to be local timezone.
|
||||
|
||||
Args:
|
||||
keywords: list of keywords to search for
|
||||
uuid: list of UUIDs to search for
|
||||
persons: list of persons to search for
|
||||
albums: list of album names to search for
|
||||
images: if True, returns image files, if False, does not return images; default is True
|
||||
movies: if True, returns movie files, if False, does not return movies; default is True
|
||||
from_date: return photos with creation date >= from_date (datetime.datetime object, default None)
|
||||
to_date: return photos with creation date <= to_date (datetime.datetime object, default None)
|
||||
intrash: if True, returns only images in "Recently deleted items" folder,
|
||||
if False returns only photos that aren't deleted; default is False
|
||||
|
||||
Returns:
|
||||
list of PhotoInfo objects
|
||||
"""
|
||||
|
||||
# implementation is a bit kludgy but it works
|
||||
@@ -2253,7 +2505,9 @@ class PhotosDB:
|
||||
title_set = set()
|
||||
for album_id in self._dbalbum_titles[album]:
|
||||
try:
|
||||
title_set.update(self._dbalbums_album[album_id])
|
||||
# _dbalbums_album value is list of tuples: [(uuid, sort order)]
|
||||
uuid_in_album, _ = zip(*self._dbalbums_album[album_id])
|
||||
title_set.update(uuid_in_album)
|
||||
except KeyError:
|
||||
# an empty album will be in _dbalbum_titles but not _dbalbums_album
|
||||
pass
|
||||
@@ -2283,8 +2537,13 @@ class PhotosDB:
|
||||
if persons:
|
||||
person_set = set()
|
||||
for person in persons:
|
||||
if person in self._dbfaces_person:
|
||||
person_set.update(self._dbfaces_person[person])
|
||||
if person in self._dbpersons_fullname:
|
||||
for pk in self._dbpersons_fullname[person]:
|
||||
try:
|
||||
person_set.update(self._dbfaces_pk[pk])
|
||||
except KeyError:
|
||||
# some persons have zero photos so they won't be in _dbfaces_pk
|
||||
pass
|
||||
else:
|
||||
logging.debug(f"Could not find person '{person}' in database")
|
||||
photos_sets.append(person_set)
|
||||
@@ -2292,6 +2551,8 @@ class PhotosDB:
|
||||
if from_date or to_date: # sourcery off
|
||||
dsel = self._dbphotos
|
||||
if from_date:
|
||||
if not datetime_has_tz(from_date):
|
||||
from_date = datetime_naive_to_local(from_date)
|
||||
dsel = {
|
||||
k: v for k, v in dsel.items() if v["imageDate"] >= from_date
|
||||
}
|
||||
@@ -2299,6 +2560,8 @@ class PhotosDB:
|
||||
f"Found %i items with from_date {from_date}" % len(dsel)
|
||||
)
|
||||
if to_date:
|
||||
if not datetime_has_tz(to_date):
|
||||
to_date = datetime_naive_to_local(to_date)
|
||||
dsel = {k: v for k, v in dsel.items() if v["imageDate"] <= to_date}
|
||||
logging.debug(f"Found %i items with to_date {to_date}" % len(dsel))
|
||||
photos_sets.append(set(dsel.keys()))
|
||||
@@ -2323,6 +2586,42 @@ class PhotosDB:
|
||||
|
||||
return photoinfo
|
||||
|
||||
def get_photo(self, uuid):
|
||||
""" Returns a single photo matching uuid
|
||||
|
||||
Arguments:
|
||||
uuid: the UUID of photo to get
|
||||
|
||||
Returns:
|
||||
PhotoInfo instance for photo with UUID matching uuid or None if no match
|
||||
"""
|
||||
try:
|
||||
return PhotoInfo(db=self, uuid=uuid, info=self._dbphotos[uuid])
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
# TODO: add to docs and test
|
||||
def photos_by_uuid(self, uuids):
|
||||
""" Returns a list of photos with UUID in uuids.
|
||||
Does not generate error if invalid or missing UUID passed.
|
||||
This is faster than using PhotosDB.photos if you have list of UUIDs.
|
||||
Returns photos regardless of intrash state.
|
||||
|
||||
Arguments:
|
||||
uuid: list of UUIDs of photos to get
|
||||
|
||||
Returns:
|
||||
list of PhotoInfo instance for photo with UUID matching uuid or [] if no match
|
||||
"""
|
||||
photos = []
|
||||
for uuid in uuids:
|
||||
try:
|
||||
photos.append(PhotoInfo(db=self, uuid=uuid, info=self._dbphotos[uuid]))
|
||||
except KeyError:
|
||||
# ignore missing/invlaid UUID
|
||||
pass
|
||||
return photos
|
||||
|
||||
def __repr__(self):
|
||||
return f"osxphotos.{self.__class__.__name__}(dbfile='{self.db_path}')"
|
||||
|
||||
|
||||
@@ -262,7 +262,10 @@ def get_preferred_uti_extension(uti):
|
||||
|
||||
def findfiles(pattern, path_):
|
||||
"""Returns list of filenames from path_ matched by pattern
|
||||
shell pattern. Matching is case-insensitive."""
|
||||
shell pattern. Matching is case-insensitive.
|
||||
If 'path_' is invalid/doesn't exist, returns []."""
|
||||
if not os.path.isdir(path_):
|
||||
return []
|
||||
# See: https://gist.github.com/techtonik/5694830
|
||||
|
||||
rule = re.compile(fnmatch.translate(pattern), re.IGNORECASE)
|
||||
|
||||
292
requirements.txt
@@ -1,19 +1,38 @@
|
||||
aiohttp==4.0.0a1
|
||||
altgraph==0.17
|
||||
ansimarkup==1.4.0
|
||||
appdirs==1.4.3
|
||||
appnope==0.1.0
|
||||
astroid==2.2.5
|
||||
async-timeout==3.0.1
|
||||
atomicwrites==1.3.0
|
||||
attrs==19.1.0
|
||||
backcall==0.1.0
|
||||
better-exceptions-fork==0.2.1.post6
|
||||
# bpylist2==2.0.3;python_version<"3.8"
|
||||
https://github.com/RhetTbull/bpylist/releases/download/v2.0.3/bpylist2-2.0.3.tar.gz#egg=bpylist2;python_version<"3.8"
|
||||
bpylist2==3.0.0;python_version>="3.8"
|
||||
certifi==2019.3.9
|
||||
black==19.10b0
|
||||
bleach==3.1.4
|
||||
bpylist2==3.0.2
|
||||
certifi==2020.4.5.1
|
||||
cffi==1.14.0
|
||||
chardet==3.0.4
|
||||
Click==7.0
|
||||
colorama==0.4.1
|
||||
coverage==4.5.4
|
||||
importlib-metadata>=0.18
|
||||
decorator==4.4.2
|
||||
distlib==0.3.1
|
||||
docutils==0.16
|
||||
entrypoints==0.3
|
||||
filelock==3.0.12
|
||||
idna==2.9
|
||||
importlib-metadata==1.6.0
|
||||
ipykernel==5.1.4
|
||||
ipython==7.13.0
|
||||
ipython-genutils==0.2.0
|
||||
isort==4.3.20
|
||||
jedi==0.16.0
|
||||
jupyter-client==6.1.2
|
||||
jupyter-core==4.6.3
|
||||
keyring==21.2.0
|
||||
lazy-object-proxy==1.4.1
|
||||
loguru==0.2.5
|
||||
macholib==1.14
|
||||
@@ -22,135 +41,166 @@ MarkupSafe==1.1.1
|
||||
mccabe==0.6.1
|
||||
modulegraph==0.18
|
||||
more-itertools==7.2.0
|
||||
multidict==4.7.6
|
||||
packaging==19.0
|
||||
parso==0.6.2
|
||||
pathspec==0.7.0
|
||||
pathvalidate==2.2.1
|
||||
pexpect==4.8.0
|
||||
pickleshare==0.7.5
|
||||
Pillow==7.2.0
|
||||
pkginfo==1.5.0.1
|
||||
pluggy==0.12.0
|
||||
prompt-toolkit==3.0.4
|
||||
psutil==5.7.0
|
||||
ptyprocess==0.6.0
|
||||
py==1.8.0
|
||||
py2app==0.21
|
||||
Pygments==2.4.2
|
||||
pycparser==2.20
|
||||
pyfiglet==0.8.post1
|
||||
Pygments==2.6.1
|
||||
PyInstaller==3.6
|
||||
pyinstaller-setuptools==2019.3
|
||||
pylint==2.3.1
|
||||
pyobjc==6.0.1
|
||||
pyobjc-core==6.0.1
|
||||
pyobjc-framework-Accounts==6.0.1
|
||||
pyobjc-framework-AddressBook==6.0.1
|
||||
pyobjc-framework-AdSupport==6.0.1
|
||||
pyobjc-framework-AppleScriptKit==6.0.1
|
||||
pyobjc-framework-AppleScriptObjC==6.0.1
|
||||
pyobjc-framework-ApplicationServices==6.0.1
|
||||
pyobjc-framework-AuthenticationServices==6.0.1
|
||||
pyobjc-framework-Automator==6.0.1
|
||||
pyobjc-framework-AVFoundation==6.0.1
|
||||
pyobjc-framework-AVKit==6.0.1
|
||||
pyobjc-framework-BusinessChat==6.0.1
|
||||
pyobjc-framework-CalendarStore==6.0.1
|
||||
pyobjc-framework-CFNetwork==6.0.1
|
||||
pyobjc-framework-CloudKit==6.0.1
|
||||
pyobjc-framework-Cocoa==6.0.1
|
||||
pyobjc-framework-Collaboration==6.0.1
|
||||
pyobjc-framework-ColorSync==6.0.1
|
||||
pyobjc-framework-Contacts==6.0.1
|
||||
pyobjc-framework-ContactsUI==6.0.1
|
||||
pyobjc-framework-CoreAudio==6.0.1
|
||||
pyobjc-framework-CoreAudioKit==6.0.1
|
||||
pyobjc-framework-CoreBluetooth==6.0.1
|
||||
pyobjc-framework-CoreData==6.0.1
|
||||
pyobjc-framework-CoreHaptics==6.0.1
|
||||
pyobjc-framework-CoreLocation==6.0.1
|
||||
pyobjc-framework-CoreMedia==6.0.1
|
||||
pyobjc-framework-CoreMediaIO==6.0.1
|
||||
pyobjc-framework-CoreML==6.0.1
|
||||
pyobjc-framework-CoreMotion==6.0.1
|
||||
pyobjc-framework-CoreServices==6.0.1
|
||||
pyobjc-framework-CoreSpotlight==6.0.1
|
||||
pyobjc-framework-CoreText==6.0.1
|
||||
pyobjc-framework-CoreWLAN==6.0.1
|
||||
pyobjc-framework-CryptoTokenKit==6.0.1
|
||||
pyobjc-framework-DeviceCheck==6.0.1
|
||||
pyobjc-framework-DictionaryServices==6.0.1
|
||||
pyobjc-framework-DiscRecording==6.0.1
|
||||
pyobjc-framework-DiscRecordingUI==6.0.1
|
||||
pyobjc-framework-DiskArbitration==6.0.1
|
||||
pyobjc-framework-DVDPlayback==6.0.1
|
||||
pyobjc-framework-EventKit==6.0.1
|
||||
pyobjc-framework-ExceptionHandling==6.0.1
|
||||
pyobjc-framework-ExecutionPolicy==6.0.1
|
||||
pyobjc-framework-ExternalAccessory==6.0.1
|
||||
pyobjc-framework-FileProvider==6.0.1
|
||||
pyobjc-framework-FileProviderUI==6.0.1
|
||||
pyobjc-framework-FinderSync==6.0.1
|
||||
pyobjc-framework-FSEvents==6.0.1
|
||||
pyobjc-framework-GameCenter==6.0.1
|
||||
pyobjc-framework-GameController==6.0.1
|
||||
pyobjc-framework-GameKit==6.0.1
|
||||
pyobjc-framework-GameplayKit==6.0.1
|
||||
pyobjc-framework-ImageCaptureCore==6.0.1
|
||||
pyobjc-framework-IMServicePlugIn==6.0.1
|
||||
pyobjc-framework-InputMethodKit==6.0.1
|
||||
pyobjc-framework-InstallerPlugins==6.0.1
|
||||
pyobjc-framework-InstantMessage==6.0.1
|
||||
pyobjc-framework-Intents==6.0.1
|
||||
pyobjc-framework-IOSurface==6.0.1
|
||||
pyobjc-framework-iTunesLibrary==6.0.1
|
||||
pyobjc-framework-LatentSemanticMapping==6.0.1
|
||||
pyobjc-framework-LaunchServices==6.0.1
|
||||
pyobjc-framework-libdispatch==6.0.1
|
||||
pyobjc-framework-LinkPresentation==6.0.1
|
||||
pyobjc-framework-LocalAuthentication==6.0.1
|
||||
pyobjc-framework-MapKit==6.0.1
|
||||
pyobjc-framework-MediaAccessibility==6.0.1
|
||||
pyobjc-framework-MediaLibrary==6.0.1
|
||||
pyobjc-framework-MediaPlayer==6.0.1
|
||||
pyobjc-framework-MediaToolbox==6.0.1
|
||||
pyobjc-framework-MetalKit==6.0.1
|
||||
pyobjc-framework-ModelIO==6.0.1
|
||||
pyobjc-framework-MultipeerConnectivity==6.0.1
|
||||
pyobjc-framework-NaturalLanguage==6.0.1
|
||||
pyobjc-framework-NetFS==6.0.1
|
||||
pyobjc-framework-Network==6.0.1
|
||||
pyobjc-framework-NetworkExtension==6.0.1
|
||||
pyobjc-framework-NotificationCenter==6.0.1
|
||||
pyobjc-framework-OpenDirectory==6.0.1
|
||||
pyobjc-framework-OSAKit==6.0.1
|
||||
pyobjc-framework-OSLog==6.0.1
|
||||
pyobjc-framework-PencilKit==6.0.1
|
||||
pyobjc-framework-Photos==6.0.1
|
||||
pyobjc-framework-PhotosUI==6.0.1
|
||||
pyobjc-framework-PreferencePanes==6.0.1
|
||||
pyobjc-framework-PubSub==6.0.1
|
||||
pyobjc-framework-PushKit==6.0.1
|
||||
pyobjc==6.2.2
|
||||
pyobjc-core==6.2.2
|
||||
pyobjc-framework-Accounts==6.2.2
|
||||
pyobjc-framework-AddressBook==6.2.2
|
||||
pyobjc-framework-AdSupport==6.2.2
|
||||
pyobjc-framework-AppleScriptKit==6.2.2
|
||||
pyobjc-framework-AppleScriptObjC==6.2.2
|
||||
pyobjc-framework-ApplicationServices==6.2.2
|
||||
pyobjc-framework-AuthenticationServices==6.2.2
|
||||
pyobjc-framework-AutomaticAssessmentConfiguration==6.2.2
|
||||
pyobjc-framework-Automator==6.2.2
|
||||
pyobjc-framework-AVFoundation==6.2.2
|
||||
pyobjc-framework-AVKit==6.2.2
|
||||
pyobjc-framework-BusinessChat==6.2.2
|
||||
pyobjc-framework-CalendarStore==6.2.2
|
||||
pyobjc-framework-CFNetwork==6.2.2
|
||||
pyobjc-framework-CloudKit==6.2.2
|
||||
pyobjc-framework-Cocoa==6.2.2
|
||||
pyobjc-framework-Collaboration==6.2.2
|
||||
pyobjc-framework-ColorSync==6.2.2
|
||||
pyobjc-framework-Contacts==6.2.2
|
||||
pyobjc-framework-ContactsUI==6.2.2
|
||||
pyobjc-framework-CoreAudio==6.2.2
|
||||
pyobjc-framework-CoreAudioKit==6.2.2
|
||||
pyobjc-framework-CoreBluetooth==6.2.2
|
||||
pyobjc-framework-CoreData==6.2.2
|
||||
pyobjc-framework-CoreHaptics==6.2.2
|
||||
pyobjc-framework-CoreLocation==6.2.2
|
||||
pyobjc-framework-CoreMedia==6.2.2
|
||||
pyobjc-framework-CoreMediaIO==6.2.2
|
||||
pyobjc-framework-CoreML==6.2.2
|
||||
pyobjc-framework-CoreMotion==6.2.2
|
||||
pyobjc-framework-CoreServices==6.2.2
|
||||
pyobjc-framework-CoreSpotlight==6.2.2
|
||||
pyobjc-framework-CoreText==6.2.2
|
||||
pyobjc-framework-CoreWLAN==6.2.2
|
||||
pyobjc-framework-CryptoTokenKit==6.2.2
|
||||
pyobjc-framework-DeviceCheck==6.2.2
|
||||
pyobjc-framework-DictionaryServices==6.2.2
|
||||
pyobjc-framework-DiscRecording==6.2.2
|
||||
pyobjc-framework-DiscRecordingUI==6.2.2
|
||||
pyobjc-framework-DiskArbitration==6.2.2
|
||||
pyobjc-framework-DVDPlayback==6.2.2
|
||||
pyobjc-framework-EventKit==6.2.2
|
||||
pyobjc-framework-ExceptionHandling==6.2.2
|
||||
pyobjc-framework-ExecutionPolicy==6.2.2
|
||||
pyobjc-framework-ExternalAccessory==6.2.2
|
||||
pyobjc-framework-FileProvider==6.2.2
|
||||
pyobjc-framework-FileProviderUI==6.2.2
|
||||
pyobjc-framework-FinderSync==6.2.2
|
||||
pyobjc-framework-FSEvents==6.2.2
|
||||
pyobjc-framework-GameCenter==6.2.2
|
||||
pyobjc-framework-GameController==6.2.2
|
||||
pyobjc-framework-GameKit==6.2.2
|
||||
pyobjc-framework-GameplayKit==6.2.2
|
||||
pyobjc-framework-ImageCaptureCore==6.2.2
|
||||
pyobjc-framework-IMServicePlugIn==6.2.2
|
||||
pyobjc-framework-InputMethodKit==6.2.2
|
||||
pyobjc-framework-InstallerPlugins==6.2.2
|
||||
pyobjc-framework-InstantMessage==6.2.2
|
||||
pyobjc-framework-Intents==6.2.2
|
||||
pyobjc-framework-IOSurface==6.2.2
|
||||
pyobjc-framework-iTunesLibrary==6.2.2
|
||||
pyobjc-framework-LatentSemanticMapping==6.2.2
|
||||
pyobjc-framework-LaunchServices==6.2.2
|
||||
pyobjc-framework-libdispatch==6.2.2
|
||||
pyobjc-framework-LinkPresentation==6.2.2
|
||||
pyobjc-framework-LocalAuthentication==6.2.2
|
||||
pyobjc-framework-MapKit==6.2.2
|
||||
pyobjc-framework-MediaAccessibility==6.2.2
|
||||
pyobjc-framework-MediaLibrary==6.2.2
|
||||
pyobjc-framework-MediaPlayer==6.2.2
|
||||
pyobjc-framework-MediaToolbox==6.2.2
|
||||
pyobjc-framework-Metal==6.2.2
|
||||
pyobjc-framework-MetalKit==6.2.2
|
||||
pyobjc-framework-ModelIO==6.2.2
|
||||
pyobjc-framework-MultipeerConnectivity==6.2.2
|
||||
pyobjc-framework-NaturalLanguage==6.2.2
|
||||
pyobjc-framework-NetFS==6.2.2
|
||||
pyobjc-framework-Network==6.2.2
|
||||
pyobjc-framework-NetworkExtension==6.2.2
|
||||
pyobjc-framework-NotificationCenter==6.2.2
|
||||
pyobjc-framework-OpenDirectory==6.2.2
|
||||
pyobjc-framework-OSAKit==6.2.2
|
||||
pyobjc-framework-OSLog==6.2.2
|
||||
pyobjc-framework-PencilKit==6.2.2
|
||||
pyobjc-framework-Photos==6.2.2
|
||||
pyobjc-framework-PhotosUI==6.2.2
|
||||
pyobjc-framework-PreferencePanes==6.2.2
|
||||
pyobjc-framework-PubSub==6.2
|
||||
pyobjc-framework-PushKit==6.2.2
|
||||
pyobjc-framework-QTKit==6.0.1
|
||||
pyobjc-framework-Quartz==6.0.1
|
||||
pyobjc-framework-QuickLookThumbnailing==6.0.1
|
||||
pyobjc-framework-SafariServices==6.0.1
|
||||
pyobjc-framework-SceneKit==6.0.1
|
||||
pyobjc-framework-ScreenSaver==6.0.1
|
||||
pyobjc-framework-ScriptingBridge==6.0.1
|
||||
pyobjc-framework-SearchKit==6.0.1
|
||||
pyobjc-framework-Security==6.0.1
|
||||
pyobjc-framework-SecurityFoundation==6.0.1
|
||||
pyobjc-framework-SecurityInterface==6.0.1
|
||||
pyobjc-framework-ServiceManagement==6.0.1
|
||||
pyobjc-framework-Social==6.0.1
|
||||
pyobjc-framework-SoundAnalysis==6.0.1
|
||||
pyobjc-framework-Speech==6.0.1
|
||||
pyobjc-framework-SpriteKit==6.0.1
|
||||
pyobjc-framework-StoreKit==6.0.1
|
||||
pyobjc-framework-SyncServices==6.0.1
|
||||
pyobjc-framework-SystemConfiguration==6.0.1
|
||||
pyobjc-framework-SystemExtensions==6.0.1
|
||||
pyobjc-framework-UserNotifications==6.0.1
|
||||
pyobjc-framework-VideoSubscriberAccount==6.0.1
|
||||
pyobjc-framework-VideoToolbox==6.0.1
|
||||
pyobjc-framework-Vision==6.0.1
|
||||
pyobjc-framework-WebKit==6.0.1
|
||||
pyobjc-framework-Quartz==6.2.2
|
||||
pyobjc-framework-QuickLookThumbnailing==6.2.2
|
||||
pyobjc-framework-SafariServices==6.2.2
|
||||
pyobjc-framework-SceneKit==6.2.2
|
||||
pyobjc-framework-ScreenSaver==6.2.2
|
||||
pyobjc-framework-ScriptingBridge==6.2.2
|
||||
pyobjc-framework-SearchKit==6.2.2
|
||||
pyobjc-framework-Security==6.2.2
|
||||
pyobjc-framework-SecurityFoundation==6.2.2
|
||||
pyobjc-framework-SecurityInterface==6.2.2
|
||||
pyobjc-framework-ServiceManagement==6.2.2
|
||||
pyobjc-framework-Social==6.2.2
|
||||
pyobjc-framework-SoundAnalysis==6.2.2
|
||||
pyobjc-framework-Speech==6.2.2
|
||||
pyobjc-framework-SpriteKit==6.2.2
|
||||
pyobjc-framework-StoreKit==6.2.2
|
||||
pyobjc-framework-SyncServices==6.2.2
|
||||
pyobjc-framework-SystemConfiguration==6.2.2
|
||||
pyobjc-framework-SystemExtensions==6.2.2
|
||||
pyobjc-framework-UserNotifications==6.2.2
|
||||
pyobjc-framework-VideoSubscriberAccount==6.2.2
|
||||
pyobjc-framework-VideoToolbox==6.2.2
|
||||
pyobjc-framework-Vision==6.2.2
|
||||
pyobjc-framework-WebKit==6.2.2
|
||||
pyparsing==2.4.1.1
|
||||
python-dateutil==2.8.1
|
||||
PyYAML==5.1.2
|
||||
pyzmq==18.1.1
|
||||
readme-renderer==25.0
|
||||
regex==2020.2.20
|
||||
six==1.12.0
|
||||
requests==2.23.0
|
||||
requests-toolbelt==0.9.1
|
||||
six==1.14.0
|
||||
termcolor==1.1.0
|
||||
toml==0.10.0
|
||||
tornado==6.0.4
|
||||
tox==3.19.0
|
||||
tox-conda==0.2.1
|
||||
tqdm==4.45.0
|
||||
traitlets==4.3.3
|
||||
twine==3.1.1
|
||||
typed-ast==1.4.1
|
||||
wcwidth==0.1.7
|
||||
typing-extensions==3.7.4.2
|
||||
urllib3==1.25.9
|
||||
virtualenv==20.0.30
|
||||
wcwidth==0.1.9
|
||||
webencodings==0.5.1
|
||||
wrapt==1.11.1
|
||||
yarl==1.4.2
|
||||
zipp==0.5.2
|
||||
|
||||
18
setup.py
@@ -48,17 +48,6 @@ with open(
|
||||
with open(os.path.join(this_directory, "README.md"), encoding="utf-8") as f:
|
||||
about["long_description"] = f.read()
|
||||
|
||||
# ugly hack to install custom version of bpylist2 needed for Python < 3.8
|
||||
# the stock version of bylist2==2.0.3 causes an error related to
|
||||
# "pkg_resources.ContextualVersionConflict: (pycodestyle 2.3.1..."
|
||||
# PEP 508 no help here as URL-based lookups not allowed in PyPI packages
|
||||
# if you know a better way, PRs welcome!
|
||||
# once I go to 3.8+ required, this won't be necessary as bpylist2 3.0+ solves this issue
|
||||
if py_ver < 3.8:
|
||||
os.system(
|
||||
"python3 -m pip install git+git://github.com/RhetTbull/bpylist2.git#egg=bpylist2"
|
||||
)
|
||||
|
||||
setup(
|
||||
name="osxphotos",
|
||||
version=about["__version__"],
|
||||
@@ -78,16 +67,15 @@ setup(
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: MacOS :: MacOS X",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
],
|
||||
install_requires=[
|
||||
"pyobjc>=6.0.1",
|
||||
"pyobjc>=6.2.2",
|
||||
"Click>=7",
|
||||
"PyYAML>=5.1.2",
|
||||
"Mako>=1.1.1",
|
||||
"bpylist2==2.0.3;python_version<'3.8'",
|
||||
"bpylist2==3.0.0;python_version>='3.8'",
|
||||
"bpylist2==3.0.2",
|
||||
"pathvalidate==2.2.1",
|
||||
"dataclasses==0.7;python_version<'3.7'",
|
||||
],
|
||||
|
||||
@@ -18,13 +18,25 @@ Some of the export tests rely on photos in my local library and will look for `O
|
||||
One test for locale does not run on GitHub's automated workflow and will look for `OSXPHOTOS_TEST_LOCALE=1` to determine if it should be run. If you want to run this test, set the environment variable.
|
||||
|
||||
## Attribution ##
|
||||
These tests utilize a test Photos library. The test library is populated with photos from [flickr](https://www.flickr.com). All images used are licensed under Creative Commons 2.0 Attribution [license](https://creativecommons.org/licenses/by/2.0/).
|
||||
These tests utilize a test Photos library. The test library is populated with photos from [flickr](https://www.flickr.com) and from my own photo library. All images used are licensed under Creative Commons 2.0 Attribution [license](https://creativecommons.org/licenses/by/2.0/).
|
||||
|
||||
Images used from:
|
||||
Flickr images used from:
|
||||
- [Jeff Hitchcock](https://www.flickr.com/photos/arbron/48353451872/)
|
||||
- [Carlos Montesdeoca](https://www.flickr.com/photos/carlosmontesdeocastudio)
|
||||
- [Rydale Clothing](https://www.flickr.com/photos/rydaleclothing)
|
||||
- [Marco Verch](https://www.flickr.com/photos/30478819@N08/48228222317/)
|
||||
- [K M](https://www.flickr.com/photos/153387643@N08/49334338022/)
|
||||
|
||||
- [Shelby Mash](https://www.flickr.com/photos/shelbzyleigh/3809603052)
|
||||
- [Rory MacLeod](https://www.flickr.com/photos/macrj/6969547134)
|
||||
- [Md. Al Amin](https://www.flickr.com/photos/alamin_bd/45207044465)
|
||||
- [Fatlum Haliti](https://www.flickr.com/photos/lumlumi/363449752)
|
||||
- [Benny Mazur](https://www.flickr.com/photos/benimoto/399012465)
|
||||
- [Sara Cooper PR](https://www.flickr.com/photos/saracooperpr/6422472677)
|
||||
- [herval](https://www.flickr.com/photos/herval/2403994289)
|
||||
- [Vox Efx](https://www.flickr.com/photos/vox_efx/141137669)
|
||||
- [Bill Strain](https://www.flickr.com/photos/billstrain/5117042252)
|
||||
- [Guilherme Yagui](https://www.flickr.com/photos/yagui7/15895161088/)
|
||||
- [Deborah Austin](https://www.flickr.com/photos/littledebbie11/8703591799/)
|
||||
- [We Are Social](https://www.flickr.com/photos/wearesocial/23309711462/)
|
||||
- [cloud.shepherd](https://www.flickr.com/photos/exnucboy1/31017877125)
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||
<date>2020-04-25T23:54:43Z</date>
|
||||
<date>2020-07-27T03:16:28Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||
<date>2020-06-27T16:03:48Z</date>
|
||||
<date>2020-07-27T12:35:43Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<key>LithiumMessageTracer</key>
|
||||
<dict>
|
||||
<key>LastReportedDate</key>
|
||||
<date>2020-04-17T17:51:16Z</date>
|
||||
<date>2020-07-27T03:18:40Z</date>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -11,6 +11,6 @@
|
||||
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
|
||||
<integer>1</integer>
|
||||
<key>PLLastRevGeoVerFileFetchDateKey</key>
|
||||
<date>2020-06-27T16:03:43Z</date>
|
||||
<date>2020-07-27T03:16:25Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LastHistoryRowId</key>
|
||||
<integer>651</integer>
|
||||
<integer>707</integer>
|
||||
<key>LibraryBuildTag</key>
|
||||
<string>D8C4AAA1-3AB6-4A65-BEBD-99CC3E5D433E</string>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<key>HistoricalMarker</key>
|
||||
<dict>
|
||||
<key>LastHistoryRowId</key>
|
||||
<integer>606</integer>
|
||||
<integer>707</integer>
|
||||
<key>LibraryBuildTag</key>
|
||||
<string>D8C4AAA1-3AB6-4A65-BEBD-99CC3E5D433E</string>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
@@ -24,7 +24,7 @@
|
||||
<key>SnapshotCompletedDate</key>
|
||||
<date>2019-07-27T13:16:43Z</date>
|
||||
<key>SnapshotLastValidated</key>
|
||||
<date>2020-06-27T16:03:33Z</date>
|
||||
<date>2020-07-27T03:18:40Z</date>
|
||||
<key>SnapshotTables</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<key>hostuuid</key>
|
||||
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
|
||||
<key>pid</key>
|
||||
<integer>1613</integer>
|
||||
<integer>3125</integer>
|
||||
<key>processname</key>
|
||||
<string>photolibraryd</string>
|
||||
<key>uid</key>
|
||||
|
||||
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 399 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 349 KiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 545 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 307 KiB |