Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions src/cropper.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import math
from pathlib import Path

from PIL import Image, ImageOps
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
92 changes: 92 additions & 0 deletions tests/test_cropper.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
CARD_H_MM,
CARD_W_MM,
_add_mirror_bleed,
_fill_rounded_corners,
crop_image,
process_for_pdf,
)
Expand Down Expand Up @@ -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

Loading