import argparse from geopy.geocoders import Nominatim import math import os import re import shutil # from typing_extensions import TypeVarTuple from PIL import Image, ImageOps from datetime import datetime from PIL.ExifTags import TAGS WORK_DIR = 'work' HUGO_ROOT = '..' HUGO_STATIC_DIR = '../static' IMAGE_ROOT_DIR = 'images/gallery' DROPIN_DIR = '../../dropin' FULLS_DIR = 'fulls' SVGS_DIR = 'svgs' THUMBS_DIR = 'thumbs' IMAGES_DIR = '../../images' SPOT_DIR = 'data/spot' HOME_LAT = 50.18573 HOME_LON = 9.14265 COLOR_CIRCLE = "#cc9966" COLOR_DOT = "#996600" # Radius around Home in meters RADIUS = 15000 # pixel = meter * scale SCALE = 0.1 # Number oy images per spot SPOTS_SIZE = 9 images = [] bundles = [] # program arguments args = None def create_banner_svg(): """ Create banner SVG files based on the build bundles. All things will be done in the locale work folder. SVG has following coordinate system: (0,0) +---------------------> +x | | +--------------+ (2*homx)+margx | | | | | (homx,homy) | | | + | | | | | | | | +--------------+ | (2*homy)+margy | V +y """ # print(f"Creating banner SVG file") # Circle should not hit image edges margin = int(round(RADIUS / 20.0)) # All calculations in a home centered coordinte system (not in a SVG coordinate system), # unscaled and without margin # Initialize xmax = RADIUS ymax = RADIUS xmin = -xmax ymin = -ymax # find canvas size (without margin): all image points have to be on the canvas # TODO Not useful as long the points will not be shown for image in images: if image['x'] > xmax: xmax = image['x'] if image['x'] < xmin: xmin = image['x'] if image['y'] > ymax: ymax = image['y'] if image['y'] < ymin: ymin = image['y'] radius15 = RADIUS radiusCenter = int(round(RADIUS * 0.02)) offsetx = -xmin + margin offsety = -ymin + margin opacbundlemax = 0.4 opacbundlemin = 0.0001 opacbundlestep = round((opacbundlemax - opacbundlemin) / len(bundles),4) # opacimagemax = 0.8 # opacimagemin = 0.2 radius_dot =int(round(RADIUS * 0.01)) print(f"Creating banner SVG, corners=({xmin},{ymin}),({xmax},{ymax})") name = os.path.join(WORK_DIR, IMAGE_ROOT_DIR, SVGS_DIR, "banner.svg") with open(name, 'w') as file: file.write(f"\n") file.write(f"\n") file.write(f"\n") file.write(f" \n ") file.write(f"\n") file.write(f" \n ") file.write(f"\n") for i, bundle in enumerate(bundles): log(f" bundle {i} with {len(bundle['images']) + 1} images") file.write(f"\n") file.write(f"") file.write(f"") def create_svgs(): """ Create SVGs files based on the build bundles. All things will be done in the locale work folder. SVG has following coordinate system: (0,0) +---------------------> +x | | +--------------+ (2*homx)+margx | | | | | (homx,homy) | | | + | | | | | | | | +--------------+ | (2*homy)+margy | V +y """ print(f"create_svgs()") # Circle should not hit image edges margin = int(round(RADIUS / 10.0)) # All calculations in a home centered coordinte system (not in a SVG coordinate system), # unscaled and without margin # Initialize opacbundlemax = 0.4 opacbundlemin = 0.0001 opacbundlestep = round((opacbundlemax - opacbundlemin) / len(bundles),4) opacimagemax = 0.8 opacimagemin = 0.2 opacmidmax = 0.9 opacmidmin = 0.4 radius_dot =int(round(RADIUS * 0.01)) for i, bundle in enumerate(bundles): # log(f"create_svgs() bundle {bundle['x']} {bundle['y']} {bundle['radius']}") # Always show center xmax = max(bundle['x'] + bundle['radius'], 0) ymax = max(bundle['y'] + bundle['radius'], 0) xmin = min(bundle['x'] - bundle['radius'], 0) ymin = min(bundle['y'] - bundle['radius'], 0) log(f" bundle: {bundle['image']['date']}: ({xmin},{ymin},{xmax},{ymax})") # log(f" ({xmax-xmin},{ymax-ymin}) - {round((xmax-xmin) / (ymax-ymin),1)}, ") # All images incl. main bundle image images = bundle['images'].copy() images.append(bundle['image']) # find canvas size (without margin): all image points have to be on the canvas for image in images: if image['x'] > xmax: xmax = image['x'] if image['x'] < xmin: xmin = image['x'] if image['y'] > ymax: ymax = image['y'] if image['y'] < ymin: ymin = image['y'] # log(f" now ({xmin},{ymin},{xmax},{ymax}) / ({xmax-xmin},{ymax-ymin}) - {round((xmax-xmin) / (ymax-ymin),1)}, ") # Original aspect ratio: 16:10 ratio = 1.8 ytarget = ( xmax - xmin ) / ratio # Expand y if ( ymax - ymin ) < ytarget: # log(f" Expanding y with ytarget={ytarget} ") ymaxold = ymax ymax = int(round(ymax + ((ytarget - ( ymax - ymin )) / 2))) ymin = int(round(ymin - ((ytarget - ( ymaxold - ymin )) / 2))) # Expand x else: xtarget = int(round(( ymax - ymin ) * ratio)) # log(f" Expanding x with xtarget={xtarget} ") xmaxold = xmax xmax = int(round(xmax + ((xtarget - ( xmax - xmin )) / 2))) xmin = int(round(xmin - ((xtarget - ( xmaxold - xmin )) / 2))) # log(f" -->({xmin},{ymin}),({xmax},{ymax}) ") # log(f" ({xmax-xmin},{ymax-ymin}) - {round((xmax-xmin) / (ymax-ymin),1)}, ") radius15 = RADIUS home_radius_center = int(round(RADIUS * 0.02)) offsetx = -xmin + margin offsety = -ymin + margin name = os.path.join(WORK_DIR, IMAGE_ROOT_DIR, SVGS_DIR, f"{bundle['image']['date']}.svg") log(f" --> Spot SVG, corners=({xmin},{ymin}),({xmax},{ymax}") with open(name, 'w') as file: file.write(f"\n") file.write(f"\n") file.write(f"\n") file.write(f" \n ") file.write(f"\n") file.write(f" \n ") file.write(f"\n") file.write(f"\n") r_from = scale((bundle['radius'] + radius_dot) * 0.995) r_to = scale((bundle['radius'] + radius_dot) * 1.005) file.write(f"\n") file.write(f"\n") file.write(f"") # Center dot of bundle circle file.write(f"\n") file.write(f"") opacimagestep = round((opacimagemax - opacimagemin) / len(images),2) for i, image in enumerate(images): file.write(f"\n") file.write(f"") file.write(f"") print("..done") def scale(value): return int(value * SCALE) def get_exif(fn, warns): """ Return dictionary with EXIF data and the optinal title. """ ret = {} with Image.open(fn) as i: info = i._getexif() for tag, value in info.items(): decoded = TAGS.get(tag, tag) ret[decoded] = value xmp = i.getxmp() title = False try: title = xmp['xmpmeta']['RDF']['Description'][0]['title']['Alt']['li']['text'] # log(f"Found title for {fn}: {ret['title']}") except: pass if not title: try: title = xmp['xmpmeta']['RDF']['Description'][0]['title'] # log(f"Found title for {fn}: {ret['title']}") except: pass if title: # log(f"{fn} Title={title}") ret['title'] = title else: if args.verbose: warns.append(f"No title found: {fn}") return ret def get_geo(gpsInfo): lat = 0 lon = 0 lat = (((gpsInfo[2][2] / 60.0) + gpsInfo[2][1]) / 60.0) + gpsInfo[2][0] if gpsInfo[1] == 'S': lat = -lat lon = (((gpsInfo[4][2] / 60.0) + gpsInfo[4][1]) / 60.0) + gpsInfo[4][0] if gpsInfo[3] == 'W': lon = -lon # log(f"get_geo: gpsInfo={gpsInfo[1]}, {gpsInfo[2]}, {gpsInfo[3]}, {gpsInfo[4]} --> lat={lat}, lon={lon}") return lat, lon def get_pos(lat, lon): """ Calculates the distance in meters between the given geo point and the HOME. Calculation is based on https://www.kompf.de/gps/distcalc.html Returns a tuple (x, y) with distance in integer meters, based on ths SVG cordinate system | -y | |(0,0) -x -------+------- +x | | | +y """ x = int(round(71.5 * 1000.0 * (lon - HOME_LON))) y = int(round(111.3 * 1000.0 * (HOME_LAT - lat))) # log(f"get_pos: {lon} - {HOME_LON} --> x={x} ") # log(f"get_pos: {lat} - {HOME_LAT} --> y={y} ") return x, y def resolve_pos(x, y): """ Reverse calculation for get_pos() """ lon = round((x / (71.5 * 1000)) + HOME_LON, 6) lat = round(HOME_LAT - (y / (111.3 * 1000)), 6) log(f"resolve_pos: ({x},{y}) --> {lat}, {lon} ") return lat, lon def create_image_item(dir, file, warns): """ Create image dictionary for the given image file in the given directory. Both must exists. Returns dectionary with image item or, in error case, false. """ filename = os.path.join(dir, file) target_filename = os.path.join(IMAGES_DIR, file) tags = get_exif(filename, warns) # Invalid items gpsInfo=, (nan, nan, nan), , (nan, nan, nan) if 'GPSInfo' in tags and (tags['GPSInfo'][1] == 'N' or tags['GPSInfo'][1] == 'S'): lat, lon = get_geo(tags['GPSInfo']) else: lat, lon = (HOME_LAT, HOME_LON) warns.append(f"No GPS data: {file}") return False x, y = get_pos(lat, lon) # log(f"{name}") format = "%Y:%m:%d %H:%M:%S" dt = datetime.strptime(tags['DateTime'], format) image = dict(dt=dt, date=dt.strftime('%Y%m%d'),name=file, filename=target_filename, tags=tags, lat=lat, lon=lon, x=x, y=y) return image def format_image(name): """ Create full and thumb for one image from the dropin folder All things will be done in the locale work folder. Returns True in success case, otherwise False """ thumb_size = (360, 360) full_width = 1900 filename = os.path.join(DROPIN_DIR, name) try: with Image.open(filename) as im: # Must be rotated by exif Orientation, otherwise thumb and full will not be # shown the image in the correct rotation im = ImageOps.exif_transpose(im) # Size of the image in pixels (size of original image) # (This is not mandatory) width, height = im.size # Setting the points for cropped image if width > height: offset = int(round((width - height) / 2)) left = offset top = 0 right = height + offset bottom = height else: offset = int(round((height - width) / 2)) left = 0 top = offset right = width bottom = width + offset # Cropped image of above dimension # (It will not change original image) thumb = im.crop((left, top, right, bottom)) thumb.thumbnail(thumb_size) thumb.save(os.path.join(WORK_DIR, IMAGE_ROOT_DIR, THUMBS_DIR, name), "JPEG") concat = full_width/float(im.size[0]) size = int((float(im.size[1])*float(concat))) # print(f"concat,size={concat}, {size}") out = im.resize((full_width,size), Image.ANTIALIAS) out.save(os.path.join(WORK_DIR, IMAGE_ROOT_DIR, FULLS_DIR, name), "JPEG") except OSError: warn(f"\ncannot convert image {filename}! ") return False return True def process_images(): """ Process all images and create fulls and thumbnails from the dropin folder. Move the processed images into /images and rebuild hugo content All things will be done in the locale folder (work, dropin, images) """ log(f"Processing images from '{DROPIN_DIR}'") global images images = [] p = re.compile('\d{8}.*\.(jpg|jpeg)', re.IGNORECASE) new_files = [f for f in os.listdir(DROPIN_DIR) if os.path.isfile(os.path.join(DROPIN_DIR, f)) and p.match(f)] old_files = [f for f in os.listdir(IMAGES_DIR) if os.path.isfile(os.path.join(IMAGES_DIR, f)) and p.match(f)] log(f"Found {len(new_files)} images in {DROPIN_DIR} and {len(old_files)} images in {IMAGES_DIR}") if not new_files and not old_files: exit_with_good_bye(f"No images found.") cnt = 0 err = 0 warns =[] for name in new_files: cnt += 1 print(f".", end="", flush=True) # Can print a warning image = create_image_item(DROPIN_DIR, name, warns) if not image is False: # Can print a warning formatted = format_image(name) if image is False or formatted is False: char = "x" err += 1 else: char = '.' shutil.move( os.path.join(DROPIN_DIR, name), image['filename']) images.append(image) if cnt % 10 == 0: print(f"\b{char} ({cnt})", end="\n", flush=True) else: print(f"\b{char}", end="", flush=True) for name in old_files: cnt += 1 image = create_image_item(IMAGES_DIR, name, warns) # This should never happens if image is False: char = "X" err += 1 else: char = 'o' images.append(image) if cnt % 10 == 0: print(f"\b{char} ({cnt})", end="\n", flush=True) else: print(f"\b{char}", end="", flush=True) print("\n") if warns: for message in warns: warn(message) if not images: exit_with_good_bye("No valid images.") print(f"Total processed {len(new_files)} new and {len(old_files)} old images: {len(images)} OK, {err} ignored.") def create_bundles(): """ Bundle images to spots by global images list. Output is a list of spots with tpe dict: { image=main_image, images=list_of_satellite_images, } All things will be done in the locale work folder. """ print(f"Create bundle images") global bundles bundles = [] if len(images)==0: exit_on_error("No images found to bundle") end = False left_images = images while not end: bundle = create_bundle(left_images) if bundle is None: end = True break bundles.append(bundle) log(f"removing main image={bundle['image']['name']}") left_images.remove(bundle['image']) for image in bundle['images']: log(f"removing bundle image={image['name']}") left_images.remove(image) # log(f"len={len(left_images)}") print(f"Processed {len(bundles)} bundles.") def calculate_circle(image, _images): """ Find the smallest circle for the images by finding the two point that are furhtermost from each other. Returns dict with x, y as distance from HOME and radius, all in meters as integer. """ # circle dimension. Initialization for case of single image bundle # 100.0 is no magic number, it's just a value to show something around the single image point max_radius = 100.0 x = image['x'] y = image['y'] images = _images.copy() images.append(image) log(f"calculate_circle for {len(images)} images", end="") for i, item1 in enumerate(images): # log(f"image {i}: {item1['x'],item1['y']}") for item2 in images[(i+1):]: x_dist = item2['x'] - item1['x'] y_dist = item2['y'] - item1['y'] radius = int(round(math.sqrt(x_dist**2 + y_dist**2) / 2.0)) if max_radius >= radius: continue max_radius = radius x = int(round(item1['x'] + (x_dist/2.0))) y = int(round(item1['y'] + (y_dist/2.0))) # log(f"image {i}: {item1['x'],item1['y']}") ret = dict(x=x,y=y,radius=max_radius) print(f"...done: {ret}") return ret def create_bundle(_images): if len(_images) == 0: return None # Find newest images.sort(key = lambda item: item.get("dt")) # Work with copy to prevent from newest = images[-1] log(f"Create bundle for newest={newest['name']}") # Add distance for image in images: image['dist'] = int(round(math.sqrt(((newest['x'] - image['x']) ** 2) + ((newest['y'] - image['y']) ** 2)))) # find nearbys. Be careful: newest must not be at first place, if there is a second image with dist == 0 images.sort(key = lambda item: item.get("dist")) # for image in images: # print(f"images: image={image['name']} dist={image['dist']}") bundle_images = [] # Exclde newest from list (must not be at first place, see above) for image in [i for i in images if i['name'] != newest['name']][:SPOTS_SIZE-1]: log(f"image={image['name']}") bundle_images.append(image) circle = calculate_circle(newest, bundle_images) bundle_images.sort(key = lambda item: item.get("date"), reverse=True) ret = dict(image=newest, images=bundle_images, x=circle['x'], y=circle['y'], radius=circle['radius']) log(f"Bundle created: x={ret['x']}, y={ret['y']}, radius={ret['radius']}") # print(f"newest ={ret['image']['date']}") # for i in ret['images']: # print(f"image ={i['dist']} , {i['date']}") return ret def get_address(latitude, longitude, language="de"): coordinates = f"{latitude}, {longitude}" # Switch off for develop pursoses if args.location_mockup: return "Entenhausen" try: geolocator = Nominatim(user_agent="de.kollegen.dierundestunde") location = geolocator.reverse(coordinates) log(f"get_address() {location}") parts = location.address.split(", ") if len(parts) == 8: return f"{parts[1]}, {parts[2]}" elif len(parts) >= 2: return f"{parts[0]}, {parts[1]}" else: return location.address except: print("geolocator failed") return "" def create_spots(): """ Create all spots content based on the build bundles. All things will be done in the locale work folder. """ print(f"Creating spots", end="") i = 0 for bundle in bundles: i += 1 filename = os.path.join(WORK_DIR, SPOT_DIR, f"{bundle['image']['date']}.yml") if (i % 2) == 0: orient = "orient-left" else: orient = "orient-right" # Get time range oldest = bundle['image']['date'] for image in bundle['images']: if image['date'] < oldest: oldest = image['date'] start = datetime.strptime(oldest, "%Y%m%d") end = datetime.strptime(bundle['image']['date'], "%Y%m%d") diff = end.date() - start.date() # Center lon, lat = resolve_pos(bundle['x'], bundle['y']) address = get_address(lon, lat) print(f"address={address}") # print(f"creating {filename}") with open(filename, 'w') as file: file.write(f"title: {bundle['image']['date'] }\n") file.write(f"headerstyle: \"style1 {orient} content-align-left image-position-center onscroll-image-fade-in\"\n") file.write(f"style: \"style2 medium lightbox onscroll-fade-in\"\n") file.write(f"header: \"{IMAGE_ROOT_DIR}/{SVGS_DIR}/{bundle['image']['date']}.svg\"\n") file.write(f"content: |\n") file.write(f" ➙ {address}
\n") file.write(f" R = {bundle['radius']} m
\n") file.write(f" Δ = {diff.days} Tage\n") file.write(f"circle:\n") file.write(f" - pos: \"({bundle['x']}, {bundle['y']})\"\n") file.write(f" radius: \"{bundle['radius']}\"\n") file.write(f"pictures:\n") write_picture_yaml(file, bundle['image']) for image in bundle['images']: write_picture_yaml(file, image) print(f".", end="") print(f" OK {len(bundles)} files created.") def write_picture_yaml(file, image): if 'title' in image['tags']: log(f"write_picture_yaml {image['date']} title={image['tags']['title']}") file.write(f" - title: \"{image['tags']['title']}\"\n") file.write(f" subtitle: \"{image['date']}\"\n") else: file.write(f" - title: \"{image['date']}\"\n") file.write(f" content: \"distance={image['dist']} m\"\n") file.write(f" distance: \"{image['dist']}\"\n") file.write(f" geo: \"{image['lon']}, {image['lat']}\"\n") file.write(f" pos: \"({str(image['x'])}, {str(image['y'])})\"\n") file.write(f" image: \"{os.path.join(IMAGE_ROOT_DIR, FULLS_DIR, image['name'])}\"\n") file.write(f" thumb: \"{os.path.join(IMAGE_ROOT_DIR, THUMBS_DIR, image['name'])}\"\n") file.write(f" button: \"Ansehen\"\n") def activate(): """ Active spots incl images from work folder to the hugo project. Old spots will be removed. This should only be processed in success case. """ print(f"Update hugo content", end="") spot_dir = os.path.join(HUGO_ROOT, SPOT_DIR) image_root_dir = os.path.join(HUGO_STATIC_DIR, IMAGE_ROOT_DIR) thumbs_dir = os.path.join(image_root_dir, THUMBS_DIR) fulls_dir = os.path.join(image_root_dir, FULLS_DIR) svgs_dir = os.path.join(image_root_dir, SVGS_DIR) if os.path.isdir(spot_dir): shutil.rmtree(spot_dir) shutil.copytree(os.path.join(WORK_DIR, SPOT_DIR), spot_dir) if not os.path.isdir(image_root_dir): os.makedirs(image_root_dir) if os.path.isdir(svgs_dir): shutil.rmtree(svgs_dir) shutil.copytree(os.path.join(WORK_DIR, IMAGE_ROOT_DIR, SVGS_DIR), svgs_dir) if os.path.isdir(thumbs_dir): shutil.rmtree(thumbs_dir) shutil.copytree(os.path.join(WORK_DIR, IMAGE_ROOT_DIR, THUMBS_DIR), thumbs_dir) if os.path.isdir(fulls_dir): shutil.rmtree(fulls_dir) shutil.copytree(os.path.join(WORK_DIR, IMAGE_ROOT_DIR, FULLS_DIR), fulls_dir) print("...done") def log(message, end="\n", flush=False): if args.verbose: print(message, end=end, flush=flush) def init_work(): """ Setup working directory. This should be the very first step. Fulls and thumbs directories will only be cleared with reset argument . """ log(f"Initialize directories") # The very firs time if not os.path.exists(DROPIN_DIR): print(f"Creating missing directory '{DROPIN_DIR}'") os.mkdir(DROPIN_DIR) if not os.path.exists(IMAGES_DIR): print(f"Creating missing directory '{IMAGES_DIR}'") os.mkdir(IMAGES_DIR) if not os.path.exists(WORK_DIR): print(f"Creating missing directory structure '{WORK_DIR}'") os.mkdir(WORK_DIR) fulls = os.path.join(WORK_DIR, IMAGE_ROOT_DIR, FULLS_DIR) if args.rebuild_all and os.path.exists(fulls): print(f"Removing {fulls}") shutil.rmtree(fulls) # The very firs time or in case of a rebuild all if not os.path.exists(fulls): os.makedirs(fulls) thumbs = os.path.join(WORK_DIR, IMAGE_ROOT_DIR, THUMBS_DIR) if args.rebuild_all and os.path.exists(thumbs): print(f"Removing {thumbs}") shutil.rmtree(thumbs) # The very first time or in case of a rebuild all if not os.path.exists(thumbs): log(f"Creating {thumbs}") os.makedirs(thumbs) # Reset always svgs = os.path.join(WORK_DIR, IMAGE_ROOT_DIR, SVGS_DIR) if os.path.isdir(svgs): log(f"Clearing {svgs}") shutil.rmtree(svgs) os.makedirs(svgs) # Reset always spot = os.path.join(WORK_DIR, SPOT_DIR) if os.path.isdir(spot): log(f"Clearing {spot}") shutil.rmtree(spot) os.makedirs(spot) # Rebuild all: move existing original images to dropin if args.rebuild_all: p = re.compile('\d{8}.*\.(jpg|jpeg)', re.IGNORECASE) old_files = [f for f in os.listdir(IMAGES_DIR) if os.path.isfile(os.path.join(IMAGES_DIR, f)) and p.match(f)] if len(old_files) == 0: print(f"No files im {IMAGES_DIR}") else: print(f"Moving {len(old_files)} files from {IMAGES_DIR} to {DROPIN_DIR}", end="") for file in old_files: shutil.move(os.path.join(IMAGES_DIR, file), os.path.join(DROPIN_DIR, file)) log("..done") log("Initialization done") def exit_on_error(message): print(f"ERROR {message}") exit(1) def exit_with_good_bye(message): print(f"{message} --- Good bye ---") exit(0) def warn(message): print(f"WARN {message}") def do_it(): init_work() process_images() create_bundles() create_svgs() create_banner_svg() create_spots() activate() print("Congratulations! Generation done.") def parse_args(): global args parser = argparse.ArgumentParser(description="Update drs by adding new images and create spot files. " + "This script must be called from its directory 'redaktion'. Usage example: Add new images into ../../dropin directory and call without parameters. This images will be moved to ../../images. ") parser.add_argument("-v", "--verbose", action="store_true", help="Increase output verbosity.") parser.add_argument("-a", "--rebuild_all", action="store_true", help="Process images in /images in additional to the /dropin images") parser.add_argument("-l", "--location_mockup", action="store_true", help="Supress cost intensive location service. Mock all locations. Just for developer purposes.") args = parser.parse_args() if __name__ == '__main__': parse_args() do_it()