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