Source code for visualkeras.utils

from typing import Any, Dict, Mapping, Union, Sequence, List, Tuple, Optional
from PIL import ImageColor, ImageDraw, Image, ImageFont
import aggdraw
import numpy as np
import math

[docs] def resolve_style( target: Any, name: str, styles: Mapping[Union[str, type], Dict[str, Any]], defaults: Dict[str, Any] ) -> Dict[str, Any]: """Resolve the effective style for a render target. Styles are applied in two passes. Class-based rules are merged following the target's method-resolution order, then name-based overrides are applied on top of that result. Parameters ---------- target : Any Layer or render object whose style should be resolved. name : str Concrete layer or node name used for name-based overrides. styles : mapping Style mapping keyed by layer class or layer name. defaults : dict Default style values to start from. Returns ------- dict Fully resolved style mapping for ``target``. """ final_style = defaults.copy() for cls in type(target).__mro__: if cls in styles: final_style.update(styles[cls]) if name in styles: final_style.update(styles[name]) return final_style
[docs] class RectShape: """Base rectangle-like drawing primitive used by multiple renderers. This class stores shared geometry and color state for simple shapes drawn by the graph, layered, and utility rendering code. It is primarily an internal helper, but it appears in the API reference because the utility page exposes the public drawing primitives directly. """ x1: int x2: int y1: int y2: int _fill: Any _outline: Any style: dict = None
[docs] def __init__(self): self.style = {}
@property def fill(self): return self._fill @property def outline(self): return self._outline @fill.setter def fill(self, v): self._fill = get_rgba_tuple(v) @outline.setter def outline(self, v): self._outline = get_rgba_tuple(v) def _get_pen_brush(self): """Return aggdraw pen and brush objects for the current style.""" pen = aggdraw.Pen(self._outline) brush = aggdraw.Brush(self._fill) return pen, brush
[docs] class Box(RectShape): """Rectangular layer primitive with optional 3D depth or rotation. ``Box`` is the main volumetric drawing primitive used by layered-style renderers. It can render flat rectangles, classic offset-depth boxes, or rotated 3D boxes while also exposing projected face coordinates for image and logo placement. """ de: int shade: int rotation: Optional[float] = None # Rotation around Y axis in degrees # Cache for projected faces to ensure logos/images align perfectly _projected_faces: Dict[int, List[Tuple[float, float]]] = None
[docs] def get_face_quad(self, face_index: int) -> List[Tuple[float, float]]: """Return the projected quadrilateral for a visible box face. Parameters ---------- face_index : int Face identifier where ``0`` is front, ``1`` is back, ``2`` is right, ``3`` is left, ``4`` is top, and ``5`` is bottom. Returns ------- list of tuple Four projected ``(x, y)`` coordinates for the requested face, or an empty list if the face projection is not available. """ if self._projected_faces and face_index in self._projected_faces: return self._projected_faces[face_index] return []
[docs] def draw(self, draw: ImageDraw, draw_reversed: bool = False): pen, brush = self._get_pen_brush() # Dimensions w = self.x2 - self.x1 h = self.y2 - self.y1 # Use 'de' as the Z-depth. # In the layout, de was a shift offset. We treat it as physical depth here. d = getattr(self, "de", 0) if d == 0: # Fallback for flat nodes (2D) draw.rectangle([self.x1, self.y1, self.x2, self.y2], pen, brush) self._projected_faces = {0: [(self.x1, self.y1), (self.x2, self.y1), (self.x2, self.y2), (self.x1, self.y2)]} return if self.rotation is None: # Legacy Drawing Logic (Isometric-ish offset) if hasattr(self, 'de') and self.de > 0: brush_s1 = aggdraw.Brush(fade_color(self.fill, self.shade)) brush_s2 = aggdraw.Brush(fade_color(self.fill, 2 * self.shade)) if draw_reversed: draw.line([self.x2 - self.de, self.y1 - self.de, self.x2 - self.de, self.y2 - self.de], pen) draw.line([self.x2 - self.de, self.y2 - self.de, self.x2, self.y2], pen) draw.line([self.x1 - self.de, self.y2 - self.de, self.x2 - self.de, self.y2 - self.de], pen) draw.polygon([self.x1, self.y1, self.x1 - self.de, self.y1 - self.de, self.x2 - self.de, self.y1 - self.de, self.x2, self.y1 ], pen, brush_s1) draw.polygon([self.x1 - self.de, self.y1 - self.de, self.x1, self.y1, self.x1, self.y2, self.x1 - self.de, self.y2 - self.de ], pen, brush_s2) # Populate projected faces for legacy mode self._projected_faces = { 0: [(self.x1, self.y1), (self.x2, self.y1), (self.x2, self.y2), (self.x1, self.y2)], # Front 4: [(self.x1 - self.de, self.y1 - self.de), (self.x2 - self.de, self.y1 - self.de), (self.x2, self.y1), (self.x1, self.y1)], # Top 2: [(self.x1 - self.de, self.y1 - self.de), (self.x1, self.y1), (self.x1, self.y2), (self.x1 - self.de, self.y2 - self.de)] # Side (Left) } else: draw.line([self.x1 + self.de, self.y1 - self.de, self.x1 + self.de, self.y2 - self.de], pen) draw.line([self.x1 + self.de, self.y2 - self.de, self.x1, self.y2], pen) draw.line([self.x1 + self.de, self.y2 - self.de, self.x2 + self.de, self.y2 - self.de], pen) draw.polygon([self.x1, self.y1, self.x1 + self.de, self.y1 - self.de, self.x2 + self.de, self.y1 - self.de, self.x2, self.y1 ], pen, brush_s1) draw.polygon([self.x2 + self.de, self.y1 - self.de, self.x2, self.y1, self.x2, self.y2, self.x2 + self.de, self.y2 - self.de ], pen, brush_s2) # Populate projected faces for legacy mode self._projected_faces = { 0: [(self.x1, self.y1), (self.x2, self.y1), (self.x2, self.y2), (self.x1, self.y2)], # Front 4: [(self.x1 + self.de, self.y1 - self.de), (self.x2 + self.de, self.y1 - self.de), (self.x2, self.y1), (self.x1, self.y1)], # Top 2: [(self.x2, self.y1), (self.x2 + self.de, self.y1 - self.de), (self.x2 + self.de, self.y2 - self.de), (self.x2, self.y2)] # Side (Right) } draw.rectangle([self.x1, self.y1, self.x2, self.y2], pen, brush) return # Center of the box in 2D layout space (reference for pivot) cx = self.x1 + w / 2 cy = self.y1 + h / 2 # 3D vertices relative to center (x, y, z) # Y is Down, X is Right, Z is Out (towards viewer) # Vertices: 0-3 Front (z=-d/2), 4-7 Back (z=d/2) # Order: TL, TR, BR, BL dx, dy, dz = w/2, h/2, d/2 vertices = [ (-dx, -dy, -dz), (dx, -dy, -dz), (dx, dy, -dz), (-dx, dy, -dz), # Front (-dx, -dy, dz), (dx, -dy, dz), (dx, dy, dz), (-dx, dy, dz) # Back ] # Rotation Angles (Radians) theta_y = math.radians(self.rotation) # Fixed Pitch (Rotation around X) to maintain 2.5D visual style (seeing top/side) # If rotation is 0, we want to match the classic 'visualkeras' look which is roughly isometric/oblique. # Classic look: Top and Right visible. phi_x = math.radians(-25) # Tilt up to see top # Transform and Project projected = [] for vx, vy, vz in vertices: # 1. Rotate Y (Yaw) x1 = vx * math.cos(theta_y) + vz * math.sin(theta_y) y1 = vy z1 = -vx * math.sin(theta_y) + vz * math.cos(theta_y) # 2. Rotate X (Pitch) x2 = x1 y2 = y1 * math.cos(phi_x) - z1 * math.sin(phi_x) z2 = y1 * math.sin(phi_x) + z1 * math.cos(phi_x) # 3. Project (Orthographic + Center Offset) # Invert Y logic for screen coords if needed, but standard math works if we assume Y down. px = x2 + cx py = y2 + cy projected.append((px, py, z2)) # Define Faces (indices) # Standard winding (CCW or CW). Let's define CCW looking from outside. faces = [ (0, [0, 1, 2, 3], "front"), # Front (1, [5, 4, 7, 6], "back"), # Back (2, [1, 5, 6, 2], "right"), # Right (3, [4, 0, 3, 7], "left"), # Left (4, [4, 5, 1, 0], "top"), # Top (5, [3, 2, 6, 7], "bottom") # Bottom ] # Colors base_color = self.fill shade1 = fade_color(base_color, self.shade) # Top/Bottom shade2 = fade_color(base_color, self.shade * 2) # Left/Right shade3 = fade_color(base_color, self.shade * 3) # Back/Inside face_colors = { "front": base_color, "back": shade3, "right": shade2, "left": shade2, "top": shade1, "bottom": shade1 } # Calculate Face Depth (Centroid Z) for sorting face_depths = [] self._projected_faces = {} for f_idx, indices, name in faces: # Get coords pts_3d = [projected[i] for i in indices] # Avg Z avg_z = sum(p[2] for p in pts_3d) / 4.0 # Store 2D Quad quad_2d = [(p[0], p[1]) for p in pts_3d] self._projected_faces[f_idx] = quad_2d face_depths.append((avg_z, f_idx, indices, name, quad_2d)) # Sort faces: furthest Z first (Painter's Algorithm) # Z increases away from camera? # In our rotation math: # Back (d/2) -> Rotated. # Usually positive Z is towards viewer in right-hand, but here standard math # x_screen, y_screen. z_depth. # We draw from lowest Z to highest Z? # Let's check: Front was -d/2. Back was +d/2. # If we rotate 180, Front becomes +d/2. # We want to draw the FURTHEST face first. # Furthest is largest positive Z (if Z points into screen) or smallest Z (if Z points out)? # Our math: vertices start with Front = -d/2. # If Z points to viewer, -d/2 is further than +d/2? No. # Let's assume standard: +Z is out of screen. -Z is into screen. # Actually, let's just test sort. Usually standard sort (ascending) works if Z is depth. face_depths.sort(key=lambda x: x[0]) # Draw smallest Z first (furthest if Z is distance) # Draw for _, _, _, name, quad in face_depths: # Prepare color f_color = face_colors[name] f_pen = pen # Always black outline f_brush = aggdraw.Brush(f_color) # Flatten quad for aggdraw coords = [] for x, y in quad: coords.extend([x, y]) draw.polygon(coords, f_pen, f_brush)
[docs] class Circle(RectShape): """Circular node primitive used by graph-style renderings. This shape is typically used for graph nodes that should read as compact points rather than volumetric boxes. """
[docs] def draw(self, draw: ImageDraw): pen, brush = self._get_pen_brush() draw.ellipse([self.x1, self.y1, self.x2, self.y2], pen, brush)
[docs] class Ellipses(RectShape): """Ellipsis marker used when neuron counts are truncated visually. Graph view uses this helper when a layer is too large to render every neuron marker individually. """
[docs] def draw(self, draw: ImageDraw): pen, brush = self._get_pen_brush() w = self.x2 - self.x1 d = int(w / 7) draw.ellipse([self.x1 + (w - d) / 2, self.y1 + 1 * d, self.x1 + (w + d) / 2, self.y1 + 2 * d], pen, brush) draw.ellipse([self.x1 + (w - d) / 2, self.y1 + 3 * d, self.x1 + (w + d) / 2, self.y1 + 4 * d], pen, brush) draw.ellipse([self.x1 + (w - d) / 2, self.y1 + 5 * d, self.x1 + (w + d) / 2, self.y1 + 6 * d], pen, brush)
[docs] class ColorWheel: """Assign repeatable colors to layer classes from a finite palette. This helper is useful when the caller wants deterministic but lightweight default colors without maintaining a full explicit color map. """
[docs] def __init__(self, colors: list = None): self._cache = dict() self.colors = colors if colors is not None else ["#ffd166", "#ef476f", "#06d6a0", "#118ab2", "#073b4c", "#ffadad", "#caffbf", "#9bf6ff", "#a0c4ff", "#bdb2ff"]
[docs] def get_color(self, class_type: type): """Return a stable palette color for ``class_type``.""" if class_type not in self._cache.keys(): index = len(self._cache.keys()) % len(self.colors) self._cache[class_type] = self.colors[index] return self._cache.get(class_type)
[docs] def fade_color(color: tuple, fade_amount: int) -> tuple: """Return ``color`` darkened by ``fade_amount`` while preserving alpha.""" r = max(0, color[0] - fade_amount) g = max(0, color[1] - fade_amount) b = max(0, color[2] - fade_amount) return r, g, b, color[3]
[docs] def get_rgba_tuple(color: Any) -> tuple: """Normalize a color value into an ``(R, G, B, A)`` tuple. Parameters ---------- color : Any Pillow-compatible color value. This may be a tuple, an integer-packed RGBA value, or a named or hex color string. Returns ------- tuple Four-item RGBA tuple. """ if isinstance(color, tuple): rgba = color elif isinstance(color, int): rgba = (color >> 16 & 0xff, color >> 8 & 0xff, color & 0xff, color >> 24 & 0xff) else: rgba = ImageColor.getrgb(color) if len(rgba) == 3: rgba = (rgba[0], rgba[1], rgba[2], 255) return rgba
[docs] def get_keys_by_value(d, v): """Yield all keys in ``d`` whose value equals ``v``.""" for key in d.keys(): # reverse search the dict for the value if d[key] == v: yield key
[docs] def self_multiply(tensor_tuple: tuple): """Multiply the numeric entries of a shape-like tuple together. ``None`` values are ignored so partially specified tensor shapes can still be reduced into a usable best-effort product. Parameters ---------- tensor_tuple : tuple Shape-like tuple whose entries should be multiplied. Returns ------- int Product of the non-``None`` entries, or ``0`` when no numeric entries are available. """ tensor_list = list(tensor_tuple) if None in tensor_list: tensor_list.remove(None) if len(tensor_list) == 0: return 0 s = tensor_list[0] for i in range(1, len(tensor_list)): s *= tensor_list[i] return s
[docs] def vertical_image_concat(im1: Image, im2: Image, background_fill: Any = 'white'): """Stack two images vertically on a shared background. Parameters ---------- im1 : PIL.Image.Image Image placed on top. im2 : PIL.Image.Image Image placed below ``im1``. background_fill : Any, default='white' Background color used for the combined canvas. Returns ------- PIL.Image.Image Vertically concatenated image. """ dst = Image.new('RGBA', (max(im1.width, im2.width), im1.height + im2.height), background_fill) dst.paste(im1, (0, 0)) dst.paste(im2, (0, im1.height)) return dst
[docs] def linear_layout(images: list, max_width: int = -1, max_height: int = -1, horizontal: bool = True, padding: int = 0, spacing: int = 0, background_fill: Any = 'white'): """Arrange images in a wrapped horizontal or vertical strip. Parameters ---------- images : list Sequence of ``PIL.Image`` objects to arrange. max_width : int, default=-1 Maximum layout width. This is only enforced in horizontal mode. max_height : int, default=-1 Maximum layout height. This is only enforced in vertical mode. horizontal : bool, default=True If ``True``, lay out images left to right and wrap into new rows when necessary. If ``False``, lay out images top to bottom and wrap into new columns. padding : int, default=0 Outer padding around the full layout. spacing : int, default=0 Gap between adjacent images. background_fill : Any, default='white' Background color for the layout canvas. Returns ------- PIL.Image.Image Composite image containing the arranged inputs. """ coords = list() width = 0 height = 0 x, y = padding, padding for img in images: if horizontal: if max_width != -1 and x + img.width > max_width: # make a new row x = padding y = height - padding + spacing coords.append((x, y)) width = max(x + img.width + padding, width) height = max(y + img.height + padding, height) x += img.width + spacing else: if max_height != -1 and y + img.height > max_height: # make a new column x = width - padding + spacing y = padding coords.append((x, y)) width = max(x + img.width + padding, width) height = max(y + img.height + padding, height) y += img.height + spacing layout = Image.new('RGBA', (width, height), background_fill) for img, coord in zip(images, coords): layout.paste(img, coord) return layout
[docs] class Ribbon: """Connector primitive that draws a shaded ribbon between two points. Ribbons are mainly used where a connector should read as a filled geometric transition instead of a simple line. """
[docs] def __init__(self, x1, y1, x2, y2, de, width, color, shade_step): self.x1, self.y1 = x1, y1 self.x2, self.y2 = x2, y2 self.de = de self.width = width self.fill = get_rgba_tuple(color) self.shade = shade_step # Calculate depth sort key for layering ribbons correctly self.z_sort = (x1 + x2) / 2 + (y1 + y2) / 2
[docs] def draw(self, draw: aggdraw.Draw): """Draw the ribbon onto an aggdraw canvas.""" pen = aggdraw.Pen("black", 0.5) # Thin outline for crispness # Colors top_color = fade_color(self.fill, self.shade) side_color = fade_color(self.fill, 2 * self.shade) front_color = self.fill brush_top = aggdraw.Brush(top_color) brush_side = aggdraw.Brush(side_color) brush_front = aggdraw.Brush(front_color) # A horizontal ribbon is a rectangle of height 'width' # A vertical ribbon is a rectangle of width 'width' is_horizontal = abs(self.y1 - self.y2) < abs(self.x1 - self.x2) if is_horizontal: # Draw Horizontal Ribbon (Left -> Right) lx, rx = min(self.x1, self.x2), max(self.x1, self.x2) y = self.y1 w = self.width # 1. Back Face (Top) # 2. Top Face (Depth) # Polygon: (lx, y), (rx, y), (rx+de, y-de), (lx+de, y-de) draw.polygon([ lx, y - w/2, rx, y - w/2, rx + self.de, y - w/2 - self.de, lx + self.de, y - w/2 - self.de ], pen, brush_top) # 3. Front Face (The main line) draw.rectangle([lx, y - w/2, rx, y + w/2], pen, brush_front) else: # Draw Vertical Ribbon (Top -> Bottom) ty, by = min(self.y1, self.y2), max(self.y1, self.y2) x = self.x1 w = self.width # 1. Side Face # Polygon: (x+w/2, ty), (x+w/2, by), (x+w/2+de, by-de), (x+w/2+de, ty-de) draw.polygon([ x + w/2, ty, x + w/2, by, x + w/2 + self.de, by - self.de, x + w/2 + self.de, ty - self.de ], pen, brush_side) # 2. Top Face draw.polygon([ x - w/2, ty, x + w/2, ty, x + w/2 + self.de, ty - self.de, x - w/2 + self.de, ty - self.de ], pen, brush_top) # 3. Front Face draw.rectangle([x - w/2, ty, x + w/2, by], pen, brush_front)
[docs] def resize_image_to_fit(image: Image.Image, target_width: int, target_height: int, fit_mode: str) -> Image.Image: """Resize an image to fit a target box using a named fit strategy. Parameters ---------- image : PIL.Image.Image Source image to resize. target_width : int Target width in pixels. target_height : int Target height in pixels. fit_mode : {'fill', 'contain', 'cover', 'match_aspect'} Strategy used to fit the image into the target box. Returns ------- PIL.Image.Image Resized image prepared for the requested fit mode. """ if target_width <= 0 or target_height <= 0: return image img_w, img_h = image.size target_ratio = target_width / target_height img_ratio = img_w / img_h new_w, new_h = target_width, target_height if fit_mode == "cover": if img_ratio > target_ratio: # Image is wider -> match height, crop width new_h = target_height new_w = int(new_h * img_ratio) else: # Image is taller -> match width, crop height new_w = target_width new_h = int(new_w / img_ratio) elif fit_mode == "contain": if img_ratio > target_ratio: # Image is wider -> match width, letterbox height new_w = target_width new_h = int(new_w / img_ratio) else: # Image is taller -> match height, letterbox width new_h = target_height new_w = int(new_h * img_ratio) elif fit_mode == "match_aspect": # In this mode, the container should have been resized already. # We just fill. pass else: # "fill" pass resized = image.resize((new_w, new_h), Image.LANCZOS) # Crop or Center if needed if fit_mode == "cover": left = (new_w - target_width) // 2 top = (new_h - target_height) // 2 resized = resized.crop((left, top, left + target_width, top + target_height)) elif fit_mode == "contain": # Create transparent background bg = Image.new("RGBA", (target_width, target_height), (0, 0, 0, 0)) left = (target_width - new_w) // 2 top = (target_height - new_h) // 2 bg.paste(resized, (left, top)) resized = bg return resized
def _calculate_affine_coeffs(quad, src_size): sw, sh = src_size p0, p1, p2, p3 = quad # Mapping: # p0 -> (0, 0) # p1 -> (sw, 0) # p3 -> (0, sh) # x_src = a*x_dst + b*y_dst + c # y_src = d*x_dst + e*y_dst + f # Matrix form for X coeffs (a, b, c): # [ x0 y0 1 ] [ a ] [ 0 ] # [ x1 y1 1 ] [ b ] = [ sw] # [ x3 y3 1 ] [ c ] [ 0 ] A = np.array([ [p0[0], p0[1], 1], [p1[0], p1[1], 1], [p3[0], p3[1], 1] ]) B_x = np.array([0, sw, 0]) B_y = np.array([0, 0, sh]) try: sol_x = np.linalg.solve(A, B_x) sol_y = np.linalg.solve(A, B_y) except np.linalg.LinAlgError: return (1, 0, 0, 0, 1, 0) return tuple(sol_x) + tuple(sol_y)
[docs] def apply_affine_transform(target_img: Image.Image, source_img: Image.Image, quad: list, fit_mode: str): """Project ``source_img`` onto a quadrilateral within ``target_img``. Parameters ---------- target_img : PIL.Image.Image Destination image that receives the transformed source. source_img : PIL.Image.Image Source image to project. quad : list Four ``(x, y)`` points defining the destination quadrilateral. fit_mode : str Fit mode passed through to :func:`resize_image_to_fit` before the projection is applied. """ # Calculate bounding box of the quad to determine target size xs = [p[0] for p in quad] ys = [p[1] for p in quad] min_x, max_x = min(xs), max(xs) min_y, max_y = min(ys), max(ys) w = int(max_x - min_x) h = int(max_y - min_y) if w <= 0 or h <= 0: return # Let's use the side lengths. side_w = np.hypot(quad[1][0]-quad[0][0], quad[1][1]-quad[0][1]) side_h = np.hypot(quad[3][0]-quad[0][0], quad[3][1]-quad[0][1]) # Resize source image to match the approximate dimensions of the target face resized_source = resize_image_to_fit(source_img, int(side_w), int(side_h), fit_mode) sw, sh = resized_source.size coeffs = _calculate_affine_coeffs(quad, (sw, sh)) # Transform # x_global = x_local + min_x # x_source = a*(x_local + min_x) + ... # = a*x_local + b*y_local + (a*min_x + b*min_y + c) new_c = coeffs[0]*min_x + coeffs[1]*min_y + coeffs[2] new_f = coeffs[3]*min_x + coeffs[4]*min_y + coeffs[5] new_coeffs = (coeffs[0], coeffs[1], new_c, coeffs[3], coeffs[4], new_f) transformed = resized_source.transform((w, h), Image.AFFINE, new_coeffs, resample=Image.BILINEAR) # Paste onto target_img at (min_x, min_y) target_img.paste(transformed, (int(min_x), int(min_y)), transformed)
[docs] def draw_logos_legend(img: Image.Image, logo_groups: Sequence[Dict[str, Any]], legend_config: Union[bool, Dict[str, Any]], background_fill: Any, font: ImageFont.ImageFont, font_color: Any) -> Image.Image: """Append a legend describing configured logo groups. Parameters ---------- img : PIL.Image.Image Base image that may receive a legend below it. logo_groups : sequence of dict Logo-group definitions used by the renderer. legend_config : bool or dict Legend toggle or configuration mapping. background_fill : Any Background color used for the legend canvas. font : PIL.ImageFont.ImageFont Font used for legend labels. font_color : Any Text color used for legend labels. Returns ------- PIL.Image.Image Original image when no legend is requested, otherwise the image with an appended legend. """ if not legend_config: return img if isinstance(legend_config, bool): legend_config = {} padding = legend_config.get("padding", 10) spacing = legend_config.get("spacing", 10) patches = [] # Determine text height for sizing if hasattr(font, 'getsize'): text_height = font.getsize("Ag")[1] else: text_height = font.getbbox("Ag")[3] # We want to show: [Logo Image] Group Name for group in logo_groups: name = group.get("name") path = group.get("file") if not name or not path: continue try: logo_img = Image.open(path) except: continue # Resize logo for legend # Let's make it square-ish, matching text height * 2? icon_size = int(text_height * 2) logo_img = resize_image_to_fit(logo_img, icon_size, icon_size, "contain") # Measure text if hasattr(font, 'getsize'): text_w, text_h = font.getsize(name) else: bbox = font.getbbox(name) text_w = bbox[2] text_h = bbox[3] patch_w = icon_size + spacing + text_w patch_h = max(icon_size, text_h) patch = Image.new("RGBA", (patch_w, patch_h), background_fill) draw = ImageDraw.Draw(patch) # Paste logo # Center vertically logo_y = (patch_h - icon_size) // 2 patch.paste(logo_img, (0, logo_y), logo_img) # Draw text text_x = icon_size + spacing text_y = (patch_h - text_h) // 2 draw.text((text_x, text_y), name, font=font, fill=font_color) patches.append(patch) if not patches: return img legend_image = linear_layout(patches, max_width=img.width, max_height=img.height, padding=padding, spacing=spacing, background_fill=background_fill, horizontal=True) return vertical_image_concat(img, legend_image, background_fill=background_fill)