mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
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>
245 lines
9.2 KiB
Python
245 lines
9.2 KiB
Python
"""Tests for adaptive image fitting (src/adaptive_images.py) and the
|
|
LayoutContext image cache."""
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
from src.adaptive_images import (
|
|
RESAMPLE_LANCZOS,
|
|
RESAMPLE_NEAREST,
|
|
ImageFitResult,
|
|
draw_fitted_image,
|
|
fit_image,
|
|
)
|
|
from src.adaptive_layout import LayoutContext, Region
|
|
from src.font_manager import FontManager
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def font_manager():
|
|
return FontManager({})
|
|
|
|
|
|
@pytest.fixture
|
|
def ctx(font_manager):
|
|
return LayoutContext(128, 32, font_manager)
|
|
|
|
|
|
def _solid(w, h, color=(255, 0, 0, 255)):
|
|
return Image.new("RGBA", (w, h), color)
|
|
|
|
|
|
def _padded_logo(ink_w=10, ink_h=10, pad=10):
|
|
"""Transparent canvas with a solid ink block in the middle — models a
|
|
logo shipped with generous transparent padding."""
|
|
img = Image.new("RGBA", (ink_w + 2 * pad, ink_h + 2 * pad), (0, 0, 0, 0))
|
|
img.paste(_solid(ink_w, ink_h), (pad, pad))
|
|
return img
|
|
|
|
|
|
class TestFitModes:
|
|
def test_contain_letterboxes_and_upscales(self):
|
|
fit = fit_image(_solid(10, 5), (40, 40))
|
|
assert (fit.width, fit.height) == (40, 20) # aspect preserved
|
|
assert fit.scale == 4.0
|
|
|
|
def test_contain_no_upscale(self):
|
|
fit = fit_image(_solid(10, 5), (40, 40), upscale=False)
|
|
assert (fit.width, fit.height) == (10, 5)
|
|
assert fit.scale == 1.0
|
|
|
|
def test_cover_fills_and_crops(self):
|
|
fit = fit_image(_solid(10, 20), (40, 40), mode="cover")
|
|
assert (fit.width, fit.height) == (40, 40)
|
|
|
|
def test_cover_top_anchor(self):
|
|
# top half red, bottom half blue; cover-crop a wide box with top anchor
|
|
img = Image.new("RGBA", (20, 40), (0, 0, 255, 255))
|
|
img.paste(_solid(20, 20, (255, 0, 0, 255)), (0, 0))
|
|
fit = fit_image(img, (20, 20), mode="cover", anchor="top")
|
|
assert fit.image.getpixel((10, 5))[:3] == (255, 0, 0) # kept the top
|
|
|
|
def test_fill_height_matches_box_height(self):
|
|
fit = fit_image(_solid(10, 10), (64, 32), mode="fill_height")
|
|
assert fit.height == 32 and fit.width == 32
|
|
|
|
def test_fill_height_capped_by_width(self):
|
|
# very wide source: height-fill would overflow the box width
|
|
fit = fit_image(_solid(100, 10), (40, 32), mode="fill_height")
|
|
assert fit.width <= 40
|
|
|
|
def test_stretch_exact(self):
|
|
fit = fit_image(_solid(3, 7), (25, 13), mode="stretch")
|
|
assert (fit.width, fit.height) == (25, 13)
|
|
|
|
def test_crop_to_ink(self):
|
|
fit = fit_image(_padded_logo(), (30, 30), crop_to_ink=True)
|
|
# 10x10 ink upscaled to fill 30x30 (padding would have kept it small)
|
|
assert (fit.width, fit.height) == (30, 30)
|
|
no_crop = fit_image(_padded_logo(), (30, 30), crop_to_ink=False)
|
|
assert no_crop.width == 30 # whole padded canvas scaled instead
|
|
|
|
def test_fully_transparent_source(self):
|
|
img = Image.new("RGBA", (10, 10), (0, 0, 0, 0))
|
|
fit = fit_image(img, (20, 20), crop_to_ink=True)
|
|
assert fit.is_empty
|
|
|
|
def test_degenerate_box(self):
|
|
assert fit_image(_solid(10, 10), (0, 20)).is_empty
|
|
assert fit_image(_solid(10, 10), Region(0, 0, 20, 0)).is_empty
|
|
|
|
def test_output_always_rgba(self):
|
|
rgb = Image.new("RGB", (10, 10), (1, 2, 3))
|
|
assert fit_image(rgb, (20, 20)).image.mode == "RGBA"
|
|
|
|
def test_nearest_keeps_hard_edges(self):
|
|
# 2x2 checker scaled 8x: NEAREST keeps pure colors, LANCZOS blends
|
|
img = Image.new("RGBA", (2, 2), (0, 0, 0, 255))
|
|
img.putpixel((0, 0), (255, 255, 255, 255))
|
|
near = fit_image(img, (16, 16), mode="stretch", resample=RESAMPLE_NEAREST)
|
|
colors = {near.image.getpixel((x, y))[:3] for x in range(16) for y in range(16)}
|
|
assert colors == {(255, 255, 255), (0, 0, 0)}
|
|
|
|
def test_unknown_mode_raises(self):
|
|
with pytest.raises(ValueError):
|
|
fit_image(_solid(4, 4), (8, 8), mode="tile")
|
|
|
|
|
|
class TestDrawFittedImage:
|
|
class _DM:
|
|
def __init__(self, w=64, h=32):
|
|
self.image = Image.new("RGB", (w, h), (0, 0, 0))
|
|
|
|
def test_pastes_aligned_in_region(self):
|
|
dm = self._DM()
|
|
box = Region(10, 4, 20, 20)
|
|
fit = fit_image(_solid(10, 10), box)
|
|
xy = draw_fitted_image(dm, fit, box)
|
|
assert xy == box.align_xy(fit.width, fit.height)
|
|
assert dm.image.getpixel((xy[0] + 1, xy[1] + 1)) == (255, 0, 0)
|
|
|
|
def test_offset_translates(self):
|
|
dm = self._DM()
|
|
box = Region(0, 0, 20, 20)
|
|
fit = fit_image(_solid(10, 10), box)
|
|
x, y = draw_fitted_image(dm, fit, box, align="left", valign="top",
|
|
offset=(3, 5))
|
|
assert (x, y) == (3, 5)
|
|
|
|
def test_empty_fit_noops(self):
|
|
dm = self._DM()
|
|
fit = fit_image(_solid(10, 10), (0, 0))
|
|
assert draw_fitted_image(dm, fit, Region(0, 0, 10, 10)) is None
|
|
|
|
|
|
class TestContextImageCache:
|
|
def test_size_keyed_hit_and_miss(self, ctx):
|
|
img = _solid(10, 10)
|
|
a = ctx.fit_image(img, (20, 20), cache_key="logo:A")
|
|
assert ctx.fit_image(img, (20, 20), cache_key="logo:A") is a
|
|
b = ctx.fit_image(img, (30, 30), cache_key="logo:A")
|
|
assert b is not a and b.width == 30 # different box size = new entry
|
|
|
|
def test_id_keyed_default(self, ctx):
|
|
img = _solid(10, 10)
|
|
a = ctx.fit_image(img, (20, 20))
|
|
assert ctx.fit_image(img, (20, 20)) is a
|
|
|
|
def test_id_keyed_entry_does_not_pin_source(self, ctx):
|
|
import gc
|
|
import weakref
|
|
|
|
img = _solid(10, 10)
|
|
ctx.fit_image(img, (20, 20))
|
|
assert len(ctx._image_cache) == 1
|
|
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):
|
|
img = _solid(10, 10)
|
|
ctx.fit_image(img, (20, 20), cache_key="logo:X")
|
|
key = next(k for k in ctx._image_cache if k[1] == "logo:X")
|
|
assert ctx._image_cache[key][1] is None
|
|
|
|
def test_lru_eviction(self, ctx):
|
|
for i in range(ctx._IMAGE_CACHE_MAX + 5):
|
|
ctx.fit_image(_solid(4, 4), (8, 8), cache_key=f"k{i}")
|
|
assert len(ctx._image_cache) == ctx._IMAGE_CACHE_MAX
|
|
assert not any(k[1] == "k0" for k in ctx._image_cache) # oldest evicted
|
|
|
|
def test_clear_cache_clears_images(self, ctx):
|
|
ctx.fit_image(_solid(4, 4), (8, 8), cache_key="k")
|
|
ctx.clear_cache()
|
|
assert len(ctx._image_cache) == 0
|
|
|
|
|
|
class TestBasePluginDrawImage:
|
|
def test_draw_image_end_to_end(self):
|
|
from src.plugin_system.base_plugin import BasePlugin
|
|
from src.plugin_system.testing.mocks import (
|
|
MockCacheManager, MockDisplayManager, MockPluginManager,
|
|
)
|
|
|
|
class _P(BasePlugin):
|
|
def update(self):
|
|
pass
|
|
|
|
def display(self, force_clear=False):
|
|
pass
|
|
|
|
plugin = _P("t", {}, MockDisplayManager(64, 32),
|
|
MockCacheManager(), MockPluginManager())
|
|
logo = _padded_logo()
|
|
box = plugin.layout.bounds.left_col(32)
|
|
ifit = plugin.draw_image(logo, box, mode="fill_height",
|
|
crop_to_ink=True, cache_key="logo:T")
|
|
assert ifit.height == 32
|
|
# pasted onto the mock's canvas
|
|
assert plugin.display_manager.image.getpixel((16, 16)) != (0, 0, 0)
|
|
|
|
|
|
class TestResultIndependence:
|
|
def test_same_size_fit_never_aliases_the_source(self):
|
|
"""LayoutContext caches ImageFitResults — an aliased image would let
|
|
later mutations of the source corrupt cached fits (or vice versa)."""
|
|
from PIL import ImageDraw
|
|
src = Image.new("RGBA", (20, 20), (255, 0, 0, 255))
|
|
fit = fit_image(src, (20, 20))
|
|
assert fit.image is not src
|
|
ImageDraw.Draw(src).rectangle([0, 0, 19, 19], fill=(0, 255, 0, 255))
|
|
assert fit.image.getpixel((5, 5)) == (255, 0, 0, 255)
|