perf(layout): id-keyed fit_image cache entries no longer pin the source (#732)

LayoutContext.fit_image keyed images without a cache_key by id() and held
a strong reference to the source so the id could not be recycled. A
plugin following the documented one-liner -- draw_image(Image.open(path),
box) each frame -- never hit that cache and kept the last 64 sources
alive: ~64MB for 500x500 RGBA team logos (median size under
assets/sports), up to ~600MB for the largest.

The entry now holds a weak reference whose callback drops it when the
source is freed, and a hit re-checks that the referent is the same
image. Sources that cannot be weak-referenced are still pinned. Keyed
entries (the only kind any plugin on ledmatrix-plugins main uses today:
football-scoreboard's logo fit) are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-03 12:11:27 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 76ad71d1c5
commit 6bf7c3fa51
2 changed files with 75 additions and 17 deletions
+34 -12
View File
@@ -28,6 +28,7 @@ freetype.Face, so it drops straight into DisplayManager.draw_text().
""" """
import logging import logging
import weakref
from collections import OrderedDict from collections import OrderedDict
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
@@ -332,9 +333,10 @@ class LayoutContext:
# a plugin fitting changing text (a live game clock, a ticker) on a # a plugin fitting changing text (a live game clock, a ticker) on a
# 24/7 service would otherwise grow this without bound. # 24/7 service would otherwise grow this without bound.
self._fit_cache: "OrderedDict[Any, FitResult]" = OrderedDict() self._fit_cache: "OrderedDict[Any, FitResult]" = OrderedDict()
# LRU-bounded (images are big). Entries hold a strong reference to # LRU-bounded (images are big). An id()-keyed entry watches its
# the source image when keyed by id() so the id can't be recycled # source image through a weak reference and is dropped when the
# out from under the cache. # source is freed (see fit_image), so the id can't be recycled out
# from under the cache and the cache never keeps the source alive.
self._image_cache: "OrderedDict[Any, Tuple[Any, Any]]" = OrderedDict() self._image_cache: "OrderedDict[Any, Tuple[Any, Any]]" = OrderedDict()
_IMAGE_CACHE_MAX = 64 _IMAGE_CACHE_MAX = 64
@@ -536,8 +538,15 @@ class LayoutContext:
cached per (image, box size, options) for this panel size. cached per (image, box size, options) for this panel size.
Prefer a stable ``cache_key`` (e.g. "logo:KC") for images that get Prefer a stable ``cache_key`` (e.g. "logo:KC") for images that get
reloaded — the default id()-based key is safe (the entry pins the reloaded — the default id()-based key misses across reloads of the
source image) but misses across reloads of the same content. same content.
An id()-keyed entry lives only as long as its source image: it holds
a weak reference and is dropped when the source is freed. It used to
pin the source instead, so a plugin passing a freshly loaded image
each frame (``draw_image(Image.open(path), box)``, the documented
one-liner) never hit and kept the last 64 sources alive — ~64MB for
500x500 RGBA team logos, the median size under assets/sports.
""" """
from src.adaptive_images import fit_image as _fit_image from src.adaptive_images import fit_image as _fit_image
@@ -547,18 +556,31 @@ class LayoutContext:
key = ("image", identity, img.size, box_w, box_h, mode, key = ("image", identity, img.size, box_w, box_h, mode,
crop_to_ink, anchor, resample_name, upscale) crop_to_ink, anchor, resample_name, upscale)
cached = self._image_cache.get(key) cache = self._image_cache
if cached is not None: cached = cache.get(key)
self._image_cache.move_to_end(key) # An id()-keyed hit must still be this very image; the callback below
# normally removes a dead source's entry before its id can recur.
if cached is not None and (cache_key is not None or cached[1]() is img):
cache.move_to_end(key)
return cached[0] return cached[0]
result = _fit_image(img, (box_w, box_h), mode=mode, result = _fit_image(img, (box_w, box_h), mode=mode,
crop_to_ink=crop_to_ink, anchor=anchor, crop_to_ink=crop_to_ink, anchor=anchor,
resample=resample, upscale=upscale) resample=resample, upscale=upscale)
# Pin the source only for id()-keyed entries (see docstring). source = None
self._image_cache[key] = (result, img if cache_key is None else None) if cache_key is None:
while len(self._image_cache) > self._IMAGE_CACHE_MAX: def _forget(ref: Any, key: Any = key) -> None:
self._image_cache.popitem(last=False) entry = cache.get(key)
if entry is not None and entry[1] is ref:
cache.pop(key, None)
try:
source = weakref.ref(img, _forget)
except TypeError:
# Not weak-referenceable: pin it, as before.
source = lambda img=img: img # noqa: E731
cache[key] = (result, source)
while len(cache) > self._IMAGE_CACHE_MAX:
cache.popitem(last=False)
return result return result
# ---- text utilities ------------------------------------------------ # ---- text utilities ------------------------------------------------
+41 -5
View File
@@ -145,13 +145,49 @@ class TestContextImageCache:
a = ctx.fit_image(img, (20, 20)) a = ctx.fit_image(img, (20, 20))
assert ctx.fit_image(img, (20, 20)) is a assert ctx.fit_image(img, (20, 20)) is a
def test_id_safety_pins_source(self, ctx): def test_id_keyed_entry_does_not_pin_source(self, ctx):
# id()-keyed entries must pin the source image so a recycled id import gc
# can't alias a dead image's cache entry. import weakref
img = _solid(10, 10) img = _solid(10, 10)
ctx.fit_image(img, (20, 20)) ctx.fit_image(img, (20, 20))
pinned = [entry[1] for entry in ctx._image_cache.values()] assert len(ctx._image_cache) == 1
assert img in pinned watch = weakref.ref(img)
del img
gc.collect()
assert watch() is None # the cache did not keep it alive
assert len(ctx._image_cache) == 0 # and its entry went with it
def test_fresh_image_each_frame_holds_nothing(self, ctx):
# draw_image(Image.open(path), box) every frame: the old pinning
# filled all 64 slots with dead-weight sources.
for _ in range(ctx._IMAGE_CACHE_MAX * 2):
ctx.fit_image(_solid(50, 50), (20, 20))
assert len(ctx._image_cache) == 0
def test_recycled_id_does_not_alias(self, ctx):
# A same-size image at a recycled address must not get the dead
# image's fit, even if the entry somehow outlived its source.
red = _solid(10, 10, (255, 0, 0, 255))
first = ctx.fit_image(red, (20, 20))
key = next(iter(ctx._image_cache))
entry = ctx._image_cache[key]
ctx._image_cache[key] = (entry[0], lambda: None) # source "gone"
again = ctx.fit_image(red, (20, 20))
assert again is not first # refit, not a stale hit
assert ctx.fit_image(red, (20, 20)) is again
def test_unweakrefable_source_is_pinned(self, ctx, monkeypatch):
import src.adaptive_layout as layout_mod
def no_weakref(*_args, **_kwargs):
raise TypeError("cannot create weak reference")
monkeypatch.setattr(layout_mod.weakref, "ref", no_weakref)
img = _solid(10, 10)
a = ctx.fit_image(img, (20, 20))
assert ctx.fit_image(img, (20, 20)) is a
assert next(iter(ctx._image_cache.values()))[1]() is img
def test_cache_key_entries_do_not_pin(self, ctx): def test_cache_key_entries_do_not_pin(self, ctx):
img = _solid(10, 10) img = _solid(10, 10)