From 4b02cc4c9dbca7102b7cfa0de2589622032d3086 Mon Sep 17 00:00:00 2001 From: evilchili Date: Sun, 10 Aug 2025 13:25:54 -0700 Subject: [PATCH] cleanup; cache tile and image data This commit refactors tileset.py to simplify the implementation and properly cache both tile data and image data. This results in a roughly 50% speedup in image generation. --- src/tilemapper/battlemap.py | 5 +- src/tilemapper/cli.py | 17 ++--- src/tilemapper/grid.py | 14 +++- src/tilemapper/tileset.py | 127 ++++++++++++++++++------------------ 4 files changed, 86 insertions(+), 77 deletions(-) diff --git a/src/tilemapper/battlemap.py b/src/tilemapper/battlemap.py index 1aae45e..af29ac4 100644 --- a/src/tilemapper/battlemap.py +++ b/src/tilemapper/battlemap.py @@ -4,12 +4,12 @@ from functools import cached_property from io import StringIO from pathlib import Path from textwrap import indent -from typing import Union, List +from typing import List, Union from PIL import Image from tilemapper.grid import Grid, Position -from tilemapper.tileset import TileSet, Tile +from tilemapper.tileset import Tile, TileSet class UnsupportedTileException(Exception): @@ -28,6 +28,7 @@ class BattleMap: >>> bmap.render() """ + name: str = "" source: Union[StringIO, Path] = None source_data: str = "" diff --git a/src/tilemapper/cli.py b/src/tilemapper/cli.py index d017a0d..57e3410 100644 --- a/src/tilemapper/cli.py +++ b/src/tilemapper/cli.py @@ -18,8 +18,7 @@ app_state = {} def main( ctx: typer.Context, config_dir: Path = typer.Option( - default=Path(__file__).parent.parent / "tilesets", - help="The path containing tile sets to load." + default=Path(__file__).parent.parent / "tilesets", help="The path containing tile sets to load." ), verbose: bool = typer.Option(default=False, help="If True, increase verbosity of status messages."), ): @@ -57,10 +56,10 @@ def inspect(source: Path = typer.Argument(help="The battle map text file to laod @app.command() def render( - source: Path = typer.Argument(help="The battle map text file to load."), - outfile: Path = typer.Argument(help="The file to create."), - tileset: str = typer.Option( - help="The name of the tile set to use (run mapper list to see what's available).")): + source: Path = typer.Argument(help="The battle map text file to load."), + outfile: Path = typer.Argument(help="The file to create."), + tileset: str = typer.Option(help="The name of the tile set to use (run mapper list to see what's available)."), +): """ Create a PNG image of a battle map using a tile set. """ @@ -70,18 +69,12 @@ def render( try: bmap = battlemap.BattleMap(name=source.name, source=source, tileset=manager.load(tileset)) - except _tileset.MissingTileException as e: - logging.error(e) - sys.exit(1) - - try: bmap.load() except battlemap.UnsupportedTileException as e: logging.error(e) sys.exit(1) image = bmap.render() - # image = image.resize((int(0.5 * image.size[0]), int(0.5 * image.size[1]))) image.save(outfile) print(f"Wrote {outfile.stat().st_size} bytes to {outfile}") diff --git a/src/tilemapper/grid.py b/src/tilemapper/grid.py index c8c5fe6..2183b35 100644 --- a/src/tilemapper/grid.py +++ b/src/tilemapper/grid.py @@ -1,14 +1,26 @@ from collections import namedtuple -from typing import Union +from dataclasses import dataclass +from typing import Any, Union # a position inside a grid. Position = namedtuple("Position", ["y", "x", "value"]) +@dataclass +class Position: + y: int + x: int + value: Any + + def __str__(self): + return f"posiiton-{self.y}-{self.x}-{self.value}" + + class Grid: """ A class wrapping a 2-d array of Position instances with some convience methods. """ + def __init__(self, data): self.data = [] for y in range(len(data)): diff --git a/src/tilemapper/tileset.py b/src/tilemapper/tileset.py index cbd888c..441ae54 100644 --- a/src/tilemapper/tileset.py +++ b/src/tilemapper/tileset.py @@ -1,5 +1,7 @@ +import logging import random import tomllib +from collections import defaultdict from dataclasses import dataclass, field from functools import cached_property from pathlib import Path @@ -34,7 +36,11 @@ COLOR_MAP = { } -class MissingTileException(Exception): +class UnsupportedTileException(Exception): + pass + + +class MissingImageDataException(Exception): pass @@ -44,6 +50,7 @@ class Tile: A base class repesenting a single member of a TileSet. Only supports text rendering; other tile types should subclass this class. """ + name: str char: str @@ -65,9 +72,10 @@ class TileSet: battle maps. Only supports text rendering; other set types should subclass this class. """ + name: str desc: str = "" - tiles: Dict[str, Tile] = field(default_factory=dict) + tiles: Dict[str, Tile] = field(default_factory=lambda: defaultdict(list)) # The default character map is suitable for dungeons and other structures. character_map: Dict[str, str] = field( @@ -95,29 +103,27 @@ class TileSet: } ) - def add(self, tiles: List[Tile]): + def add(self, tile: Tile): """ - Add a list of Tiles to the set. + Add a Tile to the set. """ - for tile in tiles: - self.tiles[tile.name] = tile + self.tiles[tile.name].append(tile) def get(self, char: str) -> Tile: """ Return the Tile instance corrresponding to an input character. """ - t = self.character_map[char] - try: - return self.tiles[f"{t}"] - except KeyError: - raise MissingTileException(f'"{self}" does not contain a "{t}" tile.') + if char not in self.character_map: + raise UnsupportedTileException(f"'{char}' is not supported by the current tile set.") + name = self.character_map[char] + return random.choice(self.tiles[name]) if name in self.tiles else self.placeholder @property def empty_space(self) -> Tile: """ Return the Tile instance representing empty space. """ - return self.tiles[self.character_map[" "]] + return self.get(" ") def __str__(self): return f"[Tileset] {self.name}: {self.desc}" @@ -129,6 +135,7 @@ class ColorizedTile(Tile): A variant of the base Tile type that supports colorized console output using ANSI color codes. """ + color: str def __str__(self): @@ -139,6 +146,7 @@ class ColorizedTileSet(TileSet): """ A variant of the base TileSet type that uses ColorizedTiles. """ + tiles: Dict[str, ColorizedTile] = {} @@ -150,7 +158,9 @@ class ImageTile(Tile): A single Tile must have one or more images in its paths. When an ImageTile is rendered, a random path from ImageTile.paths will be selected. """ - paths: List[Path] + + paths: List[Path] = [] + paths: List[Path] = field(default_factory=list) buffer: Image = None @property @@ -193,7 +203,7 @@ class ImageTile(Tile): if not size: size = self.image.width rendered = self.image.copy().resize((size, size)) - border_size = int(size/4) + border_size = int(size / 4) if nw: rendered.paste(nw.image, (0, 0), nw.image) @@ -281,59 +291,49 @@ class ImageTileSet(TileSet): Like base tiles, edge and corner tiles can have multiple variants and will be chosen at random when rendered. """ + image_dir: Path tile_size: int = 128 - _cache = {} + _tile_cache = {} # ImageTile data + _image_cache = {} # rendered image data - def _load_images(self, char: str, name: str): - """ - Load the images in the tile set corresponding to a terrain type in the - character map, and organize them into ImageTiles, potentially with - variants. Loaded image data is added to the ImageTileSet._cache; each - ImageTile will point at the cached data. - """ - paths = [] - lastkey = None - key = None - for imgfile in sorted(self.image_dir.glob(f"{name}_*.*")): + @cached_property + def paths(self) -> Dict[str, List[Path]]: + paths = defaultdict(list) + for imgfile in sorted(self.image_dir.glob("*.png")): (terrain_name, *parts) = imgfile.stem.rsplit("_") key = terrain_name if parts[0] in ("edge", "corner"): key = "_".join([terrain_name, *parts[:-1]]) - if not lastkey: - lastkey = key - if lastkey != key: - self.add([self._get_or_create_tile(char=char, name=lastkey, paths=paths)]) - lastkey = key - paths = [] - paths.append(imgfile) - if key: - self.add([self._get_or_create_tile(char=char, name=key, paths=paths)]) + paths[key].append(imgfile) + return paths + + @cached_property + def placeholder(self): + buffer = Image.new("RGB", (self.tile_size, self.tile_size)) + ImageDraw.Draw(buffer).text((3, 3), "?", (255, 255, 255)) + return buffer def load(self): """ - Walk the characeter_map and load the images associated with it each - terrain type. + Walk the character_map and load the images associated with each terrain type. """ - self.tiles = {} + self.tiles = defaultdict(list) for char, name in self.character_map.items(): - self._load_images(char, name) - - def _get_or_create_tile(self, char: str, name: str, paths: List[Path]) -> ImageTile: - """ - Load image data for the specified terrain type. If the expected image - file cannot be found, generated a placeholder image. The resulting image - data is cached for reuse, making this method idempotent. - """ - if name in self._cache: - return self._cache[name] - buffer = None - if not paths: - buffer = Image.new("RGBA", (self.tile_size, self.tile_size)) - ImageDraw.Draw(buffer).text((3, 3), name, (255, 255, 255)) - self._cache[name] = ImageTile(char=char, name=name, paths=paths, buffer=buffer) - return self._cache[name] + if name not in self.paths: + raise MissingImageDataException( + f"The tile set does not contain any images for the '{char}' ({name}) terrain." + ) + for path in self.paths[name]: + key = f"{name}-{path.name}" + if key not in self._tile_cache: + tile = ImageTile(char=char, name=name, buffer=Image.open(path)) + self._tile_cache[key] = tile + self.add(tile) + for name in self.paths: + if name not in self.character_map.values(): + logging.warn(f"{name} images exist but do not map to terrain types in the legend.") def _get_overlays(self, position: Position, adjacent: List[Position] = []) -> tuple: """ @@ -364,9 +364,12 @@ class ImageTileSet(TileSet): Return a rendered image of the tile in the specified position, including any edge and corner overlays implied by the tiles adjacent to it. """ - return position.value.render( - self.tile_size, *[self._cache.get(overlay) for overlay in self._get_overlays(position, adjacent)] - ) + key = ":".join([str(position), *[str(a) for a in adjacent]]) + if key not in self._image_cache: + self._image_cache[key] = position.value.render( + self.tile_size, *[self._image_cache.get(overlay) for overlay in self._get_overlays(position, adjacent)] + ) + return self._image_cache[key] class TileSetManager: @@ -386,6 +389,7 @@ class TileSetManager: ... """ + def __init__(self, config_dir: Path): self.config_dir = config_dir self._available = {} @@ -426,14 +430,13 @@ class TileSetManager: @cached_property def console_map(self) -> TileSet: ts = TileSet(name="console", desc="Tiles used for input and text rendering.") - ts.add([Tile(char=key, name=value) for key, value in ts.character_map.items()]) + for key, value in ts.character_map.items(): + ts.add(Tile(char=key, name=value)) return ts @cached_property def console_map_colorized(self) -> ColorizedTileSet: ts = ColorizedTileSet(name="colorized", desc="Colorized ASCII.") - ts.add([ - ColorizedTile(char=key, name=value, color=COLOR_MAP.get(key, "grey")) - for key, value in ts.character_map.items() - ]) + for key, value in ts.character_map.items(): + ts.add(ColorizedTile(char=key, name=value, color=COLOR_MAP.get(key, "grey"))) return ts