====== ThumbGen: New elements design and SVG generators ====== {{tag>dev python youtube thumbnail thumbgen }} In this article we continue our journey on how to improve on our youtube thumbnail generation process. ====== ====== Youtube video for this article available at: ;#; {{ youtube>rGF2DvbUg3E?large }} ;#; ===== ✅ Adding support for common elements ===== I think I just got a new interesting idea on this thumbnail generation system: I could introduce support for a collection of re-usable/customizable "elements"! I will start with a common file "common.yml" with some content like this: # Common parameters: parameters: title: "No title" subtitle: "" # Common element templates: templates: title_bg: text: "${title}" hide_text: true font: BebasNeue.otf rect_color: [51, 51, 51, 170] hpad: 1000px vpad: 5px pos: [20px, 20px] anchor: "dtl" Now let's try to use that. Introducing support to load the "common" templates on thumbnail generation request: # Load the common templates/parameters: common_file = self.get_path(desc_dir, "common.yml") if self.file_exists(common_file): logger.info("Loading common templates...") self.load_templates(common_file) With the following new method now: def load_templates(self, tpl_file): """Load the templates from a given file""" if not self.file_exists(tpl_file): logger.warning("Missing template file %s", tpl_file) cfg = self.read_yaml(tpl_file) # override any parameter: params = cfg.get("parameters", {}) for key, val in params.items(): self.parameters[key] = val # override/extend any templates: tpls = cfg.get("templates", {}) for key, tpl in tpls.items(): if key not in self.templates: self.templates[key] = tpl else: basetpl = self.templates[key] for ename, entry in tpl.items(): basetpl[ename] = entry And now injecting our base template elements whenever this is applicable in the thumbnail build process: def inject_base(self, desc): """Check if there is a base in our description and inject it in that case""" if "base" not in desc: return desc # remove the base element from the desc now: bname = desc.pop("base") # get the template with that name tpl = self.inject_base(self.templates[bname]) for key, val in tpl.items(): if key not in desc: desc[key] = val return desc def add_elements(self, img_arr, elems): """Add "sub-images" on our background image""" img = Image.fromarray((img_arr * 255.0).astype(np.uint8)) for desc in elems: # For each element, we check if we have a base: desc = self.inject_base(desc) img = self.add_element(img, desc) return np.array(img).astype(np.float32) / 255.0 Next, adding support to inject parameters too: def inject_parameters(self, desc, params=None): """Inject the parameters in an element description""" if params is None: params = {} # Inject the common parameters: params.update(self.parameters) # Inject the desc itself as it may contain parameter overrides too: if isinstance(desc, dict): params.update(desc) if isinstance(desc, dict): for key, val in desc.items(): desc[key] = self.inject_parameters(val, params) if isinstance(desc, list): desc = [self.inject_parameters(el) for el in desc] if isinstance(desc, str): while "$" in desc: for pname, pval in params.items(): src = f"${{{pname}}}" if desc == src: # Replace the source string potentially changing the type: return pval elif src in desc: desc = desc.replace(src, pval) return desc Which those updates, here is how I can describe a thumbnail content in a more concise way now: thumbnail: params: title: "QT for WebAssembly\nInitial guide" subtitle: "Starting our journey\nwith QT and emscripten" elements: # Background rect for title/subtitle: - base: title_bg - base: subtitle_bg # Face: - base: face src: face/smile3.png mirror: true brightness: 1.1 # tint_factors: [0.82,1.05,1.05] scale: 1.0 angle: 0.0 - base: title_right - base: subtitle And this will still produce the expected visual as shown below: {{ blog:2023:033_thumbgen_templates.png }} ===== ✅ Introducing support for layer resizing ===== Next I wanted to add support to specify a background image with a simple entry such as: - base: background src: backgrounds/computers2.png Thus I prepared the corresponding template: background: size: [100%, 100%] resize_mode: fill pos: [0.0, 0.0] anchor: "tl" bg_color: [0, 255, 0, 255] And in this process I had to introduce support for layer resizing + resizing mode which can be "stretch", "fit" or "fill" for now: def resize_layer(self, layer, new_width, new_height, mode, bg_color): """Resize an image layer with different modes""" new_width = int(new_width) new_height = int(new_height) if layer.width == new_width and layer.height == new_height: # Nothing to resize here: return layer new_size = (new_width, new_height) if mode == "stretch": # Just stretch the image: return layer.resize(new_size) if mode == "fit" or mode == "fill": # We should fit the image on the given area: img = Image.new("RGBA", new_size, tuple(bg_color)) # Compute the ratios by which we should reduce the initial image # to get to the desired dimensions: wratio = new_width / layer.width hratio = new_height / layer.height # We should use the smallest reduction factor to ensure both dimensions can # fit in the destination image: if mode == "fit": ratio = hratio if wratio > hratio else wratio else: ratio = hratio if wratio < hratio else wratio nsize = (int(ratio * layer.width), int(ratio * layer.height)) layer = layer.resize(nsize) # Now we copy the layer at the center of our image: pos = ((img.width - layer.width) // 2, (img.height - layer.height) // 2) img.paste(layer, pos, mask=layer) return img self.throw("Cannot handle resize mode %s", mode) return None => so with this mechanism I can now very easily specify the position/size of any given layer 👍! ===== ✅ Add support for custom generators ===== Added dedicated **svg_generator.py** file and will add custom generators there. For now I just added generators to construct a simple arrow: def generate_arrow(desc, img): """Generate an SVG arrow from a given description""" # Settings to draw an arrow: scale = desc["scale"] bh = desc["body_height"] * scale bw = desc["body_width"] * scale hh = desc["head_height"] * scale hw = desc["head_width"] * scale sw = desc["stroke_width"] * scale scol = desc["stroke_color"] fcol = desc["fill_color"] padx = desc["padding_x"] pady = desc["padding_y"] # First we need to figure out what should be the size of our layer image: size = desc["svg_size"] sww = padx * 2 + sw * 2 + bw + hw shh = pady * 2 + sw * 2 + max(bh, hh) if size == "auto": svg_width = sww svg_height = shh else: svg_width = to_px_size(size[0], img.width) svg_height = to_px_size(size[1], img.height) orig = desc["svg_origin"] orig_x = to_px_size(orig[0], svg_width) orig_y = to_px_size(orig[1], svg_height) # Start drawing: logger.info("Generating SVG of size %dx%d", svg_width, svg_height) drw = draw.Drawing(svg_width, svg_height, origin=(orig_x, orig_y)) # Draw the path representing the arrow: # , transform=f"scale({scale})" p = draw.Path(stroke_width=sw, stroke=scol, fill=fcol) x0 = padx + sw cy = svg_height * 0.5 p.M(x0, cy - bh * 0.5).L(x0 + bw, cy - bh * 0.5) p.L(x0 + bw, cy - hh * 0.5).L(x0 + bw + hw, cy) p.L(x0 + bw, cy + hh * 0.5) p.L(x0 + bw, cy + bh * 0.5).L(x0, cy + bh * 0.5).Z() drw.append(p) return drw And another one to produce a "curved arrow" using a circle shape for the body of the arrow: def generate_curved_arrow(desc, img): """Generate an SVG arrow from a given description""" # Settings to draw an arrow: scale = desc["scale"] bh = desc["body_height"] * scale bw = desc["body_width"] * scale hh = desc["head_height"] * scale hw = desc["head_width"] * scale sw = desc["stroke_width"] * scale scol = desc["stroke_color"] fcol = desc["fill_color"] padx = desc["padding_x"] pady = desc["padding_y"] # First we need to figure out what should be the size of our layer image: size = desc["svg_size"] sww = padx * 2 + sw * 2 + bw + hw shh = pady * 2 + sw * 2 + max(bh, hh) * 2 if size == "auto": svg_width = sww svg_height = shh else: svg_width = to_px_size(size[0], img.width) svg_height = to_px_size(size[1], img.height) orig = desc["svg_origin"] orig_x = to_px_size(orig[0], svg_width) orig_y = to_px_size(orig[1], svg_height) # Start drawing: logger.info("Generating SVG of size %dx%d", svg_width, svg_height) drw = draw.Drawing(svg_width, svg_height, origin=(orig_x, orig_y)) # drw = draw.Drawing(svg_width * 3, svg_height * 3, origin=(orig_x, orig_y)) # Draw the path representing the arrow: # x0 = padx + sw cy = svg_height * 0.5 p = draw.Path(stroke_width=sw, stroke=scol, fill=fcol, transform=f"rotate(38,{bh*0.5},{cy})") # p = draw.Path(stroke_width=sw, stroke=scol, fill=fcol) # compute sqrt of body width: sbw = bw / math.sqrt(2) r1 = sbw + bh * 0.5 r2 = sbw - bh * 0.5 dh = (hh - bh) * 0.5 # cy = 0 p.M(0, cy).A(r1, r1, 0, 0, 1, r1, cy - r1) p.L(r1, cy - r1 - dh).L(r1 + hw, cy - r1 + bh * 0.5).L(r1, cy - r1 + bh + dh) p.L(r1, cy - r1 + bh) p.A(r2, r2, 0, 0, 0, bh, cy) p.Z() drw.append(p) return drw Next we just need to register those generators in the thumbnail generator itself, which is done in the constructor: # List of available layer generators: self.generators = { "arrow": svg.generate_arrow, "curved_arrow": svg.generate_curved_arrow, "highlight_lines": svg.generate_highlight_lines, } And finally, those SVG generator names can be used to define new elements to build our thumbnails using the **type** entry: svg_base: svg_size: auto svg_origin: [0, 0] anchor: cc svg_arrow: type: arrow base: svg_base body_height: 8 body_width: 40 head_height: 20 head_width: 10 stroke_width: 2 stroke_color: white fill_color: red padding_x: 5 padding_y: 5 scale: 1.0