Skip to content

perf: crop element transforms, composite dlimg in place, cache fonts and QR codes#31

Open
balloob wants to merge 1 commit into
OpenDisplay:mainfrom
balloob:perf/compositing-caching
Open

perf: crop element transforms, composite dlimg in place, cache fonts and QR codes#31
balloob wants to merge 1 commit into
OpenDisplay:mainfrom
balloob:perf/compositing-caching

Conversation

@balloob

@balloob balloob commented Jul 4, 2026

Copy link
Copy Markdown

Summary

Four performance fixes — one worst-case (per-element transforms) and three steady-state (dlimg, fonts, QR). No visual change: the transform change is verified pixel-identical to the previous full-canvas path (see tests).

Findings addressed

  • B1 — per-element transforms were ~1000× more expensive than needed. _render_transformed transformed and alpha-composited the entire canvas per transformed element (measured: 40 small rects on 800×480 → 0.3 ms plain vs 496 ms with rotation: 45 on each). New apply_transform_region crops the layer to the pivot-centered bounding radius (mirror and rotation are isometries about the pivot, so every non-transparent pixel stays within the farthest-bbox-corner distance), plus a resampling margin, transforms just that crop, and composites it back at the right offset. Cost is now proportional to the element, not the canvas.
  • B3 — dlimg composited the whole canvas three times per image. Replaced the full-canvas Image.new + paste + alpha_composite + paste-back (~35× overhead) with an in-place ctx.img.alpha_composite(source, (x, y)), clipping to the canvas first so a partially off-canvas image still doesn't raise.
  • B4 — fonts re-read from disk every render. generate_image builds a fresh FontManager per call, so its cache never survived — every render re-ran ImageFont.truetype (blocking disk I/O on the event loop). Added a module-level (path, size) truetype cache shared across instances, mirroring the existing MDI font cache. The per-instance cache (and clear_cache) are unchanged.
  • B5 — QR codes regenerated every render. Generation is ~5 ms (mostly the library's best-mask-pattern search over all 8 masks) and dashboards re-render the same QR (Wi-Fi creds, URL) every update. Added an LRU cache (maxsize=32) keyed by (data, boxsize, border, fill, back) holding the rendered RGBA image.

Correctness of B1

apply_transform_region is proven equivalent to apply_transform + full composite: mirror (reflection across an axis through the pivot) and rotation (about the pivot) both preserve distance to the pivot, so a crop of radius ⌈max-corner-distance⌉ + 2px (the +2 covers the BICUBIC kernel support), clipped to the canvas, contains all source and transformed pixels; transforming it about the translated pivot yields identical output. Verified numerically over a battery of cases (rotation, mirror+rotation, off-center pivot, near-edge clipping) — all pixel-identical.

Tests

Unit tests assert apply_transform_region matches the full path pixel-for-pixel across rotation / mirror+rotation / off-center pivot / near-edge clipping, that the font cache is shared across FontManager instances and keyed on size, and that identical QR inputs reuse a cached render. No new failures (pre-existing tests/visual/* byte-snapshot failures under Pillow 12 are unrelated).

🤖 Generated with Claude Code

…and QR codes

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVpLN4Rzdv7y3ud7QzutVJ
@balloob balloob requested a review from g4bri3lDev as a code owner July 4, 2026 07:41
@codecov

codecov Bot commented Jul 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.66667% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/odl_renderer/elements/media.py 94.44% 1 Missing ⚠️
src/odl_renderer/transforms.py 96.15% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant