A+ occasionally likes to flip through pictures in a photo album. I
want to print another batch of 4x6 photos, and I'd like to crop them
before labeling them with the date from the EXIF info. Most of the
pictures are from my phone, so I have a 4:3 aspect ratio instead of
the 3:2 aspect ratio I want for prints.
First step: figuring out how to get the size of an image. I could
either use Emacs's built-in image-size
function or call
ImageMagick's identify
command. Which one's faster? First, I define
the functions:
(defun my-image-size (filename)
(let ((img (create-image filename)))
(prog1 (image-size img t) (image-flush img))))
(defun my-identify-image-size (filename)
(let ((result
(seq-map 'string-to-number
(split-string
(shell-command-to-string
(concat "identify -format \"%w %h\" " (shell-quote-argument filename)))))))
(when (and result (> (car result) 0))
result)))
and then benchmark them:
(let ((filename "~/Downloads/Other prints/20230102_135059.MP.jpg")
(times 10))
(list (benchmark times `(my-image-size ,filename))
(benchmark times `(my-identify-image-size ,filename))))
Looks like ImageMagick's identify
command is a lot faster.
Now I can define a filter:
Code for aspect ratios
(defun my-aspect-ratio (normalize &rest args)
"Return the aspect ratio of ARGS.
If NORMALIZE is non-nil, return an aspect ratio >= 1 (width is greater than height).
ARGS can be:
- width height
- a filename
- a list of (width height)"
(let (size width height result)
(cond
((stringp (car args))
(setq size (my-identify-image-size (car args)))
(setq width (car size) height (cadr size)))
((listp (car args))
(setq width (car (car args)) height (cadr (car args))))
(t
(setq width (car args) height (cadr args))))
(when (and width height)
(setq result (/ (* 1.0 width) height))
(if (and normalize (< result 1))
(/ 1 result)
result))))
(defun my-files-not-matching-aspect-ratio (print-width print-height file-list)
(let ((target-aspect-ratio (my-aspect-ratio t print-width print-height)))
(seq-filter
(lambda (filename)
(let ((image-ratio (my-aspect-ratio t filename)))
(when image-ratio
(> (abs (- image-ratio
target-aspect-ratio))
0.001))))
file-list)))
and I could use it like this to get a list of files that need to be cropped:
(my-files-not-matching-aspect-ratio 4 6 (directory-files "~/Downloads/Other prints" t))
… which is most of the pictures, so let's see if I can get smartcrop
to automatically crop them as a starting point. I used npm install -g
smartcrop-cli node-opencv
to install the Node packages I needed, and
then I defined these functions:
Code for cropping
(defvar my-smartcrop-image-command '("smartcrop" "--faceDetection"))
(defun my-smartcrop-image (filename aspect-ratio output-file &optional do-copy)
"Call smartcrop command to crop FILENAME to ASPECT-RATIO if needed.
Write the result to OUTPUT-FILE.
If DO-COPY is non-nil, copy files if they already have the correct aspect ratio."
(when (file-directory-p output-file)
(setq output-file (expand-file-name (file-name-nondirectory filename)
output-file)))
(let* ((size (my-identify-image-size filename))
(image-ratio (my-aspect-ratio t size))
new-height new-width
buf)
(when image-ratio
(if (< (abs (- image-ratio aspect-ratio)) 0.01)
(when do-copy (copy-file filename output-file t))
(with-current-buffer (get-buffer-create "*smartcrop*")
(erase-buffer)
(setq new-width
(number-to-string
(floor (min
(car size)
(*
(cadr size)
(if (> (car size) (cadr size))
aspect-ratio
(/ 1.0 aspect-ratio))))))
new-height
(number-to-string
(floor (min
(cadr size)
(/
(car size)
(if (> (car size) (cadr size))
aspect-ratio
(/ 1.0 aspect-ratio)))))))
(message "%d %d -> %s %s: %s" (car size) (cadr size) new-width new-height filename)
(apply 'call-process
(car
my-smartcrop-image-command)
nil t t
(append
(cdr my-smartcrop-image-command)
(list
"--width"
new-width
"--height"
new-height
filename
output-file))))))))
so that I could use this code to process the files:
(let ((aspect-ratio (my-aspect-ratio t 4 6))
(output-dir "~/Downloads/Other prints/cropped"))
(mapc (lambda (file)
(unless (file-exists-p (expand-file-name (file-name-nondirectory file) output-dir))
(my-smartcrop-image file aspect-ratio output-dir t)))
(directory-files "~/Downloads/Other prints" t)))
Then I can use Geeqie to review the cropped images and straighten or re-crop specific ones with Shotwell.
It looks like smartcrop removes the exif information (including
original date), so I want to copy that info again.
for FILE in *; do exiftool -TagsFromFile "../$FILE" "-all:all>all:all" "exif/$FILE"; done
And then finally, I can add the labels with this add-labels.py script, which I call with add-labels.py output-dir file1 file2 file3...
.
add-labels.py: add the date to the lower left corner
import sys
import PIL
import PIL.Image as Image
import PIL.ImageDraw as ImageDraw
import PIL.ImageFont as ImageFont
from PIL import Image, ExifTags
import re
import os
PHOTO_DIR = "/home/sacha/photos/"
OUTPUT_DIR = sys.argv[1]
OUTPUT_WIDTH = 6
OUTPUT_HEIGHT = 4
OUTPUT_RATIO = OUTPUT_WIDTH * 1.0 / OUTPUT_HEIGHT
font_fname = "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf"
ALWAYS = True
DO_ROTATE = False
def label_image(filename):
numbers = re.sub(r'[^0-9]', '', filename)
img = Image.open(filename)
exif = {
PIL.ExifTags.TAGS[k]: v
for k, v in img._getexif().items() if k in PIL.ExifTags.TAGS
}
if DO_ROTATE:
if exif['Orientation'] == 3:
img = img.rotate(180, expand=True)
elif exif['Orientation'] == 6:
img = img.rotate(270, expand=True)
elif exif['Orientation'] == 8:
img = img.rotate(90, expand=True)
time = exif['DateTimeOriginal'][0:10].replace(':', '-')
if not time:
if len(numbers) >= 10 and numbers[0:4] >= '2016' and numbers[0:4] < '2025':
time = '%s-%s-%s' % (numbers[0:4], numbers[4:6], numbers[6:8])
new_filename = os.path.join(OUTPUT_DIR, time + ' ' + os.path.basename(filename))
if ALWAYS or not os.path.isfile(new_filename):
out = add_label(img, time)
print(filename, time)
out.save(new_filename)
return new_filename
def add_label(img, caption):
draw = ImageDraw.Draw(img)
w, h = img.size
border = int(min(w, h) * 0.02)
font_size = int(min(w, h) * 0.04)
font = ImageFont.truetype(font_fname, font_size)
_, _, text_w, text_h = draw.textbbox((0, 0), caption, font)
overlay = Image.new('RGBA', (w, h))
draw = ImageDraw.Draw(overlay)
draw.rectangle([(border, h - text_h - 2 * border),
(text_w + 3 * border, h - border)],
fill=(255, 255, 255, 128))
draw.text((border * 2, h - text_h - 2 * border), caption, (0, 0, 0), font=font)
out = Image.alpha_composite(img.convert('RGBA'), overlay).convert('RGB')
return out
if len(sys.argv) >= 2:
for a in sys.argv[2:]:
if ALWAYS or not os.path.exists(a):
print(a)
try:
label_image(a)
except Exception as e:
print("Error", a, e)
I hope it all works out, since I've just ordered 120 4x6 prints
covering the past three years or so…