Skip to content

Commit

Permalink
Merge branch 'master' of github.com:darrenburns/rich-pixels
Browse files Browse the repository at this point in the history
  • Loading branch information
darrenburns committed Feb 10, 2024
2 parents 8286ffb + 33a5f40 commit 3ed24a4
Show file tree
Hide file tree
Showing 8 changed files with 431 additions and 108 deletions.
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,9 @@ cython_debug/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
.idea/

# Mac
.DS_Store
.AppleDouble
.LSOverride
8 changes: 7 additions & 1 deletion rich_pixels/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
from rich_pixels._pixel import Pixels
from rich_pixels._renderer import Renderer, HalfcellRenderer, FullcellRenderer

__all__ = [ "Pixels" ]
__all__ = [
"Pixels",
"Renderer",
"HalfcellRenderer",
"FullcellRenderer",
]
64 changes: 32 additions & 32 deletions rich_pixels/_pixel.py
Original file line number Diff line number Diff line change
@@ -1,73 +1,55 @@
from __future__ import annotations

from pathlib import Path, PurePath
from typing import Iterable, Mapping, Tuple, Union, Optional, List
from typing import Iterable, Mapping, Tuple, Union, Optional

from PIL import Image as PILImageModule
from PIL.Image import Image
from PIL.Image import Resampling
from rich.console import Console, ConsoleOptions, RenderResult
from rich.segment import Segment, Segments
from rich.style import Style

from rich_pixels._renderer import Renderer, HalfcellRenderer, FullcellRenderer


class Pixels:

def __init__(self) -> None:
self._segments: Segments | None = None

@staticmethod
def from_image(
image: Image,
renderer: Renderer = HalfcellRenderer(),
):
segments = Pixels._segments_from_image(image)
segments = Pixels._segments_from_image(image, renderer=renderer)
return Pixels.from_segments(segments)

@staticmethod
def from_image_path(
path: Union[PurePath, str],
resize: Optional[Tuple[int, int]] = None,
renderer: Renderer = HalfcellRenderer(),
) -> Pixels:
"""Create a Pixels object from an image. Requires 'image' extra dependencies.
Args:
path: The path to the image file.
resize: A tuple of (width, height) to resize the image to.
renderer: The renderer to use. Defaults to HalfcellRenderer.
"""
with PILImageModule.open(Path(path)) as image:
segments = Pixels._segments_from_image(image, resize)
segments = Pixels._segments_from_image(image, resize, renderer=renderer)

return Pixels.from_segments(segments)

@staticmethod
def _segments_from_image(
image: Image, resize: Optional[Tuple[int, int]] = None
image: Image,
resize: Optional[Tuple[int, int]] = None,
renderer: Renderer = HalfcellRenderer(),
) -> list[Segment]:
if resize:
image = image.resize(resize, resample=Resampling.NEAREST)

width, height = image.width, image.height
rgba_image = image.convert("RGBA")
get_pixel = rgba_image.getpixel
parse_style = Style.parse
null_style = Style.null()
segments = []

for y in range(height):
this_row: List[Segment] = []
row_append = this_row.append

for x in range(width):
r, g, b, a = get_pixel((x, y))
style = parse_style(f"on rgb({r},{g},{b})") if a > 0 else null_style
row_append(Segment(" ", style))

row_append(Segment("\n", null_style))

# TODO: Double-check if this is required - I've forgotten...
if not all(t[1] == "" for t in this_row[:-1]):
segments += this_row

return segments
return renderer.render(image, resize)

