fix(artwork): letterbox images and back alpha art with black
This commit is contained in:
1 parent
50a9a735f7
commit
e14a349c2d
2 files changed
+85
-12
No files matched your search
+22
-6
@@ -230,22 +230,38 @@ async def fetch(client: httpx.AsyncClient, url: str) -> bytes | None:
|
|||||||
|
|
||||||
|
|
||||||
def fit_image(data: bytes, width: int, height: int):
|
def fit_image(data: bytes, width: int, height: int):
|
||||||
"""Decode and center-crop an image to exactly ``width`` x ``height`` cells."""
|
"""Decode and letterbox an image into exactly ``width`` x ``height`` cells.
|
||||||
|
|
||||||
|
Scale to fit, never crop. Square album art fills the box either way, but a
|
||||||
|
wide station wordmark (Bassdrive's logo is 3:1) used to be centre-cropped
|
||||||
|
down to a zoomed slice of its middle. The padding stays transparent, so
|
||||||
|
the mosaic behind the artwork shows through instead of a black bar.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
from PIL import Image, ImageOps
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
with Image.open(BytesIO(data)) as source:
|
with Image.open(BytesIO(data)) as source:
|
||||||
|
# A PNG with transparency is drawn *over* the mosaic, which reads
|
||||||
|
# as noise behind a logo; composite those onto black instead.
|
||||||
|
has_alpha = source.mode in ("RGBA", "LA", "PA", "RGBa", "La") or (
|
||||||
|
"transparency" in source.info
|
||||||
|
)
|
||||||
image = source.convert("RGBA")
|
image = source.convert("RGBA")
|
||||||
cell_width, cell_height = cell_size()
|
cell_width, cell_height = cell_size()
|
||||||
target = (
|
target = (
|
||||||
max(1, width * cell_width),
|
max(1, width * cell_width),
|
||||||
max(1, height * cell_height),
|
max(1, height * cell_height),
|
||||||
)
|
)
|
||||||
return ImageOps.fit(
|
fitted = ImageOps.contain(image, target, method=Image.Resampling.LANCZOS)
|
||||||
image,
|
background = (0, 0, 0, 255) if has_alpha else (0, 0, 0, 0)
|
||||||
target,
|
canvas = Image.new("RGBA", target, background)
|
||||||
method=Image.Resampling.LANCZOS,
|
canvas.alpha_composite(
|
||||||
centering=(0.5, 0.5),
|
fitted,
|
||||||
|
(
|
||||||
|
(target[0] - fitted.width) // 2,
|
||||||
|
(target[1] - fitted.height) // 2,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
return canvas
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
+63
-6
@@ -9,9 +9,20 @@ from PIL import Image
|
|||||||
from beefy_player import artwork
|
from beefy_player import artwork
|
||||||
|
|
||||||
|
|
||||||
def png_bytes(size=(400, 200)) -> bytes:
|
def png_bytes(size=(400, 200), alpha: int = 180) -> bytes:
|
||||||
buffer = BytesIO()
|
buffer = BytesIO()
|
||||||
Image.new("RGBA", size, (20, 180, 220, 180)).save(buffer, format="PNG")
|
Image.new("RGBA", size, (20, 180, 220, alpha)).save(buffer, format="PNG")
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def ink_bbox(image: Image.Image):
|
||||||
|
"""Where the artwork itself is, ignoring transparent or black padding."""
|
||||||
|
return image.convert("RGB").getbbox()
|
||||||
|
|
||||||
|
|
||||||
|
def opaque_png_bytes(size=(400, 200)) -> bytes:
|
||||||
|
buffer = BytesIO()
|
||||||
|
Image.new("RGB", size, (20, 180, 220)).save(buffer, format="PNG")
|
||||||
return buffer.getvalue()
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
@@ -35,12 +46,58 @@ def test_protocol_detection() -> None:
|
|||||||
assert artwork.detect_protocol({"SOMAFM_IMAGES": "0"}) is None
|
assert artwork.detect_protocol({"SOMAFM_IMAGES": "0"}) is None
|
||||||
|
|
||||||
|
|
||||||
def test_fit_image_crops_to_the_cell_box() -> None:
|
def test_fit_image_letterboxes_inside_the_cell_box() -> None:
|
||||||
fitted = artwork.fit_image(png_bytes(), 13, 8)
|
fitted = artwork.fit_image(png_bytes((400, 200)), 13, 8)
|
||||||
assert fitted is not None
|
assert fitted is not None
|
||||||
cell_width, cell_height = artwork.cell_size()
|
cell_width, cell_height = artwork.cell_size()
|
||||||
expected = (13 * cell_width) / (8 * cell_height)
|
assert (fitted.width, fitted.height) == (13 * cell_width, 8 * cell_height)
|
||||||
assert fitted.width / fitted.height == pytest.approx(expected, rel=0.01)
|
|
||||||
|
# the ink keeps its own 2:1 aspect instead of being cropped to the box
|
||||||
|
ink = ink_bbox(fitted)
|
||||||
|
assert ink is not None
|
||||||
|
left, top, right, bottom = ink
|
||||||
|
assert (right - left) / (bottom - top) == pytest.approx(2.0, rel=0.02)
|
||||||
|
# a wide image is padded above and below, and spans the full width
|
||||||
|
assert left == 0 and right == fitted.width
|
||||||
|
assert top > 0 and bottom < fitted.height
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_image_with_alpha_gets_a_black_backing() -> None:
|
||||||
|
"""Transparent artwork is drawn over the mosaic; black keeps it readable."""
|
||||||
|
fitted = artwork.fit_image(png_bytes((400, 200)), 13, 8)
|
||||||
|
assert fitted is not None
|
||||||
|
# every pixel is opaque now, and the padding is black rather than mosaic
|
||||||
|
assert fitted.getchannel("A").getextrema() == (255, 255)
|
||||||
|
assert fitted.getpixel((0, 0)) == (0, 0, 0, 255)
|
||||||
|
# the ink is still letterboxed, not cropped: its own 2:1 aspect survives
|
||||||
|
ink = ink_bbox(fitted)
|
||||||
|
assert ink is not None
|
||||||
|
left, top, right, bottom = ink
|
||||||
|
assert (right - left) / (bottom - top) == pytest.approx(2.0, rel=0.02)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_opaque_image_keeps_the_transparent_padding() -> None:
|
||||||
|
"""No alpha channel: nothing to hide, so the mosaic shows through."""
|
||||||
|
fitted = artwork.fit_image(opaque_png_bytes((400, 200)), 13, 8)
|
||||||
|
assert fitted is not None
|
||||||
|
assert fitted.getpixel((0, 0))[3] == 0
|
||||||
|
ink = ink_bbox(fitted)
|
||||||
|
assert ink is not None
|
||||||
|
left, top, right, bottom = ink
|
||||||
|
assert (right - left) / (bottom - top) == pytest.approx(2.0, rel=0.02)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_image_centres_a_tall_image() -> None:
|
||||||
|
fitted = artwork.fit_image(png_bytes((100, 400)), 13, 8)
|
||||||
|
assert fitted is not None
|
||||||
|
ink = ink_bbox(fitted)
|
||||||
|
assert ink is not None
|
||||||
|
left, _, right, _ = ink
|
||||||
|
assert left > 0 and right < fitted.width
|
||||||
|
assert right - left == pytest.approx(fitted.height / 4, rel=0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_image_rejects_garbage() -> None:
|
||||||
assert artwork.fit_image(b"not an image", 13, 8) is None
|
assert artwork.fit_image(b"not an image", 13, 8) is None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user