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