blog:2022:0423_image_overview_generation

Quick project: Images overview generation [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:

barcelone_overview_1.jpg

⇒ 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 :-):

barcelone_overview_2.jpg

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 😉!

  • blog/2022/0423_image_overview_generation.txt
  • Last modified: 2022/04/23 07:10
  • by 127.0.0.1