From fbbb3dd89c7e26773b330b43f9ed1dd31a2898e2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 4 Jul 2026 03:40:41 -0400 Subject: [PATCH] perf: crop element transforms, composite dlimg in place, cache fonts and QR codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steady-state and worst-case render speedups. No visual change — the transform change is verified pixel-identical to the previous full-canvas path. - B1: transform only the region an element occupies instead of the whole canvas. apply_transform_region() crops to the pivot-centered bounding radius (mirror and rotation are isometries about the pivot, so all content stays within it), plus a resampling margin, then transforms the crop and composites it back at the right offset. 40 rotated tiny rects on 800x480 went from ~496 ms to proportional cost. - B3: composite dlimg in place with ctx.img.alpha_composite() instead of allocating and blending a full-canvas temporary three times per image (~35x overhead). Clips to the canvas first to preserve the previous off-canvas behavior. - B4: module-level (path, size) truetype cache shared across FontManager instances, so a fresh FontManager per generate_image() no longer re-reads fonts from disk (blocking I/O on the event loop) every render. - B5: LRU-cache rendered QR images keyed by (data, boxsize, border, fill, back). Generation is ~5 ms (mostly the mask-pattern search) and dashboards re-render the same QR every update, so the cache makes the steady-state cost ~0. Adds unit tests asserting apply_transform_region is pixel-identical to the full path (rotation, mirror+rotation, off-center pivot, near-edge clipping), that the font cache is shared across managers, and that QR renders are reused. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UVpLN4Rzdv7y3ud7QzutVJ --- src/odl_renderer/core.py | 13 +++-- src/odl_renderer/elements/media.py | 68 ++++++++++++++--------- src/odl_renderer/fonts.py | 31 +++++++++-- src/odl_renderer/transforms.py | 87 ++++++++++++++++++++++++++---- tests/integration/test_qrcodes.py | 12 +++++ tests/unit/test_fonts.py | 15 ++++++ tests/unit/test_transforms.py | 52 ++++++++++++++++++ 7 files changed, 235 insertions(+), 43 deletions(-) diff --git a/src/odl_renderer/core.py b/src/odl_renderer/core.py index 20bc71c..82660fe 100644 --- a/src/odl_renderer/core.py +++ b/src/odl_renderer/core.py @@ -13,7 +13,7 @@ from .elements import debug, icons, media, shapes, text, visualizations # noqa: F401 from .fonts import FontManager from .registry import get_all_handlers -from .transforms import apply_transform, has_transform +from .transforms import apply_transform_region, has_transform from .types import DataProvider, DrawingContext, ElementType _LOGGER = logging.getLogger(__name__) @@ -123,8 +123,9 @@ async def _render_transformed(ctx: DrawingContext, handler: Any, element: dict[s """Render an element onto its own layer, transform it, then composite back. The element is drawn onto a transparent full-canvas layer by temporarily - pointing the context at it, so the handler is used unchanged. The layer is - then rotated/mirrored and alpha-composited onto the base image. Mutating + pointing the context at it, so the handler is used unchanged. Only the region + the element occupies is then rotated/mirrored and alpha-composited onto the + base image (cost proportional to the element, not the whole canvas). Mutating ``ctx.img`` in place preserves any ``ctx.pos_y`` flow updates the handler makes (e.g. text auto-flow). """ @@ -136,14 +137,16 @@ async def _render_transformed(ctx: DrawingContext, handler: Any, element: dict[s finally: ctx.img = base - layer = apply_transform( + result = apply_transform_region( layer, rotation=element.get("rotation"), mirror=element.get("mirror"), pivot=element.get("pivot"), coords=ctx.coords, ) - base.alpha_composite(layer) + if result is not None: + transformed, offset = result + base.alpha_composite(transformed, offset) _FALSY_VISIBLE_STRINGS = frozenset({"false", ""}) diff --git a/src/odl_renderer/elements/media.py b/src/odl_renderer/elements/media.py index 2d9e1ac..27c87b7 100644 --- a/src/odl_renderer/elements/media.py +++ b/src/odl_renderer/elements/media.py @@ -1,5 +1,6 @@ from __future__ import annotations +import functools import logging from typing import Any @@ -15,6 +16,35 @@ _LOGGER = logging.getLogger(__name__) +@functools.lru_cache(maxsize=32) +def _render_qr_image( + data: str, + boxsize: int, + border: int, + fill: tuple[int, int, int], + back: tuple[int, int, int], +) -> Image.Image: + """Render (and cache) a QR code image. + + Generating a QR code costs ~5 ms, almost all in the library's best-mask-pattern + search, and e-paper dashboards re-render the same QR (Wi-Fi creds, a URL) on + every update. Caching by the inputs that determine the pixels turns the + steady-state cost to ~0. The returned image must not be mutated by callers + (it is composited read-only). + """ + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_H, + box_size=boxsize, + border=border, + ) + qr.add_data(data) + qr.make(fit=True) + qr_img = qr.make_image(fill_color=fill, back_color=back) + rgba: Image.Image = qr_img.convert("RGBA") + return rgba + + @element_handler(ElementType.QRCODE, requires=["x", "y", "data"]) async def draw_qrcode(ctx: DrawingContext, element: dict[str, Any]) -> None: """Draw QR code element. @@ -45,21 +75,8 @@ async def draw_qrcode(ctx: DrawingContext, element: dict[str, Any]) -> None: boxsize = element.get("boxsize", 2) try: - # Create QR code instance - qr = qrcode.QRCode( - version=1, - error_correction=qrcode.constants.ERROR_CORRECT_H, - box_size=boxsize, - border=border, - ) - - # Add data and generate QR code - qr.add_data(element["data"]) - qr.make(fit=True) - - # Create QR code image (convert RGBA to RGB for fill/back colors) - qr_img = qr.make_image(fill_color=color[:3], back_color=bgcolor[:3]) - qr_img = qr_img.convert("RGBA") + # Render (or reuse a cached) QR code image for these exact inputs. + qr_img = _render_qr_image(str(element["data"]), boxsize, border, color[:3], bgcolor[:3]) # Paste QR code onto main image ctx.img.paste(qr_img, (x, y), qr_img) @@ -123,16 +140,19 @@ async def draw_downloaded_image(ctx: DrawingContext, element: dict[str, Any]) -> if source_img.size != target_size: source_img = source_img.resize(target_size) - # Convert to RGBA + # Convert to RGBA and composite in place — avoids allocating and blending + # a full-canvas temporary image three times per dlimg. Clip to the canvas + # first so a partially off-canvas image doesn't raise (alpha_composite + # requires the source to fit within the destination). source_img = source_img.convert("RGBA") - - # Create temporary image for composition - temp_img = Image.new("RGBA", ctx.img.size) - temp_img.paste(source_img, (pos_x, pos_y), source_img) - - # Composite images - img_composite = Image.alpha_composite(ctx.img, temp_img) - ctx.img.paste(img_composite, (0, 0)) + crop_left = max(0, -pos_x) + crop_top = max(0, -pos_y) + crop_right = min(source_img.width, ctx.img.width - pos_x) + crop_bottom = min(source_img.height, ctx.img.height - pos_y) + if crop_right > crop_left and crop_bottom > crop_top: + if (crop_left, crop_top, crop_right, crop_bottom) != (0, 0, source_img.width, source_img.height): + source_img = source_img.crop((crop_left, crop_top, crop_right, crop_bottom)) + ctx.img.alpha_composite(source_img, (pos_x + crop_left, pos_y + crop_top)) # Update vertical position ctx.pos_y = pos_y + target_size[1] diff --git a/src/odl_renderer/fonts.py b/src/odl_renderer/fonts.py index ba99c36..83e82cf 100644 --- a/src/odl_renderer/fonts.py +++ b/src/odl_renderer/fonts.py @@ -12,6 +12,31 @@ # Assets directory (bundled fonts) _ASSETS_DIR = Path(__file__).parent / "assets" +# Module-level cache of loaded fonts keyed by (resolved_path, size), shared across +# every FontManager instance. core.generate_image() builds a fresh FontManager per +# call, so without this the truetype file would be re-read from disk on every render +# (blocking I/O on the event loop — several ms per render on an SD card). Mirrors the +# module-level MDI font cache in elements/icons.py. +_truetype_cache: dict[Tuple[str, int], ImageFont.FreeTypeFont] = {} + + +def _load_truetype(path: str, size: int) -> ImageFont.FreeTypeFont: + """Load a TrueType font, reusing the process-wide cache when possible. + + Args: + path: Absolute path to the font file. + size: Font size in pixels. + + Returns: + The loaded (and cached) font object. + """ + key = (path, size) + cached = _truetype_cache.get(key) + if cached is None: + cached = ImageFont.truetype(path, size) + _truetype_cache[key] = cached + return cached + class FontManager: """Manages font loading and caching. @@ -83,7 +108,7 @@ def _load_font(self, font_spec: str, size: int) -> ImageFont.FreeTypeFont: if not os.path.exists(font_spec): raise ValueError(f"Font file not found: {font_spec}") try: - return ImageFont.truetype(font_spec, size) + return _load_truetype(font_spec, size) except (OSError, IOError) as err: raise ValueError(f"Failed to load font from {font_spec}: {err}") from err @@ -95,7 +120,7 @@ def _load_font(self, font_spec: str, size: int) -> ImageFont.FreeTypeFont: candidate = os.path.join(directory, font_name) if os.path.isfile(candidate): try: - return ImageFont.truetype(candidate, size) + return _load_truetype(candidate, size) except (OSError, IOError) as err: _LOGGER.warning("Failed to load font from %s: %s", candidate, err) @@ -103,7 +128,7 @@ def _load_font(self, font_spec: str, size: int) -> ImageFont.FreeTypeFont: asset_path = _ASSETS_DIR / font_name if asset_path.exists(): try: - return ImageFont.truetype(str(asset_path), size) + return _load_truetype(str(asset_path), size) except (OSError, IOError) as err: raise ValueError(f"Failed to load built-in font '{font_name}': {err}") from err diff --git a/src/odl_renderer/transforms.py b/src/odl_renderer/transforms.py index ef76801..c8d875f 100644 --- a/src/odl_renderer/transforms.py +++ b/src/odl_renderer/transforms.py @@ -12,6 +12,7 @@ from __future__ import annotations +import math from typing import TYPE_CHECKING, Any from PIL import Image @@ -19,12 +20,43 @@ if TYPE_CHECKING: from .coordinates import CoordinateParser +# Extra transparent margin (px) kept around the element when cropping before a +# transform, so the BICUBIC rotation kernel (±2 px support) samples the same +# neighborhood it would on the full canvas — keeping the output pixel-identical. +_CROP_MARGIN = 2 + def has_transform(element: dict[str, Any]) -> bool: """Return True if an element requests a rotation or mirror transform.""" return bool(element.get("rotation")) or bool(element.get("mirror")) +def _transform_layer( + layer: Image.Image, + rotation: float | int | None, + mirror: str | None, + px: float, + py: float, +) -> Image.Image: + """Apply mirror then rotation to *layer* about the pivot ``(px, py)``. + + ``(px, py)`` are in *layer*-local coordinates. The output keeps the input size. + """ + if mirror: + layer = _apply_mirror(layer, mirror, px, py) + + if rotation: + # PIL rotates counter-clockwise; negate so positive = clockwise. + layer = layer.rotate( + -float(rotation), + resample=Image.Resampling.BICUBIC, + center=(px, py), + expand=False, + ) + + return layer + + def apply_transform( layer: Image.Image, *, @@ -54,20 +86,53 @@ def apply_transform( return layer px, py = _resolve_pivot(pivot, bbox, coords) + return _transform_layer(layer, rotation, mirror, px, py) - if mirror: - layer = _apply_mirror(layer, mirror, px, py) - if rotation: - # PIL rotates counter-clockwise; negate so positive = clockwise. - layer = layer.rotate( - -float(rotation), - resample=Image.Resampling.BICUBIC, - center=(px, py), - expand=False, - ) +def apply_transform_region( + layer: Image.Image, + *, + rotation: float | int | None = None, + mirror: str | None = None, + pivot: Any = None, + coords: CoordinateParser | None = None, +) -> tuple[Image.Image, tuple[int, int]] | None: + """Transform only the region of *layer* the element occupies. - return layer + Same result as :func:`apply_transform` followed by an ``alpha_composite`` of the + full layer, but the mirror/rotate run on a crop around the element instead of the + whole canvas — so the cost is proportional to the element size, not the canvas. + + Mirror and rotation are both isometries about the pivot, so every non-transparent + pixel stays within distance ``R`` (the farthest bbox corner from the pivot) of it. + A crop of that radius (plus a resampling margin), clipped to the canvas, therefore + contains all source *and* transformed content, and transforming it about the + translated pivot yields pixels identical to the full-canvas path. + + Returns: + ``(transformed_crop, (offset_x, offset_y))`` to composite the crop at, or + ``None`` if the layer is empty or the element lies entirely off-canvas. + """ + bbox = layer.getbbox() + if bbox is None: + return None + + px, py = _resolve_pivot(pivot, bbox, coords) + + left, top, right, bottom = bbox + radius = max(math.hypot(cx - px, cy - py) for cx in (left, right) for cy in (top, bottom)) + reach = math.ceil(radius) + _CROP_MARGIN + + ox = max(0, math.floor(px - reach)) + oy = max(0, math.floor(py - reach)) + ex = min(layer.width, math.ceil(px + reach)) + ey = min(layer.height, math.ceil(py + reach)) + if ex <= ox or ey <= oy: + return None + + crop = layer.crop((ox, oy, ex, ey)) + transformed = _transform_layer(crop, rotation, mirror, px - ox, py - oy) + return transformed, (ox, oy) def _apply_mirror(layer: Image.Image, mirror: str, px: float, py: float) -> Image.Image: diff --git a/tests/integration/test_qrcodes.py b/tests/integration/test_qrcodes.py index 9c9bd88..19569cc 100644 --- a/tests/integration/test_qrcodes.py +++ b/tests/integration/test_qrcodes.py @@ -47,3 +47,15 @@ async def test_empty_qrcode_data(self): image = await generate_image(width=200, height=200, elements=[E.qrcode("", x=100, y=100, size=100)]) assert image.size == (200, 200) + + +def test_qr_image_cache_reuses_render(): + """Identical QR inputs reuse a cached rendered image (B5).""" + from odl_renderer.elements.media import _render_qr_image + + a = _render_qr_image("https://example.org", 2, 1, (0, 0, 0), (255, 255, 255)) + b = _render_qr_image("https://example.org", 2, 1, (0, 0, 0), (255, 255, 255)) + assert a is b # cache hit — no regeneration + + c = _render_qr_image("https://different.org", 2, 1, (0, 0, 0), (255, 255, 255)) + assert c is not a # different data → distinct render diff --git a/tests/unit/test_fonts.py b/tests/unit/test_fonts.py index af7c0f5..19e1ce4 100644 --- a/tests/unit/test_fonts.py +++ b/tests/unit/test_fonts.py @@ -115,3 +115,18 @@ def test_no_font_dirs_behaves_as_before(self): manager = FontManager() font = manager.get_font("ppb", 16) assert isinstance(font, ImageFont.FreeTypeFont) + + +def test_module_font_cache_shared_across_managers(): + """The process-wide truetype cache is reused across FontManager instances (B4).""" + m1 = FontManager() + m2 = FontManager() + f1 = m1.get_font("ppb", 16) + f2 = m2.get_font("ppb", 16) + # A fresh FontManager per generate_image() must not re-read the font from disk. + assert f1 is f2 + + +def test_module_font_cache_keys_on_size(): + m = FontManager() + assert m.get_font("ppb", 16) is not m.get_font("ppb", 24) diff --git a/tests/unit/test_transforms.py b/tests/unit/test_transforms.py index a92533a..97cb6c0 100644 --- a/tests/unit/test_transforms.py +++ b/tests/unit/test_transforms.py @@ -7,6 +7,7 @@ _anchor_point, _resolve_pivot, apply_transform, + apply_transform_region, has_transform, ) @@ -116,3 +117,54 @@ def test_unknown_mirror_code_is_noop(self): layer = _layer_with_box() out = apply_transform(layer, mirror="x") assert ImageChops.difference(layer, out).getbbox() is None + + +def _composited(size, box, color, **transform_kwargs): + """Full-canvas transform then composite over white — the reference path.""" + base = Image.new("RGBA", size, (255, 255, 255, 255)) + layer = _layer_with_box(size=size, box=box, color=color) + base.alpha_composite(apply_transform(layer, **transform_kwargs)) + return base + + +def _composited_region(size, box, color, **transform_kwargs): + """Region-cropped transform then composite over white — the optimized path.""" + base = Image.new("RGBA", size, (255, 255, 255, 255)) + layer = _layer_with_box(size=size, box=box, color=color) + result = apply_transform_region(layer, **transform_kwargs) + if result is not None: + transformed, offset = result + base.alpha_composite(transformed, offset) + return base + + +class TestApplyTransformRegion: + """apply_transform_region must be pixel-identical to the full-canvas path (B1).""" + + def test_empty_layer_returns_none(self): + empty = Image.new("RGBA", (100, 100), (0, 0, 0, 0)) + assert apply_transform_region(empty, rotation=45) is None + + def test_rotation_matches_full(self): + ref = _composited((200, 120), (40, 30, 110, 80), (0, 0, 0, 255), rotation=37) + opt = _composited_region((200, 120), (40, 30, 110, 80), (0, 0, 0, 255), rotation=37) + assert ImageChops.difference(ref, opt).getbbox() is None + + def test_mirror_and_rotation_matches_full(self): + kw = dict(rotation=20, mirror="hv") + ref = _composited((200, 120), (30, 20, 90, 70), (255, 0, 0, 255), **kw) + opt = _composited_region((200, 120), (30, 20, 90, 70), (255, 0, 0, 255), **kw) + assert ImageChops.difference(ref, opt).getbbox() is None + + def test_off_center_pivot_matches_full(self): + coords = CoordinateParser(200, 120) + kw = dict(rotation=50, pivot=[150, 100], coords=coords) + ref = _composited((200, 120), (10, 10, 60, 40), (0, 0, 0, 255), **kw) + opt = _composited_region((200, 120), (10, 10, 60, 40), (0, 0, 0, 255), **kw) + assert ImageChops.difference(ref, opt).getbbox() is None + + def test_near_edge_content_matches_full(self): + # Rotation pushes part of the element off-canvas; both paths must clip the same. + ref = _composited((200, 120), (150, 5, 195, 45), (0, 0, 0, 255), rotation=60) + opt = _composited_region((200, 120), (150, 5, 195, 45), (0, 0, 0, 255), rotation=60) + assert ImageChops.difference(ref, opt).getbbox() is None