Skip to content

Commit

Permalink
fix: cropping and image type
Browse files Browse the repository at this point in the history
- crop all sides the same
- change the Image object from numpy.ndarray to PIL.Image
- rework the image operations to use PIL
- add cache and outputs into .gitignore
- fix other minor typing issues
  • Loading branch information
AdamBajger committed May 16, 2024
1 parent 0f77830 commit 95208b7
Show file tree
Hide file tree
Showing 6 changed files with 160 additions and 200 deletions.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,7 @@ dmypy.json

# Pyre type checker
.pyre/

# MTG Cache
.cache_mtg/
tests/outputs/
33 changes: 10 additions & 23 deletions mtgproxies/dimensions.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
from logging import getLogger

from typing import Literal
from collections.abc import Iterable
from logging import getLogger
from typing import Any, Literal

import numpy as np
from nptyping import NDArray, Float
from nptyping import Float, NDArray, UInt
from nptyping.shape import Shape


Expand Down Expand Up @@ -79,29 +78,19 @@
},
}

UNITS_TO_MM = {
"in": 25.4,
"mm": 1.0,
"cm": 10
}
UNITS_TO_MM = {"in": 25.4, "mm": 1.0, "cm": 10}

UNITS_TO_IN = {
"in": 1.0,
"mm": 1/25.4,
"cm": 10/25.4
}
UNITS_TO_IN = {"in": 1.0, "mm": 1 / 25.4, "cm": 10 / 25.4}


Units = Literal["in", "mm", "cm"]


