199 lines
7.0 KiB
Python
199 lines
7.0 KiB
Python
"""Detect QR Codes in photos in Apple Photos and add qrcode tag/keyword to photos contain a QR Code
|
|
|
|
Run with `osxphotos run detect_qrcodes.py`
|
|
|
|
Run with `osxphotos run detect_qrcodes.py --help` for help
|
|
|
|
All dependencies are already installed as part of a standard osxphotos install.
|
|
"""
|
|
|
|
import datetime
|
|
import json
|
|
import os
|
|
import os.path
|
|
from typing import List
|
|
|
|
import click
|
|
import objc
|
|
import Quartz
|
|
from Cocoa import NSURL
|
|
from Foundation import NSDictionary
|
|
from photoscript import Photo, PhotosLibrary
|
|
from rich import print
|
|
from rich.progress import Progress
|
|
|
|
from osxphotos import PhotosDB
|
|
from osxphotos.cli.common import get_data_dir
|
|
from osxphotos.sqlitekvstore import SQLiteKVStore
|
|
|
|
QRCODE_KEYWORD = "qrcode"
|
|
|
|
|
|
def detect_qrcodes_in_image(filepath: str) -> List[str]:
|
|
"""Detect QR Codes in images using CIDetector and return text of the found QR Codes"""
|
|
with objc.autorelease_pool():
|
|
context = Quartz.CIContext.contextWithOptions_(None)
|
|
options = NSDictionary.dictionaryWithDictionary_(
|
|
{"CIDetectorAccuracy": Quartz.CIDetectorAccuracyHigh}
|
|
)
|
|
detector = Quartz.CIDetector.detectorOfType_context_options_(
|
|
Quartz.CIDetectorTypeQRCode, context, options
|
|
)
|
|
|
|
results = []
|
|
input_url = NSURL.fileURLWithPath_(filepath)
|
|
input_image = Quartz.CIImage.imageWithContentsOfURL_(input_url)
|
|
features = detector.featuresInImage_(input_image)
|
|
|
|
if not features:
|
|
return []
|
|
for idx in range(features.count()):
|
|
feature = features.objectAtIndex_(idx)
|
|
results.append(feature.messageString())
|
|
return results
|
|
|
|
|
|
@click.command()
|
|
@click.option(
|
|
"--keyword",
|
|
"-k",
|
|
default=QRCODE_KEYWORD,
|
|
help=f"Keyword to add to photos with QR Code; default = '{QRCODE_KEYWORD}'.",
|
|
)
|
|
@click.option(
|
|
"--description",
|
|
"-d",
|
|
is_flag=True,
|
|
help="Set the description of the photo to the decoded QR Code text.",
|
|
)
|
|
@click.option(
|
|
"--verbose",
|
|
"-V",
|
|
"verbose_mode",
|
|
is_flag=True,
|
|
help="Print verbose output.",
|
|
)
|
|
@click.option(
|
|
"--dry-run",
|
|
"-n",
|
|
is_flag=True,
|
|
help="Dry run mode: don't actually update keywords in Photos library.",
|
|
)
|
|
@click.option(
|
|
"--selected",
|
|
"-s",
|
|
is_flag=True,
|
|
help="Only process selected photos.",
|
|
)
|
|
@click.option(
|
|
"--reset",
|
|
"-R",
|
|
help="Reset the database of previously processed photos.",
|
|
is_flag=True,
|
|
)
|
|
def detect_qrcodes(keyword, description, verbose_mode, dry_run, selected, reset):
|
|
"""Detect QR Codes in your photos and add a tag/keyword to photos containing a QR Code."""
|
|
# osxphotos includes a simple sqlite-based key-value store for storing data
|
|
# Use this to store photos that have already been processed so if the script is re-run
|
|
# it doesn't re-process photos that have already been processed
|
|
|
|
# get_data_dir() returns the path to the user's XDG data directory, usually ~/.local/share/osxphotos
|
|
# reference: https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
|
|
|
|
def verbose(msg):
|
|
if not verbose_mode:
|
|
return
|
|
print(msg)
|
|
|
|
db_path = os.path.join(get_data_dir(), "qrcodes.db")
|
|
if reset:
|
|
verbose(f"Resetting database: {db_path}")
|
|
if os.path.exists(db_path):
|
|
os.remove(db_path)
|
|
verbose(f"Using database {db_path}")
|
|
# enable write-ahead logging for performance, serialize/deserialize data as JSON
|
|
kvstore = SQLiteKVStore(
|
|
db_path, wal=True, serialize=json.dumps, deserialize=json.loads
|
|
)
|
|
|
|
# get list of photos to process
|
|
verbose("Getting list of photos to process...")
|
|
# Capture selection before loading the Photos database it can take a while to process the database
|
|
# and the selection may change while the database is being processed
|
|
selection = PhotosLibrary().selection if selected else []
|
|
# the QR detection doesn't work on movies so exclude movies
|
|
photos = PhotosDB().photos(movies=False)
|
|
if selected:
|
|
selected_uuid = [p.uuid for p in selection]
|
|
photos = [p for p in photos if p.uuid in selected_uuid]
|
|
|
|
# track number of photos processed for reporting at the end
|
|
num_photos = len(photos)
|
|
num_processed = 0
|
|
num_previously_processed = 0
|
|
num_skipped = 0
|
|
num_qrcodes = 0
|
|
|
|
# process all the photos
|
|
with Progress() as progress:
|
|
task = progress.add_task(f"Processing {num_photos} photos", total=num_photos)
|
|
for photo in photos:
|
|
# check if photo has already been processed
|
|
if kvstore.get(photo.uuid):
|
|
verbose(
|
|
f"Skipping previously processed photo {photo.original_filename} ({photo.uuid})"
|
|
)
|
|
num_previously_processed += 1
|
|
continue
|
|
|
|
# cv2.imread() doesn't work on some file types like HEIC but every photo has a
|
|
# JPEG preview image ("derivative" in Photos) so use that. The preview image is
|
|
# smaller and lower-resolution, but in my testing, good enough for QR detection
|
|
if not photo.path_derivatives:
|
|
verbose(
|
|
f"Skipping {photo.original_filename} ({photo.uuid}), could not find photo path"
|
|
)
|
|
num_skipped += 1
|
|
continue
|
|
photo_path = photo.path_derivatives[0]
|
|
|
|
# record that will be stored in the kvstore database
|
|
record = {
|
|
"datetime": datetime.datetime.now().isoformat(),
|
|
"uuid": photo.uuid,
|
|
"original_filename": photo.original_filename,
|
|
"qrcode": None,
|
|
}
|
|
verbose(f"Processing {photo.original_filename} ({photo.uuid})")
|
|
num_processed += 1
|
|
if qrcode_text := detect_qrcodes_in_image(photo_path):
|
|
# add qrcode tag/keyword to photo
|
|
# osxphotos PhotoInfo objects are read-only but you can get a photoscript Photo object
|
|
# that allows you to modify certain data about the Photo via the Photos app AppleScript interface
|
|
photo_ = Photo(photo.uuid)
|
|
if not dry_run:
|
|
photo_.keywords = list(set(photo_.keywords + [keyword]))
|
|
if description:
|
|
photo_.description = ", ".join(qrcode_text)
|
|
record["qrcode"] = qrcode_text
|
|
verbose(
|
|
f"Added {keyword} to {photo.original_filename} ({photo.uuid}), detected QR Code: {qrcode_text}"
|
|
)
|
|
num_qrcodes += 1
|
|
|
|
# store photo in kvstore to indicate it's been processed
|
|
if not dry_run:
|
|
kvstore.set(photo.uuid, record)
|
|
|
|
progress.advance(task)
|
|
|
|
print(f"Processed {num_photos} photos")
|
|
print(f"Previously processed {num_previously_processed} photos")
|
|
print(f"Skipped {num_skipped} missing photos")
|
|
print(f"Processed {num_processed} photos this time")
|
|
print(f"Detected {num_qrcodes} QR Codes")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
detect_qrcodes()
|