blog:2023:0729_thumbgen_elements_and_svg_gens

ThumbGen: New elements design and SVG generators

In this article we continue our journey on how to improve on our youtube thumbnail generation process.

Youtube video for this article available at:

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:

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

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

  • blog/2023/0729_thumbgen_elements_and_svg_gens.txt
  • Last modified: 2023/07/29 14:25
  • (external edit)