Skip to content
Merged
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
119 changes: 40 additions & 79 deletions pixel_renderer/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,41 +17,34 @@
from PIL import ImageFont # noqa: E402, F401, I001
from gi.repository import Pango, PangoCairo # noqa: E402

# Reusable measurement context - avoids creating new surface/context/layout per call
# This provides ~10% speedup by reusing Pango layout object
_measurement_context = None

# Reusable rendering surface - avoids creating new surface per call
# Keyed by block_size (height), provides ~10% additional speedup
_render_surfaces = {}
_MAX_RENDER_WIDTH = 1024 # Max width for reusable surface


def _get_measurement_layout():
"""Get or create a reusable Pango layout for text measurement."""
global _measurement_context
if _measurement_context is None:
temp_surface = cairo.ImageSurface(cairo.FORMAT_RGB24, 1, 1)
temp_ctx = cairo.Context(temp_surface)
try:
layout = PangoCairo.create_layout(temp_ctx)
except KeyError as e:
if "could not find foreign type Context" in str(e):
raise RuntimeError(
"Pango/Cairo not properly installed. See https://github.com/sign/WeLT/issues/31"
) from e
raise
_measurement_context = (temp_surface, temp_ctx, layout)
return _measurement_context[2]


def _get_render_surface(block_size: int):
"""Get or create a reusable rendering surface for the given block size."""
if block_size not in _render_surfaces:
surface = cairo.ImageSurface(cairo.FORMAT_RGB24, _MAX_RENDER_WIDTH, block_size)
_MIN_CANVAS_WIDTH = 1024


@cache
def _get_layout() -> Pango.Layout:
"""A reusable Pango layout, shared by all calls."""
context = cairo.Context(cairo.ImageSurface(cairo.FORMAT_RGB24, 1, 1))
try:
return PangoCairo.create_layout(context)
except KeyError as e:
if "could not find foreign type Context" in str(e):
raise RuntimeError("Pango/Cairo not properly installed. See https://github.com/sign/WeLT/issues/31") from e
raise


_canvases = {}


def _get_canvas(height: int, width: int):
"""A reusable (surface, context, pixels) canvas per height, grown when a wider one is needed."""
canvas = _canvases.get(height)
if canvas is None or canvas[0].get_width() < width:
surface = cairo.ImageSurface(cairo.FORMAT_RGB24, max(width, _MIN_CANVAS_WIDTH), height)
context = cairo.Context(surface)
_render_surfaces[block_size] = (surface, context)
return _render_surfaces[block_size]
context.set_source_rgb(0, 0, 0) # Text is always black
pixels = np.frombuffer(surface.get_data(), dtype=np.uint8).reshape(height, surface.get_stride() // 4, 4)
canvas = _canvases[height] = (surface, context, pixels)
return canvas


def dim_to_block_size(value: int, block_size: int) -> int:
Expand Down Expand Up @@ -86,10 +79,7 @@ def render_signwriting(text: str, block_size: int = 16) -> np.ndarray:
new_image = Image.new("RGB", (width, height), color=(255, 255, 255))
padding = (width - image.width) // 2, (height - image.height) // 2
new_image.paste(image, padding, image)
# Explicitly convert PIL Image to numpy array with np.array() before ascontiguousarray()
# This ensures correct shape interpretation regardless of PIL's internal buffer layout
arr = np.array(new_image, dtype=np.uint8)
return np.ascontiguousarray(arr)
return np.array(new_image, dtype=np.uint8)


@cache
Expand All @@ -103,7 +93,7 @@ def render_text(text: str, block_size: int = 16, font_size: int = 12) -> np.ndar

Args:
text (str): The text to render on a single line
block_size (int): Height of each line in pixels, and width scale (default: 32)
block_size (int): Height of each line in pixels, and width scale (default: 16)
font_size (int): Font size (default: 12)

Returns:
Expand All @@ -112,54 +102,25 @@ def render_text(text: str, block_size: int = 16, font_size: int = 12) -> np.ndar
if is_swu(text):
return render_signwriting(text, block_size=block_size)

text = visualize_control_tokens(text, include_whitespace=True)

# Get reusable layout for text measurement (avoids creating new surface/context/layout each call)
layout = _get_measurement_layout()

# Set font and measure text
font_desc = cached_font_description("sans", font_size)
layout.set_font_description(font_desc)
layout.set_text(text, -1)
layout = _get_layout()
layout.set_font_description(cached_font_description("sans", font_size))
layout.set_text(visualize_control_tokens(text, include_whitespace=True), -1)
text_width, text_height = layout.get_pixel_size()

# Add padding and round up to nearest multiple of block_size
width = dim_to_block_size(text_width + 10, block_size=block_size)
line_height = block_size

# Get reusable surface if width fits, otherwise create new one
if width <= _MAX_RENDER_WIDTH:
surface, context = _get_render_surface(block_size)
surface_width = _MAX_RENDER_WIDTH
else:
# Text too wide for reusable surface, create dedicated one
surface = cairo.ImageSurface(cairo.FORMAT_RGB24, width, line_height)
context = cairo.Context(surface)
surface_width = width

# Fill white background (only the area we need)
context.set_source_rgb(1.0, 1.0, 1.0)
context.rectangle(0, 0, width, line_height)
context.fill()

# Set black text color
context.set_source_rgb(0.0, 0.0, 0.0)

# Position text (left-aligned horizontally, vertically within its line)
x = 5 # Small left padding
y = (line_height - text_height) // 2
context.move_to(x, y)
surface, context, pixels = _get_canvas(block_size, width)
pixels = pixels[:, :width]
pixels.fill(255) # White background, only the area we need
surface.mark_dirty()

# Render text
# Left-aligned with a small padding, vertically centered
context.move_to(5, (block_size - text_height) // 2)
PangoCairo.show_layout(context, layout)
surface.flush()

# Extract image data as numpy array
data = surface.get_data()
bgra = np.frombuffer(data, dtype=np.uint8).reshape((line_height, surface_width, 4))
# Slice to actual width if using reusable surface
if surface_width > width:
bgra = bgra[:, :width, :]
return bgra_to_rgb(bgra)
return bgra_to_rgb(pixels)


def render_text_image(text: str, block_size: int = 16, font_size: int = 12) -> Image.Image:
Expand Down
Loading