#!/usr/bin/env python3
# vim:tabstop=4 softtabstop=4 shiftwidth=4 textwidth=160 smarttab expandtab colorcolumn=160
#
# Copyright (C) 2021 Daniel Friesel
#
# SPDX-License-Identifier: BSD-2-Clause
import argparse
from datetime import datetime
import exifread
import json
import os
import PIL
from PIL import Image
from progress.bar import Bar
import shutil
import subprocess
import sys
geocoder = None
location_cache = dict()
def rotate_image(image, exif_tag):
if "Image Orientation" not in exif_tag:
return image
orientation = exif_tag["Image Orientation"].values
if 3 in orientation:
image = image.transpose(Image.ROTATE_180)
if 6 in orientation:
image = image.transpose(Image.ROTATE_270)
if 8 in orientation:
image = image.transpose(Image.ROTATE_90)
return image
def format_f(value, precision=1):
if value % 1 == 0:
return f"{value:.0f}"
return f"{value:.{precision}f}"
class ProgressBar(Bar):
suffix = "%(percent).0f%% [%(elapsed_td)s/%(eta_td)s]"
class GPSData:
def __init__(self, lat, lon, location):
self.lat = lat
self.lon = lon
self.location = location
class ImageHTML:
def __init__(self):
self.gps = None
self.datetime = None
self.file_link = None
self.make = None
self.focus = None
self.ev = None
self.exposure = None
self.exposure_mode = None
self.exposure_program = None
self.f_num = None
self.flash = None
self.focal_length = None
self.focus_distance = None
self.focus_mode = None
self.iso = None
self.lv = None
self.software = None
self.subject_distance = None
def set_datetime(self, dt):
self.datetime = dt.strftime("""%d.%m.%Y %H:%M""")
def set_ev(self, ev):
self.ev = f"""{ev} EV"""
def set_exposure_mode(self, exposure_mode):
self.exposure_mode = f"""{exposure_mode}"""
def set_exposure_program(self, exposure_program):
self.exposure_program = (
f"""{exposure_program}"""
)
def set_flash(self, flash):
self.flash = f"""{flash}"""
def set_focus_distance(self, lower, upper):
if upper == "inf":
upper = "∞"
self.focus_distance = (
f"""{lower} – {upper}"""
)
def set_focus_mode(self, mode):
self.focus_mode = f"""{mode}"""
def set_focus(
self, f_num, exposure, focal_length, focal_length35, crop_factor, iso
):
entries = list()
if f_num is not None:
self.f_num = f"""f/{format_f(f_num)}"""
entries.append(self.f_num)
if exposure is not None:
if exposure >= 1:
self.exposure = (
f"""{format_f(exposure)}s"""
)
elif exposure >= 1e-3:
self.exposure = (
f"""{format_f(exposure * 1e3)}ms"""
)
else:
self.exposure = (
f"""{format_f(exposure * 1e6)}µs"""
)
entries.append(self.exposure)
if focal_length is not None:
entry = f"{format_f(focal_length)}mm"
if focal_length35 is not None and focal_length35 != focal_length:
entry += f" (≙ {format_f(focal_length35)}mm)"
elif crop_factor is not None:
entry += f" (≙ {format_f(focal_length * crop_factor, 0)}mm)"
self.focal_length = f"""{entry}"""
entries.append(self.focal_length)
if iso is not None:
self.iso_iso = f"""ISO{iso}"""
self.iso = f"""{iso}"""
entries.append(self.iso_iso)
self.focus = " ".join(entries)
def set_gps(self, gps):
self.gps = f"""{gps.location}"""
def set_lv(self, lv):
self.lv = f"""{lv} LV"""
def set_makemodel(self, make, model):
self.make = f"""{make} {model}"""
def set_software(self, software):
self.software = f"""{software}"""
def set_subject_distance(self, distance):
if distance < 10000:
self.subject_distance = (
f"""{distance} m"""
)
else:
self.subject_distance = f"""∞"""
def to_thumbnail_html(self, index, filename, thumbname, with_detail_page=False):
if with_detail_page:
self.focus = f"""{self.focus}"""
exif_lines = (self.datetime, self.gps, self.make, self.focus)
exif_html = """ • """.join(filter(bool, exif_lines))
buf = """
"
buf += f"""\n"""
buf += (
f"""
{filename}"""
)
buf += f"""•{exif_html}
\n"""
buf += "
\n"
return buf
def to_detail_html(self, filename):
buf = """"""
buf += f"""

