From c463a2e2d672effd82d18665da7565949944dd39 Mon Sep 17 00:00:00 2001 From: Fer Date: Tue, 23 Jun 2026 08:14:40 +0200 Subject: [PATCH] PDF Generation Issue: Scryfall images sometimes have rounded corners, which created strange white artifacts when building the PDF a new function based on luminance has been created to avoid this effect and remove the rounded corners before generating the pdf and apply the cropping modified files: src/cropper.py test/test_cropper.py test deck: https://moxfield.com/decks/3lvo2muCskuvIvlrv_0Y0Q --- src/cropper.py | 109 ++++++++++++++++++++++++++++++++++++++++++ tests/test_cropper.py | 92 +++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) diff --git a/src/cropper.py b/src/cropper.py index cb734b5..5bf6776 100644 --- a/src/cropper.py +++ b/src/cropper.py @@ -1,4 +1,5 @@ import logging +import math from pathlib import Path from PIL import Image, ImageOps @@ -16,6 +17,111 @@ # Mirror bleed to add around each trimmed card (mm) BLEED_MM = 1.0 +# Rounded-corner fill: fraction of the shorter side used as corner radius +_CORNER_RADIUS_FRAC = 0.04 +# Minimum luminance difference to consider a corner pixel "anomalous" +_CORNER_LUMA_THRESHOLD = 60 +# Offset past the corner zone where border color is sampled +_CORNER_SAMPLE_OFFSET = 1.0 + + +def _luminance(r: int, g: int, b: int) -> float: + return 0.299 * r + 0.587 * g + 0.114 * b + + +def _sample_border_color( + img: Image.Image, corner: str, radius: int, +) -> tuple[int, int, int]: + """Sample the border color near a corner by taking the median of pixels + along the horizontal and vertical edges just outside the rounded zone.""" + w, h = img.size + samples: list[tuple[int, int, int]] = [] + # Sample range: from radius to 2*radius along each edge + lo = radius + hi = min(2 * radius, min(w, h) // 2) + + if corner == "tl": + for i in range(lo, hi): + if i < w: + samples.append(img.getpixel((i, 0))) + if i < h: + samples.append(img.getpixel((0, i))) + elif corner == "tr": + for i in range(lo, hi): + if i < w: + samples.append(img.getpixel((w - 1 - i, 0))) + if i < h: + samples.append(img.getpixel((w - 1, i))) + elif corner == "bl": + for i in range(lo, hi): + if i < w: + samples.append(img.getpixel((i, h - 1))) + if i < h: + samples.append(img.getpixel((0, h - 1 - i))) + elif corner == "br": + for i in range(lo, hi): + if i < w: + samples.append(img.getpixel((w - 1 - i, h - 1))) + if i < h: + samples.append(img.getpixel((w - 1, h - 1 - i))) + + if not samples: + return (0, 0, 0) + samples.sort(key=lambda c: _luminance(*c)) + return samples[len(samples) // 2] + + +def _fill_rounded_corners(img: Image.Image) -> Image.Image: + """Detect rounded corners and fill them with the nearby border color. + + Scryfall card images have rounded corners with dark/black pixels that + create artefacts when mirror-bleed is applied. This function replaces + those corner pixels with the colour sampled from the adjacent border. + Images without rounded corners are returned unmodified. + """ + w, h = img.size + radius = max(1, round(min(w, h) * _CORNER_RADIUS_FRAC)) + + # corner_name → (origin_x, origin_y, dx_sign, dy_sign) + corners = { + "tl": (0, 0, 1, 1), + "tr": (w - 1, 0, -1, 1), + "bl": (0, h - 1, 1, -1), + "br": (w - 1, h - 1, -1, -1), + } + + any_filled = False + pixels = img.load() + + for name, (ox, oy, sx, sy) in corners.items(): + border_color = _sample_border_color(img, name, radius) + border_luma = _luminance(*border_color) + + filled_this = False + for dy in range(radius): + for dx in range(radius): + # Distance from the corner vertex + dist = math.sqrt(dx * dx + dy * dy) + if dist > radius * _CORNER_SAMPLE_OFFSET: + continue + px = ox + dx * sx + py = oy + dy * sy + if not (0 <= px < w and 0 <= py < h): + continue + r, g, b = pixels[px, py][:3] + luma = _luminance(r, g, b) + if abs(luma - border_luma) > _CORNER_LUMA_THRESHOLD: + pixels[px, py] = border_color + filled_this = True + + if filled_this: + any_filled = True + + if any_filled: + _log.debug("Filled rounded corners") + + return img + def _crop_to_trim(img: Image.Image) -> Image.Image: w, h = img.size @@ -70,6 +176,9 @@ def process_for_pdf( except Exception as exc: raise RuntimeError(f"No se puede abrir la imagen '{input_path}': {exc}") from exc trimmed = _crop_to_trim(img) if crop_borders else img + if not crop_borders: + # Scryfall images may have rounded corners with dark pixels + trimmed = _fill_rounded_corners(trimmed) cw, ch = trimmed.size bx = round(cw * BLEED_MM / CARD_W_MM) by = round(ch * BLEED_MM / CARD_H_MM) diff --git a/tests/test_cropper.py b/tests/test_cropper.py index 7321428..2f8838b 100644 --- a/tests/test_cropper.py +++ b/tests/test_cropper.py @@ -13,6 +13,7 @@ CARD_H_MM, CARD_W_MM, _add_mirror_bleed, + _fill_rounded_corners, crop_image, process_for_pdf, ) @@ -202,3 +203,94 @@ def test_bottom_bleed_is_flip_of_bottom_strip(self): expected = ImageOps.flip(img.crop((0, h - by, w, h))) actual = out.crop((bx, nh - by, bx + w, nh)) assert list(actual.getdata()) == list(expected.getdata()) + + +# ─── _fill_rounded_corners ────────────────────────────────────────────────── + + +def _img_with_rounded_corners(w: int, h: int, border_color, corner_color) -> Image.Image: + """Create an image with a solid border_color and dark corner_color in the + corner zones to simulate Scryfall rounded corners.""" + import math + + img = Image.new("RGB", (w, h), border_color) + radius = max(1, round(min(w, h) * 0.04)) + pixels = img.load() + # Paint dark arcs in each corner + corners = [(0, 0, 1, 1), (w - 1, 0, -1, 1), (0, h - 1, 1, -1), (w - 1, h - 1, -1, -1)] + for ox, oy, sx, sy in corners: + for dy in range(radius): + for dx in range(radius): + dist = math.sqrt(dx * dx + dy * dy) + # Only fill inside the radius (the "rounded" zone) + if dist <= radius * 0.9: + px = ox + dx * sx + py = oy + dy * sy + if 0 <= px < w and 0 <= py < h: + pixels[px, py] = corner_color + return img + + +class TestFillRoundedCorners: + def test_fills_dark_corners(self): + """Dark corner pixels should be replaced by the border colour.""" + border = (180, 120, 80) + dark = (5, 5, 5) + img = _img_with_rounded_corners(200, 280, border, dark) + result = _fill_rounded_corners(img) + # Check top-left pixel was filled + assert result.getpixel((0, 0)) == border + + def test_no_change_on_uniform_image(self): + """A uniform image without rounded corners should not be modified.""" + color = (150, 100, 60) + img = Image.new("RGB", (200, 280), color) + original_data = list(img.getdata()) + result = _fill_rounded_corners(img) + assert list(result.getdata()) == original_data + + def test_only_corner_zone_is_affected(self): + """Pixels outside the corner zone should remain untouched.""" + import math + + border = (180, 120, 80) + dark = (5, 5, 5) + w, h = 200, 280 + img = _img_with_rounded_corners(w, h, border, dark) + radius = max(1, round(min(w, h) * 0.04)) + # Sample a pixel safely inside the image but outside any corner zone + center_x, center_y = w // 2, h // 2 + original = img.getpixel((center_x, center_y)) + _fill_rounded_corners(img) + assert img.getpixel((center_x, center_y)) == original + + def test_all_four_corners_are_filled(self): + """All four corners should get their dark pixels replaced.""" + border = (200, 160, 100) + dark = (0, 0, 0) + w, h = 200, 280 + img = _img_with_rounded_corners(w, h, border, dark) + result = _fill_rounded_corners(img) + # Check one pixel in each corner + assert result.getpixel((0, 0)) == border + assert result.getpixel((w - 1, 0)) == border + assert result.getpixel((0, h - 1)) == border + assert result.getpixel((w - 1, h - 1)) == border + + def test_process_for_pdf_applies_fill_when_no_crop(self, tmp_path): + """process_for_pdf with crop_borders=False should fill rounded corners.""" + border = (180, 120, 80) + dark = (5, 5, 5) + img = _img_with_rounded_corners(200, 280, border, dark) + inp = tmp_path / "scryfall.png" + img.save(str(inp)) + out = tmp_path / "bled.png" + process_for_pdf(inp, out, crop_borders=False) + result = Image.open(out) + # The bled image is larger; the original image starts at (bx, by) + cw, ch = 200, 280 + bx = round(cw * BLEED_MM / CARD_W_MM) + by = round(ch * BLEED_MM / CARD_H_MM) + # The pixel at the original top-left should now be the border color + assert result.getpixel((bx, by)) == border +