You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
spiel/spiel/image.py

86 lines
2.6 KiB
Python

from __future__ import annotations
3 years ago
from dataclasses import dataclass
from functools import lru_cache
3 years ago
from math import floor
from pathlib import Path
from typing import Iterable, List, NamedTuple, Tuple, Union
3 years ago
from PIL import Image as Img
3 years ago
from rich.color import Color
from rich.console import Console, ConsoleOptions
3 years ago
from rich.segment import Segment
from rich.style import Style
from .utils import chunks
3 years ago
class ImageSize(NamedTuple):
width: int
height: int
Pixels = Tuple[Union[Tuple[int, int, int], None], ...]
@lru_cache(maxsize=2 ** 8)
def _pixels_to_segments(pixels: Pixels, size: ImageSize) -> List[Segment]:
line = Segment.line()
segments = []
pixel_row_pairs = chunks(chunks(pixels, size.width), 2, fill_value=[None] * size.width)
for top_pixel_row, bottom_pixel_row in pixel_row_pairs:
for top_pixel, bottom_pixel in zip(top_pixel_row, bottom_pixel_row):
# use upper-half-blocks for the top pixel row and the background color for the bottom pixel row
segments.append(
Segment(
text="",
style=Style(
color=Color.from_rgb(*top_pixel) if top_pixel else None,
bgcolor=Color.from_rgb(*bottom_pixel) if bottom_pixel else None,
),
)
)
segments.append(line)
return list(Segment.simplify(segments))
@lru_cache(maxsize=2 ** 4)
def _load_image(path: Path) -> Image:
return Img.open(path)
@dataclass(frozen=True)
3 years ago
class Image:
img: Img
3 years ago
@classmethod
def from_file(cls, path: Path) -> Image:
return cls(img=_load_image(path))
3 years ago
def _determine_size(self, options: ConsoleOptions) -> ImageSize:
width, height = self.img.size
# multiply the max height by 2, because we're going to print 2 "pixels" per row
3 years ago
max_height = options.height * 2 if options.height else None
if max_height:
width, height = width * max_height / self.img.height, max_height
3 years ago
if width > options.max_width:
width, height = options.max_width, height * options.max_width / width
return ImageSize(floor(width), floor(height))
def _resize(self, size: ImageSize) -> Img:
return self.img.resize(
3 years ago
size=size,
resample=Img.LANCZOS,
3 years ago
)
def __rich_console__(self, console: Console, options: ConsoleOptions) -> Iterable[Segment]:
size = self._determine_size(options)
resized = self._resize(size)
pixels = tuple(resized.getdata())
yield from _pixels_to_segments(pixels, size)