#!/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 os import PIL from PIL import Image from progress.bar import Bar import shutil 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 ThumbnailHTML: def __init__(self): self.gps = None self.datetime = None self.file_link = None self.make = None self.focus = None def set_datetime(self, dt): self.datetime = dt.strftime("""%d.%m.%Y %H:%M""") def set_focus(self, f_num, exposure, focal_length, focal_length35, iso): entries = list() if f_num is not None: entries.append(f"""f/{format_f(f_num)}""") if exposure is not None: if exposure >= 1: entries.append( f"""{format_f(exposure)}s""" ) elif exposure >= 1e-3: entries.append( f"""{format_f(exposure * 1e3)}ms""" ) else: entries.append( f"""{format_f(exposure * 1e6)}µs""" ) 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)" entries.append(f"""{entry}""") if iso is not None: entries.append(f"""ISO{iso}""") self.focus = " ".join(entries) def set_gps(self, gps): self.gps = f"""{gps.location}""" def set_makemodel(self, make, model): self.make = f"""{make} {model}""" def to_html(self, index, filename, thumbname): exif_lines = (self.datetime, self.gps, self.make, self.focus) exif_html = """ """.join(filter(bool, exif_lines)) buf = """
\n""" buf += f"""""" buf += f"""{filename}""" buf += "" buf += "
" buf += f"""
\n""" buf += ( f"""

{filename}""" ) buf += f"""{exif_html}

\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.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.convert("RGB").save(self.thumbname, "JPEG") self.html = ThumbnailHTML() self._get_datetime() self._get_focus() self._get_makemodel() 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): pass if dt: self.exif_dt = dt self.html.set_datetime(dt) 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 self.html.set_focus(f_num, exposure, focal_length, focal_length35, iso) 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): return self.html.to_html(i, self.filename, self.thumbname) 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") parser.add_argument("--size", type=int, default=250, help="Thumbnail size [px]") parser.add_argument("--sort", type=str, 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-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: continue thumbnails.append( Thumbnail(filename, im, size=args.size, with_gps=args.with_nominatim) ) 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) 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)