#!/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 = """
\n""" buf += f"""""" buf += f"""{filename}""" buf += "" 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"\n" if self.gps: buf += f"\n" if self.make: buf += f"\n" buf += "\n" if self.f_num: buf += f"\n" if self.exposure: buf += f"\n" if self.iso: buf += f"\n" if self.focal_length: buf += f"\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"\n" buf += "\n" if self.exposure_program: buf += f"\n" if self.exposure_mode: buf += f"\n" if self.focus_mode: buf += f"\n" if self.subject_distance: buf += f"\n" if self.focus_distance: buf += f"\n" if self.flash: buf += f"\n" if self.software: buf += f"\n" buf += "
Zeit{self.datetime}
Ort{self.gps}
Kamera{self.make}
 
Blende{self.f_num}
Belichtung{self.exposure}
ISO{self.iso}
Brennweite{self.focal_length}
Helligkeit{text}
 
Modus{self.exposure_program}
Belichtung{self.exposure_mode}
Fokus{self.focus_mode}
Entfernung{self.subject_distance}
Schärfebereich{self.focus_distance}
Blitz{self.flash}
Software{self.software}

\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)