====== Quick project: Images overview generation [python] ====== {{tag>dev python}} While I'm on image manipulation, there is another topic that could be of interest to me. And that is to generate an "overview" image from a given list of images so that I could quickly figure out which image is what just looking at that single overview. As usual, I'm going to use python to build this, let's start! 👍 ====== ====== As in my last post, I need an NVP component and a command line argument to handle this process, I wont repeat that part here, let's just not that I'm using the command name **image-overview**, and I'm going to prepare some initial command line optional parameters for this: psr = ctx.get_parser('main.image-overview') psr.add_argument("-i", "--input", dest="input_folder", type=str, help="Input folder to start overview generation.") psr.add_argument("-m", "--mode", dest="overview_mode", type=str, default="grid", help="Overview generation mode") psr.add_argument("-w", "--width", dest="img_width", type=int, default=800, help="Image width") psr.add_argument("-c", "--cols", dest="num_cols", type=int, default=5, help="Number of columns in grid") psr.add_argument("--aspect", dest="aspect", type=float, default=16/9, help="thumbnail aspect ratio") psr.add_argument("-a", "--animate", dest="animate", action='store_true', help="Specify if we shoudl generate an animation/gif or not") psr.add_argument("--mean", dest="use_mean", action='store_true', help="Use mean instead of median to get statistics") And here is the first "somewhat working version" that I implemented to handle the overview generation: def generate_img_overview(self, input_dir): """Generate an image overview from a given list of images""" logger.info("Should generate overview from images in %s", input_dir) all_files = self.get_all_files(input_dir, recursive=True) logger.info("All files: %s", all_files) # keep only the image files: contain_gifs = False img_exts = [".jpg", ".gif", ".png"] images = [] for fname in all_files: fext = self.get_path_extension(fname).lower() if fext not in img_exts: continue # Open the image img_file = self.get_path(input_dir, fname) img = PIL.Image.open(img_file) frame_dur = 50 nframes = getattr(img, 'n_frames', 1) if fext == ".gif": # frame_dur = self.get_gif_frame_durations(img, img_file) if 'duration' in img.info: frame_dur = img.info['duration'] else: logger.warning("No GIF frame duration found in %s, using default value.", img_file) frame_dur = 50 contain_gifs = True desc = { "ext": fext, "width": img.width, "height": img.height, "nframes": nframes, "frame_duration": int(frame_dur), "total_duration": frame_dur*nframes, "file": img_file } img.close() images.append(desc) # logger.info("Images data: %s", self.pretty_print(images)) if len(images) == 0: logger.info("No images found in %s", input_dir) return animate = self.get_param("animate", False) out_dir = self.get_parent_folder(input_dir) folder_name = self.get_filename(input_dir) # We should name the overview with the folder name: out_ext = ".gif" if (contain_gifs and animate) else ".jpg" out_file = self.get_path(out_dir, f"{folder_name}_overview{out_ext}") self.generate_gif(images, contain_gifs and animate, out_file) def generate_gif(self, images, animate, out_file): """Generate an animated GIF with the given images.""" if animate: # get the median frame duration, all_durs = [] gif_durs = [] for img in images: durs = [img['frame_duration']] * img['nframes'] all_durs += durs gif_durs.append(sum(durs)) use_mean = self.get_param("use_mean", False) if use_mean: frame_dur = int(statistics.mean(all_durs)) total_dur = int(statistics.mean(gif_durs)) else: frame_dur = int(statistics.median(all_durs)) total_dur = int(statistics.median(gif_durs)) logger.info("Median frame dur: %d", frame_dur) logger.info("Median gif duration: %s", total_dur) else: frame_dur = 50 total_dur = 0 # and the median gif duration too: # compute how many frames we need: nframes = int(1 + total_dur/frame_dur) logger.info("Should write %d frames", nframes) # Get the size of the frame: img_width = self.get_param("img_width") ncols = self.get_param("num_cols") num_images = len(images) # If we have less images than number of cols we extends the needed cols: ncols = min(ncols, num_images) thumb_width = int(img_width / ncols) aspect = self.get_param("aspect") thumb_height = int(thumb_width / aspect) logger.info("Should write thumbnails of size %d x %d", thumb_width, thumb_height) # Adapt the image width if needed: img_width2 = thumb_width * ncols if img_width2 != img_width: logger.info("Adapting overwiew image width: %d -> %d", img_width, img_width2) img_width = img_width2 # Now compute how many lines of thumbnails we will need: nrow = 1 + (num_images-1) // ncols # So now we get the final image height: img_height = thumb_height * nrow logger.info("Should write a final GIF of size %d x %d", img_width, img_height) # open all the images and resize them correctly: for i in range(num_images): desc = images[i] img = PIL.Image.open(desc['file']) desc['obj'] = img # Compute the image position on the grid: row = i//ncols col = i % ncols desc['width'] = thumb_width desc['height'] = thumb_height desc['xpos'] = col*thumb_width desc['ypos'] = row*thumb_height logger.info("Cropping frames for %s", desc['file']) srcs = transform_image(img, thumb_width, thumb_height) frames = [] for srcf in srcs: frame = np.array( srcf.copy().convert('RGB').getdata(), dtype=np.uint8).reshape( thumb_height, thumb_width, 3) frames.append(frame) desc['frames'] = frames # So now we write the frames: frames = [] cur_time = 0 for i in range(nframes): logger.info("Generating GIF frame %d/%d", i+1, nframes) dst_frame = np.zeros((img_height, img_width, 3), dtype=np.uint8) for desc in images: self.insert_overview_frame(dst_frame, desc, cur_time) frame = PIL.Image.fromarray(dst_frame, mode="RGB") frames.append(frame) cur_time += frame_dur # Close the images: for i in range(num_images): desc = images[i] desc['obj'].close() # Write the output gif: logger.info("Saving output file %s...", out_file) if out_file.endswith(".jpg"): frames[0].save(out_file, quality=90) else: frames[0].save(out_file, save_all=True, append_images=frames[1:], optimize=True, duration=frame_dur, loop=0) logger.info("Done generating overview image.") def insert_overview_frame(self, dst_frame, desc, cur_time): """Invsert an overview frame inside the parent large frame.""" # Get the frame id we should display: frames = desc['frames'] nframes = len(frames) fdur = desc['frame_duration'] frame_idx = (cur_time//fdur) % nframes data = frames[frame_idx] # inject the data: xpos = desc['xpos'] ypos = desc['ypos'] ww = desc['width'] hh = desc['height'] dst_frame[ypos:ypos+hh, xpos:xpos+ww, :] = data This actually works pretty well already with gif images. But with jpgs, not so good lol: {{ blog:2022:0423:barcelone_overview_1.jpg?800 }} => What I need here is to take into account the image orientation from the EXIF data I think, let's see... So I simply updated this sinppet of code just before starting to prepare the image thumbs: for i in range(num_images): desc = images[i] img = PIL.Image.open(desc['file']) # cf. https://stackoverflow.com/questions/13872331/rotating-an-image-with-orientation-specified-in-exif-using-python-without-pil-in try: orientation = None for orientation, val in ExifTags.TAGS.items(): if val == 'Orientation': break exif = img.getexif() 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) except (AttributeError, KeyError, IndexError): # cases: image don't have getexif pass desc['obj'] = img # Continue here as in code above... And this time, this did the trick :-): {{ blog:2022:0423:barcelone_overview_2.jpg?800 }} Not always the best thumbnail results of course, but well, that's better than nothing, right ?! I'm now already thinking about extending this little tool further: for instance to place the images randomly on a canvas with layers/transparency support and borders around each image: that would be awesome 😁! But since this is only a quick project here let's stop it here, and continue in another post later, see yaa 😉!