def get_pixels_from_size_and_ppsu(
ppsu: int,
size: Iterable[float] | float
) -> Iterable[int]:
def get_pixels_from_size_and_ppsu(ppsu: int, size: Iterable[float] | float) -> NDArray[Any, UInt]:
"""Calculate size in pixels from size and DPI.
The code assumes that everything is handled in milimetres.
Args:
ppsu (int): Dots per inch. Dots here are pixels.
size (Iterable[float] | float): Value or iterable of values representing size in inches.
Expand All @@ -113,14 +102,15 @@ def get_pixels_from_size_and_ppsu(


def get_ppsu_from_size_and_pixels(
pixel_values: Iterable[int] | int,
size: Iterable[float] | float,
pixel_values: Iterable[int] | int,
size: Iterable[float] | float,
) -> int:
"""Calculate PPSU (points per size unit) from size and amount of pixels.
It calculates the PPSU by dividing the amount of pixels by the size in whatever units are used.
If multiple dimensions are provided, it averages over the DPIs for each dimension.
If the PPSUs differ, a warning is logged.
Args:
pixel_values (Iterable[int] | int): Value or iterable of values representing size in pixels.
size (Iterable[float] | float): Value or iterable of values representing size in any units.
Expand All @@ -143,6 +133,3 @@ def parse_papersize_from_spec(spec: str, units: Units) -> NDArray[Shape["2"], Fl
raise ValueError(f"Units {units} not supported for papersize {spec}")
else:
raise ValueError(f"Paper size not supported: {spec}")



133 changes: 97 additions & 36 deletions mtgproxies/print_cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
if TYPE_CHECKING:
from collections.abc import Generator
from pathlib import Path
from typing import Any, Literal
from typing import Literal

from nptyping import Float, NDArray, Shape
from nptyping import Float, NDArray


logger = getLogger(__name__)
Expand Down Expand Up @@ -61,11 +61,11 @@ def blend_patch_into_image(bbox: tuple[int, int, int, int], image: PIL.Image, pa


def blend_flipped_stripe(
square_image: NDArray[Shape[Any, Any, 4], Float],
square_image: Image,
stripe_width_fraction: float,
flip_how: Literal["horizontal", "vertical"],
stripe_location: Literal["top", "bottom", "left", "right"],
) -> NDArray[Shape[2], Float]:
) -> Image:
"""Takes a leftmost stripe of a square image, flips it, and blends it back into the image.
The stripe is blended using the alpha channel of the image to fill in the transparent regions
Expand All @@ -82,7 +82,7 @@ def blend_flipped_stripe(
The image with the flipped stripe blended in.
"""
corner_copy = square_image.copy()
width, height = corner_copy.shape[:2]
width, height = corner_copy.size
if stripe_location in ["top", "bottom"]:
transpose_method = Transpose.FLIP_LEFT_RIGHT
elif stripe_location in ["left", "right"]:
Expand Down Expand Up @@ -111,43 +111,103 @@ def blend_flipped_stripe(
else:
raise ValueError(f"Invalid flip_how: {flip_how}")

image = PIL.Image.fromarray(corner_copy)
patch_inverted = image.crop(bbox).transpose(method=transpose_method)
return blend_patch_into_image(bbox, image, patch_inverted)
patch_inverted = corner_copy.crop(bbox).transpose(method=transpose_method)
return blend_patch_into_image(bbox, corner_copy, patch_inverted)


def fill_corners(img: NDArray[Shape[2], Float]) -> NDArray[Shape[2], Float]:
def fill_corners(card_image: Image) -> Image:
"""Fill the corners of the card with the closest pixels around the corners to match the border color."""
card_width = img.shape[0]
corner_size = card_width // 10

# left side
img[:corner_size, :corner_size] = blend_flipped_stripe(img[:corner_size, :corner_size], 1 / 6, "vertical", "left")
img[:corner_size, -corner_size:] = blend_flipped_stripe(
img[:corner_size, -corner_size:], 1 / 6, "vertical", "right"
corner_size = card_image.width // 10

# top corners, vertical stripes
box_left = (0, 0, corner_size, corner_size)
card_image.paste(
blend_flipped_stripe(
square_image=card_image.crop(box=box_left),
stripe_width_fraction=1 / 6,
flip_how="vertical",
stripe_location="left",
),
box=box_left,
)
box_right = (card_image.width - corner_size, 0, card_image.width, corner_size)
card_image.paste(
blend_flipped_stripe(
square_image=card_image.crop(box=box_right),
stripe_width_fraction=1 / 6,
flip_how="vertical",
stripe_location="right",
),
box=box_right,
)

# right side
img[-corner_size:, :corner_size] = blend_flipped_stripe(img[-corner_size:, :corner_size], 1 / 6, "vertical", "left")
img[-corner_size:, -corner_size:] = blend_flipped_stripe(
img[-corner_size:, -corner_size:], 1 / 6, "vertical", "right"
# bottom corners, vertical stripes
box_left = (0, card_image.height - corner_size, corner_size, card_image.height)
card_image.paste(
blend_flipped_stripe(
square_image=card_image.crop(box=box_left),
stripe_width_fraction=1 / 6,
flip_how="vertical",
stripe_location="left",
),
box=box_left,
)
box_right = (card_image.width - corner_size, card_image.height - corner_size, card_image.width, card_image.height)
card_image.paste(
blend_flipped_stripe(
square_image=card_image.crop(box=box_right),
stripe_width_fraction=1 / 6,
flip_how="vertical",
stripe_location="right",
),
box=box_right,
)

# top side
img[:corner_size, :corner_size] = blend_flipped_stripe(img[:corner_size, :corner_size], 1 / 6, "horizontal", "top")
img[-corner_size:, :corner_size] = blend_flipped_stripe(
img[-corner_size:, :corner_size], 1 / 6, "horizontal", "bottom"
# top corners, horizontal stripes
box_top = (0, 0, corner_size, corner_size)
card_image.paste(
blend_flipped_stripe(
square_image=card_image.crop(box=box_top),
stripe_width_fraction=1 / 6,
flip_how="horizontal",
stripe_location="top",
),
box=box_top,
)
box_bottom = (card_image.width - corner_size, 0, card_image.width, corner_size)
card_image.paste(
blend_flipped_stripe(
square_image=card_image.crop(box=box_bottom),
stripe_width_fraction=1 / 6,
flip_how="horizontal",
stripe_location="top",
),
box=box_bottom,
)

# bottom side
img[:corner_size, -corner_size:] = blend_flipped_stripe(
img[:corner_size, -corner_size:], 1 / 6, "horizontal", "top"
# bottom corners, horizontal stripes
box_top = (0, card_image.height - corner_size, corner_size, card_image.height)
card_image.paste(
blend_flipped_stripe(
square_image=card_image.crop(box=box_top),
stripe_width_fraction=1 / 6,
flip_how="horizontal",
stripe_location="bottom",
),
box=box_top,
)
img[-corner_size:, -corner_size:] = blend_flipped_stripe(
img[-corner_size:, -corner_size:], 1 / 6, "horizontal", "bottom"
box_bottom = (card_image.width - corner_size, card_image.height - corner_size, card_image.width, card_image.height)
card_image.paste(
blend_flipped_stripe(
square_image=card_image.crop(box=box_bottom),
stripe_width_fraction=1 / 6,
flip_how="horizontal",
stripe_location="bottom",
),
box=box_bottom,
)

return img
return card_image


class CardAssembler(abc.ABC):
Expand Down Expand Up @@ -212,15 +272,16 @@ def process_card_image(self, card_image_filepath: Path) -> Image:
Args:
card_image_filepath: Image file to process.
"""
img = np.asarray(PIL.Image.open(card_image_filepath)).copy()
img = PIL.Image.open(card_image_filepath).copy()
# fill corners
if self.filled_corners:
img = fill_corners(img)
# crop the cards
ppsu = get_ppsu_from_size_and_pixels(pixel_values=img.shape[:2], size=self.card_size)
crop_px = get_pixels_from_size_and_ppsu(ppsu=ppsu, size=self.border_crop)
img = img[crop_px:, crop_px:]
return PIL.Image.fromarray(img)
ppsu = get_ppsu_from_size_and_pixels(pixel_values=img.size, size=self.card_size)
crop_px = int(get_pixels_from_size_and_ppsu(ppsu=ppsu, size=self.border_crop))

img = img.crop(box=(crop_px, crop_px, img.width - crop_px, img.height - crop_px))
return img

def get_page_generators(
self,
Expand Down Expand Up @@ -376,7 +437,6 @@ def assemble(self, card_image_filepaths: list[Path], output_filepath: Path):
ax.plot(x_rel, y_rel, color="black", linewidth=crop_marks_thickness_in_pt)

for bbox, image in bbox_gen:
# extent = (left, right, bottom, top)
left, top, width, height = bbox

x0 = left / self.paper_size[0]
Expand All @@ -388,6 +448,7 @@ def assemble(self, card_image_filepaths: list[Path], output_filepath: Path):
x1 = x0 + width_scaled
y1 = y0 + height_scaled

# extent = (left, right, bottom, top)
extent = (x0, x1, y0, y1)

_ = ax.imshow(image, extent=extent, interpolation="lanczos", aspect="auto", origin="lower")
Expand Down
29 changes: 16 additions & 13 deletions mtgproxies/scryfall/scryfall.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
"""

import json
import logging
import threading
from collections import defaultdict
from functools import lru_cache
from functools import cache
from pathlib import Path
import logging

import numpy as np
import requests
Expand All @@ -20,7 +20,7 @@

logger = logging.getLogger(__name__)

DEFAULT_CACHE_DIR = Path.home() / ".cache" / "mtgproxies" / "scryfall"
DEFAULT_CACHE_DIR = Path(__file__).parent / ".cache" / "mtgproxies" / "scryfall"
# cache.mkdir(parents=True, exist_ok=True) # Create cache folder
scryfall_rate_limiter = RateLimiter(delay=0.1)
_download_lock = threading.Lock()
Expand Down Expand Up @@ -111,7 +111,7 @@ def search(q: str) -> list[dict]:
return depaginate(f"https://api.scryfall.com/cards/search?q={q}&format=json")


@lru_cache(maxsize=None)
@cache
def _get_database(cache_dir: Path, database_name: str = "default_cards"):
databases = depaginate("https://api.scryfall.com/bulk-data")
bulk_data = [database for database in databases if database["type"] == database_name]
Expand All @@ -133,8 +133,10 @@ def canonic_card_name(card_name: str) -> str:
return card_name


def get_card(card_name: str, cache_dir: Path, set_id: str = None, collector_number: str = None) -> dict | None:
"""Find a card by it's name and possibly set and collector number.
def get_card(
card_name: str, cache_dir: Path, set_id: str | None = None, collector_number: str | None = None
) -> dict | None:
"""Find a card by its name and possibly set and collector number.
In case, the Scryfall database contains multiple cards, the first is returned.
Expand Down Expand Up @@ -194,8 +196,9 @@ def get_faces(card):
raise ValueError(f"Unknown layout {card['layout']}")


def recommend_print(cache_dir: Path, current=None, card_name: str | None = None, oracle_id: str | None = None,
mode="best"):
def recommend_print(
cache_dir: Path, current=None, card_name: str | None = None, oracle_id: str | None = None, mode="best"
):
if current is not None and oracle_id is None: # Use oracle id of current
if current.get("layout") == "reversible_card":
# Reversible cards have the same oracle id for both faces
Expand Down Expand Up @@ -245,7 +248,7 @@ def score(card: dict):
if current is not None:
if current in recommendations:
recommendations.remove(current)
recommendations = [current] + recommendations
recommendations = [current, *recommendations]

# Return all card in descending order
return recommendations
Expand Down Expand Up @@ -274,7 +277,7 @@ def score(card: dict):
raise ValueError(f"Unknown mode '{mode}'")


@lru_cache(maxsize=None)
@cache
def card_by_id(cache_dir: Path):
"""Create dictionary to look up cards by their id.
Expand All @@ -286,7 +289,7 @@ def card_by_id(cache_dir: Path):
return {c["id"]: c for c in get_cards(cache_dir=cache_dir)}


@lru_cache(maxsize=None)
@cache
def get_cards_by_oracle_id(cache_dir: Path):
"""Create dictionary to look up cards by their oracle id.
Expand All @@ -304,7 +307,7 @@ def get_cards_by_oracle_id(cache_dir: Path):
return cards_by_oracle_id


@lru_cache(maxsize=None)
@cache
def get_oracle_ids_by_name(cache_dir: Path) -> dict[str, list[dict]]:
"""Create dictionary to look up oracle ids by their name.
Expand All @@ -331,7 +334,7 @@ def get_oracle_ids_by_name(cache_dir: Path) -> dict[str, list[dict]]:
return oracle_ids_by_name


def get_price(cache_dir: Path, oracle_id: str, currency: str = "eur", foil: bool = None) -> float | None:
def get_price(cache_dir: Path, oracle_id: str, currency: str = "eur", foil: bool | None = None) -> float | None:
"""Find the lowest price for oracle id.
Args:
Expand Down
Loading

0 comments on commit 95208b7

Please sign in to comment.