@staticmethod
def from_segments(
Expand Down Expand Up @@ -114,7 +96,24 @@ def __rich_console__(
if __name__ == "__main__":
console = Console()
images_path = Path(__file__).parent / "../tests/.sample_data/images"
pixels = Pixels.from_image_path(images_path / "bulbasaur.png")
pixels = Pixels.from_image_path(images_path / "bulbasaur.png",
renderer=FullcellRenderer())
console.print("\\[case.1] print with fullpixels renderer")
console.print(pixels)

pixels = Pixels.from_image_path(images_path / "bulbasaur.png",
renderer=FullcellRenderer(default_color="black"))
console.print("\\[case.2] print with fullpixels renderer and default_color")
console.print(pixels)

pixels = Pixels.from_image_path(images_path / "bulbasaur.png",
renderer=HalfcellRenderer())
console.print("\\[case.3] print with halfpixels renderer")
console.print(pixels)

pixels = Pixels.from_image_path(images_path / "bulbasaur.png",
renderer=HalfcellRenderer(default_color="black"))
console.print("\\[case.4] print with halfpixels renderer and default_color")
console.print(pixels)

grid = """\
Expand All @@ -131,4 +130,5 @@ def __rich_console__(
"O": Segment("O", Style.parse("white on blue")),
}
pixels = Pixels.from_ascii(grid, mapping)
console.print("\\[case.5] print ascii")
console.print(pixels)
165 changes: 165 additions & 0 deletions rich_pixels/_renderer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
from __future__ import annotations

from typing import Callable, Tuple
from rich.segment import Segment
from rich.style import Style
from PIL.Image import Image, Resampling

RGBA = Tuple[int, int, int, int]
GetPixel = Callable[[Tuple[int, int]], RGBA]


def _get_color(pixel: RGBA, default_color: str | None = None) -> str | None:
r, g, b, a = pixel
return f"rgb({r},{g},{b})" if a > 0 else default_color


class Renderer:
"""
Base class for renderers.
"""

default_color: str | None
null_style: Style | None

def __init__(
self,
*,
default_color: str | None = None,
) -> None:
self.default_color = default_color
self.null_style = None if default_color is None else Style.parse(
f"on {default_color}")

def render(self, image: Image, resize: Tuple[int, int] | None) -> list[Segment]:
"""
Render an image to Segments.
"""

rgba_image = image.convert("RGBA")
if resize:
rgba_image = rgba_image.resize(resize, resample=Resampling.NEAREST)

get_pixel = rgba_image.getpixel
width, height = rgba_image.width, rgba_image.height

segments = []

for y in self._get_range(height):
this_row: list[Segment] = []

this_row += self._render_line(line_index=y, width=width,
get_pixel=get_pixel)
this_row.append(Segment("\n", self.null_style))

# TODO: Double-check if this is required - I've forgotten...
if not all(t[1] == "" for t in this_row[:-1]):
segments += this_row

return segments

def _get_range(self, height: int) -> range:
"""
Get the range of lines to render.
"""
raise NotImplementedError

def _render_line(
self,
*,
line_index: int,
width: int,
get_pixel: GetPixel
) -> list[Segment]:
"""
Render a line of pixels.
"""
raise NotImplementedError


class HalfcellRenderer(Renderer):
"""
Render an image to half-height cells.
"""

def render(self, image: Image, resize: Tuple[int, int] | None) -> list[Segment]:
# because each row is 2 lines high, so we need to make sure the height is even
target_height = resize[1] if resize else image.size[1]
if target_height % 2 != 0:
target_height += 1

if image.size[1] != target_height:
resize = (resize[0], target_height) if resize else (
image.size[0], target_height)

return super().render(image, resize)

def _get_range(self, height: int) -> range:
return range(0, height, 2)

def _render_line(
self,
*,
line_index: int,
width: int,
get_pixel: GetPixel
) -> list[Segment]:
line = []
for x in range(width):
line.append(self._render_halfcell(x=x, y=line_index, get_pixel=get_pixel))
return line

def _render_halfcell(
self,
*,
x: int,
y: int,
get_pixel: GetPixel
) -> Segment:
colors = []

# get lower pixel, render lower pixel use foreground color, so it must be first
lower_color = _get_color(get_pixel((x, y + 1)),
default_color=self.default_color)
colors.append(lower_color or "")
# get upper pixel, render upper pixel use background color, it is optional
upper_color = _get_color(get_pixel((x, y)), default_color=self.default_color)
if upper_color: colors.append(upper_color or "")

style = Style.parse(" on ".join(colors)) if colors else self.null_style
# use lower halfheight block to render if lower pixel is not transparent
return Segment("▄" if lower_color else " ", style)


class FullcellRenderer(Renderer):
"""
Render an image to full-height cells.
"""

def _get_range(self, height: int) -> range:
return range(height)

def _render_line(
self,
*,
line_index: int,
width: int,
get_pixel: GetPixel
) -> list[Segment]:
line = []
for x in range(width):
line.append(self._render_fullcell(x=x, y=line_index, get_pixel=get_pixel))
return line

def _render_fullcell(
self,
*,
x: int,
y: int,
get_pixel: GetPixel
) -> Segment:
pixel = get_pixel((x, y))
style = Style.parse(
f"on {_get_color(pixel, default_color=self.default_color)}") if pixel[
3] > 0 else self.null_style
return Segment(" ", style)
Empty file added tests/__init__.py
Empty file.
Loading

0 comments on commit 3ed24a4

Please sign in to comment.