"""
buf += "
\n"
buf += """\n"""
buf += "
\n"
if self.datetime:
buf += f"Zeit | {self.datetime} |
\n"
if self.gps:
buf += f"Ort | {self.gps} |
\n"
if self.make:
buf += f"Kamera | {self.make} |
\n"
buf += " | |
\n"
if self.f_num:
buf += f"Blende | {self.f_num} |
\n"
if self.exposure:
buf += f"Belichtung | {self.exposure} |
\n"
if self.iso:
buf += f"ISO | {self.iso} |
\n"
if self.focal_length:
buf += f"Brennweite | {self.focal_length} |
\n"
if self.ev or self.lv:
if self.ev and self.lv:
text = f"{self.lv} / {self.ev}"
elif self.ev:
text = self.ev
elif self.lv:
text = self.lv
buf += f"Helligkeit | {text} |
\n"
buf += " | |
\n"
if self.exposure_program:
buf += f"Modus | {self.exposure_program} |
\n"
if self.exposure_mode:
buf += f"Belichtung | {self.exposure_mode} |
\n"
if self.focus_mode:
buf += f"Fokus | {self.focus_mode} |
\n"
if self.subject_distance:
buf += f"Entfernung | {self.subject_distance} |
\n"
if self.focus_distance:
buf += f"Schärfebereich | {self.focus_distance} |
\n"
if self.flash:
buf += f"Blitz | {self.flash} |
\n"
if self.software:
buf += f"Software | {self.software} |
\n"
buf += "
\n"
return buf
class Thumbnail:
def __init__(self, filename, im, size=250, with_gps=False):
self.filename = filename
self.size = size
self.exif_dt = None
self.gps = None
with open(filename, "rb") as f:
self.exif_tag = exifread.process_file(f)
self.exiftool = dict()
try:
exiftool = subprocess.run(
["exiftool", "-json", "-escapeHTML", "-groupNames", filename],
stdout=subprocess.PIPE,
text=True,
)
if exiftool.returncode == 0:
self.exiftool = json.loads(exiftool.stdout)[0]
except FileNotFoundError:
pass
self.thumbname = f".thumbnails/{filename}"
if not filename.lower().endswith((".jpeg", ".jpg")):
self.thumbname += ".jpg"
im = rotate_image(im, self.exif_tag)
im.thumbnail((self.size * 2, self.size * 2))
im = im.convert("RGB")
im.save(self.thumbname, "JPEG")
if args.with_detail_page and 0:
self.average_color = im.resize((1, 1)).getpixel((0, 0))
self.luminance = im.convert("L").getpixel((0, 0))
self.html = ImageHTML()
self._get_datetime()
self._get_focus()
self._get_makemodel()
self._get_details()
if with_gps:
self._get_gps()
def _get_datetime(self):
dt = None
try:
dt = datetime.strptime(
self.exif_tag["EXIF DateTimeOriginal"].values, "%Y:%m:%d %H:%M:%S"
)
except (KeyError, ValueError):
try:
dt = datetime.strptime(
self.exif_tag["Image DateTimeOriginal"].values, "%Y:%m:%d %H:%M:%S"
)
except (KeyError, ValueError):
try:
dt = datetime.strptime(
self.exif_tag["Image DateTime"].values, "%Y:%m:%d %H:%M:%S"
)
except (KeyError, ValueError):
dt = datetime.fromtimestamp(os.path.getmtime(self.filename))
if dt:
self.exif_dt = dt
self.html.set_datetime(dt)
def _get_details(self):
try:
self.html.set_flash(self.exif_tag["EXIF Flash"])
except KeyError:
pass
try:
self.html.set_subject_distance(
float(self.exif_tag["EXIF SubjectDistance"].values[0])
)
except (KeyError, ZeroDivisionError):
pass
try:
self.html.set_focus_distance(
self.exiftool["MakerNotes:FocusDistanceLower"],
self.exiftool["MakerNotes:FocusDistanceUpper"],
)
except KeyError:
pass
try:
self.html.set_software(self.exif_tag["Image Software"])
except KeyError:
pass
try:
self.html.set_focus_mode(self.exiftool["MakerNotes:FocusMode"])
except KeyError:
pass
try:
self.html.set_focus_mode(self.exiftool["MakerNotes:FocusMode"])
except KeyError:
pass
try:
self.html.set_ev(self.exiftool["MakerNotes:MeasuredEV"])
except KeyError:
pass
try:
self.html.set_lv(self.exiftool["Composite:LightValue"])
except KeyError:
pass
def _get_focus(self):
entries = list()
f_num = None
exposure = None
focal_length = None
focal_length35 = None
iso = None
try:
f_num = float(self.exif_tag["EXIF FNumber"].values[0])
except (KeyError, ZeroDivisionError):
pass
try:
exposure = float(self.exif_tag["EXIF ExposureTime"].values[0])
except (KeyError, ZeroDivisionError):
pass
try:
focal_length = float(self.exif_tag["EXIF FocalLength"].values[0])
focal_length35 = float(
self.exif_tag["EXIF FocalLengthIn35mmFilm"].values[0]
)
except (KeyError, ZeroDivisionError):
pass
try:
iso = self.exif_tag["EXIF ISOSpeedRatings"].values[0]
except KeyError:
pass
crop_factor = self.exiftool.get("Composite:ScaleFactor35efl", None)
self.html.set_focus(
f_num, exposure, focal_length, focal_length35, crop_factor, iso
)
try:
self.html.set_exposure_mode(self.exif_tag["EXIF ExposureMode"])
except KeyError:
pass
try:
self.html.set_exposure_program(self.exif_tag["EXIF ExposureProgram"])
except KeyError:
pass
def _get_gps(self):
try:
lat = self.exif_tag["GPS GPSLatitude"]
latref = self.exif_tag["GPS GPSLatitudeRef"].values[0]
lon = self.exif_tag["GPS GPSLongitude"]
lonref = self.exif_tag["GPS GPSLongitudeRef"].values[0]
except KeyError:
return
try:
lat = (
float(lat.values[0])
+ float(lat.values[1]) / 60
+ float(lat.values[2]) / 3600
)
lon = (
float(lon.values[0])
+ float(lon.values[1]) / 60
+ float(lon.values[2]) / 3600
)
except (IndexError, ZeroDivisionError):
return
if abs(lat) < 0.01 and abs(lon) < 0.01:
return
if latref == "S":
lat = -lat
if lonref == "W":
lon = -lon
global location_cache
global geocoder
latlon = f"{lat:.3f}/{lon:.3f}"
if latlon in location_cache:
self.gps = GPSData(lat, lon, location_cache[latlon])
self.html.set_gps(self.gps)
return
if geocoder is None:
from geopy.geocoders import Nominatim
geocoder = Nominatim(user_agent="pyggle +https://github.com/derf/pyggle")
# zoom level: 12/13 -> city, 14/15 -> district, 16/17 -> street, 18 -> house no
try:
res = geocoder.reverse((lat, lon), zoom=args.nominatim_zoom)
location = res.address.split(",")[0]
location_cache[latlon] = location
except TypeError as e:
location = latlon
self.gps = GPSData(lat, lon, location)
self.html.set_gps(self.gps)
def _get_makemodel(self):
try:
make = self.exif_tag["Image Make"].values
model = self.exif_tag["Image Model"].values
except KeyError:
return
if model.startswith(make):
model = model[len(make) :]
if model[0] == " ":
model = model[1:]
try:
lens = self.exif_tag["EXIF LensModel"]
if lens:
model += f" + {lens}"
except KeyError:
# Unknown or built-in lens
pass
self.html.set_makemodel(make, model)
def to_html(self, index, with_detail_page=False):
return self.html.to_thumbnail_html(
i, self.filename, self.thumbname, with_detail_page
)
def to_detail_html(self, html_prefix, html_postfix):
with open(f"{self.filename}.html", "w") as f:
f.write(html_prefix.replace("", self.filename))
f.write(self.html.to_detail_html(self.filename))
f.write(html_postfix)
def copy_files(base_dir):
for directory in ".data/css .data/js .thumbnails".split():
os.makedirs(directory, exist_ok=True)
boxwidth = f"{args.size * args.spacing}px"
boxheight = f"{args.size * args.spacing}px"
imgwidth = f"{args.size}px"
imgheight = f"{args.size}px"
css_files = ["glightbox.min.css", "light.css", "dark.css"]
js_files = ["glightbox.min.js"]
for css_file in css_files:
shutil.copy(f"{base_dir}/css/{css_file}", f".data/css/{css_file}")
for js_file in js_files:
shutil.copy(f"{base_dir}/js/{js_file}", f".data/js/{js_file}")
with open(f"{base_dir}/css/main.css", "r") as f:
main_css = f.read()
main_css = main_css.replace("/* $boxwidth */", boxwidth)
main_css = main_css.replace("/* $boxheight */", boxheight)
main_css = main_css.replace("/* $imgwidth */", imgwidth)
main_css = main_css.replace("/* $imgheight */", imgheight)
with open(".data/css/main.css", "w") as f:
f.write(main_css)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter, description=__doc__
)
parser.add_argument(
"--html-include",
metavar="FILE",
type=str,
help="file with HTML to include before thumbnail list",
)
parser.add_argument(
"--nominatim-zoom",
type=int,
default=16,
help="Zoom Level for reverse geocoding",
)
parser.add_argument("--reverse", action="store_true", help="Reverse sort order")
parser.add_argument(
"--scrub-metadata",
action="store_true",
help="Scrub EXIF metadata from images (IN-PLACE EDIT)",
)
parser.add_argument("--size", type=int, default=250, help="Thumbnail size [px]")
parser.add_argument(
"--sort",
metavar="MODE",
choices=["none", "time"],
default="none",
help="Sort images",
)
parser.add_argument(
"--spacing", type=float, default=1.1, help="Thumbnail spacing ratio"
)
parser.add_argument("--title", type=str, help="HTML title", default="")
parser.add_argument("--with-detail-page", action="store_true")
parser.add_argument("--with-nominatim", action="store_true")
parser.add_argument("images", type=str, nargs="+")
args = parser.parse_args()
base_dir = "/".join(os.path.realpath(sys.argv[0]).split("/")[:-2])
copy_files(f"{base_dir}/share")
with open(f"{base_dir}/share/html_start", "r") as f:
html_buf = f.read().replace("", args.title)
if args.html_include:
with open(args.html_include, "r") as f:
html_buf += f.read()
filenames = args.images
thumbnails = list()
for i, filename in enumerate(ProgressBar(max=len(filenames)).iter(filenames)):
try:
im = Image.open(filename)
except PIL.UnidentifiedImageError:
try:
im = Image.open(f".thumbnail.for.{filename}")
except FileNotFoundError:
continue
except PIL.UnidentifiedImageError:
# perhaps raise a warning?
continue
thumbnails.append(
Thumbnail(filename, im, size=args.size, with_gps=args.with_nominatim)
)
if args.scrub_metadata:
subprocess.run(
[
"exiftool",
"-q",
"-overwrite_original",
"-EXIF:SerialNumber=",
"-EXIF:LensSerialNumber=",
"-Makernotes:all=",
"-geotag=",
"-ThumbnailImage=",
filename,
]
)
if args.sort == "time":
thumbnails = sorted(thumbnails, key=lambda t: t.exif_dt, reverse=args.reverse)
for i, thumbnail in enumerate(thumbnails):
html_buf += thumbnail.to_html(i, args.with_detail_page)
if args.with_detail_page:
with open(f"{base_dir}/share/html_detail_start", "r") as f:
detail_html_start = f.read()
with open(f"{base_dir}/share/html_detail_end", "r") as f:
detail_html_end = f.read()
for thumbnail in thumbnails:
thumbnail.to_detail_html(detail_html_start, detail_html_end)
with open(f"{base_dir}/share/html_end", "r") as f:
html_buf += f.read()
with open("index.html", "w") as f:
f.write(html_buf)