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.
This commit is contained in:
evilchili 2025-08-10 13:25:54 -07:00
parent 78785fbea5
commit 4b02cc4c9d
4 changed files with 86 additions and 77 deletions

View File

@ -4,12 +4,12 @@ from functools import cached_property
from io import StringIO from io import StringIO
from pathlib import Path from pathlib import Path
from textwrap import indent from textwrap import indent
from typing import Union, List from typing import List, Union
from PIL import Image from PIL import Image
from tilemapper.grid import Grid, Position from tilemapper.grid import Grid, Position
from tilemapper.tileset import TileSet, Tile from tilemapper.tileset import Tile, TileSet
class UnsupportedTileException(Exception): class UnsupportedTileException(Exception):
@ -28,6 +28,7 @@ class BattleMap:
>>> bmap.render() >>> bmap.render()
""" """
name: str = "" name: str = ""
source: Union[StringIO, Path] = None source: Union[StringIO, Path] = None
source_data: str = "" source_data: str = ""

View File

@ -18,8 +18,7 @@ app_state = {}
def main( def main(
ctx: typer.Context, ctx: typer.Context,
config_dir: Path = typer.Option( config_dir: Path = typer.Option(
default=Path(__file__).parent.parent / "tilesets", default=Path(__file__).parent.parent / "tilesets", help="The path containing tile sets to load."
help="The path containing tile sets to load."
), ),
verbose: bool = typer.Option(default=False, help="If True, increase verbosity of status messages."), verbose: bool = typer.Option(default=False, help="If True, increase verbosity of status messages."),
): ):
@ -59,8 +58,8 @@ def inspect(source: Path = typer.Argument(help="The battle map text file to laod
def render( def render(
source: Path = typer.Argument(help="The battle map text file to load."), source: Path = typer.Argument(help="The battle map text file to load."),
outfile: Path = typer.Argument(help="The file to create."), outfile: Path = typer.Argument(help="The file to create."),
tileset: str = typer.Option( tileset: str = typer.Option(help="The name of the tile set to use (run mapper list to see what's available)."),
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. Create a PNG image of a battle map using a tile set.
""" """
@ -70,18 +69,12 @@ def render(
try: try:
bmap = battlemap.BattleMap(name=source.name, source=source, tileset=manager.load(tileset)) 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() bmap.load()
except battlemap.UnsupportedTileException as e: except battlemap.UnsupportedTileException as e:
logging.error(e) logging.error(e)
sys.exit(1) sys.exit(1)
image = bmap.render() image = bmap.render()
# image = image.resize((int(0.5 * image.size[0]), int(0.5 * image.size[1])))
image.save(outfile) image.save(outfile)
print(f"Wrote {outfile.stat().st_size} bytes to {outfile}") print(f"Wrote {outfile.stat().st_size} bytes to {outfile}")

View File

@ -1,14 +1,26 @@
from collections import namedtuple from collections import namedtuple
from typing import Union from dataclasses import dataclass
from typing import Any, Union
# a position inside a grid. # a position inside a grid.
Position = namedtuple("Position", ["y", "x", "value"]) 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: class Grid:
""" """
A class wrapping a 2-d array of Position instances with some convience methods. A class wrapping a 2-d array of Position instances with some convience methods.
""" """
def __init__(self, data): def __init__(self, data):
self.data = [] self.data = []
for y in range(len(data)): for y in range(len(data)):

View File

@ -1,5 +1,7 @@
import logging
import random import random
import tomllib import tomllib
from collections import defaultdict
from dataclasses import dataclass, field from dataclasses import dataclass, field
from functools import cached_property from functools import cached_property
from pathlib import Path from pathlib import Path
@ -34,7 +36,11 @@ COLOR_MAP = {
} }
class MissingTileException(Exception): class UnsupportedTileException(Exception):
pass
class MissingImageDataException(Exception):
pass pass
@ -44,6 +50,7 @@ class Tile:
A base class repesenting a single member of a TileSet. Only supports A base class repesenting a single member of a TileSet. Only supports
text rendering; other tile types should subclass this class. text rendering; other tile types should subclass this class.
""" """
name: str name: str
char: str char: str
@ -65,9 +72,10 @@ class TileSet:
battle maps. Only supports text rendering; other set types should subclass battle maps. Only supports text rendering; other set types should subclass
this class. this class.
""" """
name: str name: str
desc: 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. # The default character map is suitable for dungeons and other structures.
character_map: Dict[str, str] = field( 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].append(tile)
self.tiles[tile.name] = tile
def get(self, char: str) -> Tile: def get(self, char: str) -> Tile:
""" """
Return the Tile instance corrresponding to an input character. Return the Tile instance corrresponding to an input character.
""" """
t = self.character_map[char] if char not in self.character_map:
try: raise UnsupportedTileException(f"'{char}' is not supported by the current tile set.")
return self.tiles[f"{t}"] name = self.character_map[char]
except KeyError: return random.choice(self.tiles[name]) if name in self.tiles else self.placeholder
raise MissingTileException(f'"{self}" does not contain a "{t}" tile.')
@property @property
def empty_space(self) -> Tile: def empty_space(self) -> Tile:
""" """
Return the Tile instance representing empty space. Return the Tile instance representing empty space.
""" """
return self.tiles[self.character_map[" "]] return self.get(" ")
def __str__(self): def __str__(self):
return f"[Tileset] {self.name}: {self.desc}" 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 A variant of the base Tile type that supports colorized console output
using ANSI color codes. using ANSI color codes.
""" """
color: str color: str
def __str__(self): def __str__(self):
@ -139,6 +146,7 @@ class ColorizedTileSet(TileSet):
""" """
A variant of the base TileSet type that uses ColorizedTiles. A variant of the base TileSet type that uses ColorizedTiles.
""" """
tiles: Dict[str, ColorizedTile] = {} 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 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. 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 buffer: Image = None
@property @property
@ -281,59 +291,49 @@ class ImageTileSet(TileSet):
Like base tiles, edge and corner tiles can have multiple variants and Like base tiles, edge and corner tiles can have multiple variants and
will be chosen at random when rendered. will be chosen at random when rendered.
""" """
image_dir: Path image_dir: Path
tile_size: int = 128 tile_size: int = 128
_cache = {} _tile_cache = {} # ImageTile data
_image_cache = {} # rendered image data
def _load_images(self, char: str, name: str): @cached_property
""" def paths(self) -> Dict[str, List[Path]]:
Load the images in the tile set corresponding to a terrain type in the paths = defaultdict(list)
character map, and organize them into ImageTiles, potentially with for imgfile in sorted(self.image_dir.glob("*.png")):
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}_*.*")):
(terrain_name, *parts) = imgfile.stem.rsplit("_") (terrain_name, *parts) = imgfile.stem.rsplit("_")
key = terrain_name key = terrain_name
if parts[0] in ("edge", "corner"): if parts[0] in ("edge", "corner"):
key = "_".join([terrain_name, *parts[:-1]]) key = "_".join([terrain_name, *parts[:-1]])
if not lastkey: paths[key].append(imgfile)
lastkey = key return paths
if lastkey != key:
self.add([self._get_or_create_tile(char=char, name=lastkey, paths=paths)]) @cached_property
lastkey = key def placeholder(self):
paths = [] buffer = Image.new("RGB", (self.tile_size, self.tile_size))
paths.append(imgfile) ImageDraw.Draw(buffer).text((3, 3), "?", (255, 255, 255))
if key: return buffer
self.add([self._get_or_create_tile(char=char, name=key, paths=paths)])
def load(self): def load(self):
""" """
Walk the characeter_map and load the images associated with it each Walk the character_map and load the images associated with each terrain type.
terrain type.
""" """
self.tiles = {} self.tiles = defaultdict(list)
for char, name in self.character_map.items(): for char, name in self.character_map.items():
self._load_images(char, name) if name not in self.paths:
raise MissingImageDataException(
def _get_or_create_tile(self, char: str, name: str, paths: List[Path]) -> ImageTile: f"The tile set does not contain any images for the '{char}' ({name}) terrain."
""" )
Load image data for the specified terrain type. If the expected image for path in self.paths[name]:
file cannot be found, generated a placeholder image. The resulting image key = f"{name}-{path.name}"
data is cached for reuse, making this method idempotent. if key not in self._tile_cache:
""" tile = ImageTile(char=char, name=name, buffer=Image.open(path))
if name in self._cache: self._tile_cache[key] = tile
return self._cache[name] self.add(tile)
buffer = None for name in self.paths:
if not paths: if name not in self.character_map.values():
buffer = Image.new("RGBA", (self.tile_size, self.tile_size)) logging.warn(f"{name} images exist but do not map to terrain types in the legend.")
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]
def _get_overlays(self, position: Position, adjacent: List[Position] = []) -> tuple: 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 Return a rendered image of the tile in the specified position, including any edge and
corner overlays implied by the tiles adjacent to it. corner overlays implied by the tiles adjacent to it.
""" """
return position.value.render( key = ":".join([str(position), *[str(a) for a in adjacent]])
self.tile_size, *[self._cache.get(overlay) for overlay in self._get_overlays(position, 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: class TileSetManager:
@ -386,6 +389,7 @@ class TileSetManager:
... ...
""" """
def __init__(self, config_dir: Path): def __init__(self, config_dir: Path):
self.config_dir = config_dir self.config_dir = config_dir
self._available = {} self._available = {}
@ -426,14 +430,13 @@ class TileSetManager:
@cached_property @cached_property
def console_map(self) -> TileSet: def console_map(self) -> TileSet:
ts = TileSet(name="console", desc="Tiles used for input and text rendering.") 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 return ts
@cached_property @cached_property
def console_map_colorized(self) -> ColorizedTileSet: def console_map_colorized(self) -> ColorizedTileSet:
ts = ColorizedTileSet(name="colorized", desc="Colorized ASCII.") ts = ColorizedTileSet(name="colorized", desc="Colorized ASCII.")
ts.add([ for key, value in ts.character_map.items():
ColorizedTile(char=key, name=value, color=COLOR_MAP.get(key, "grey")) ts.add(ColorizedTile(char=key, name=value, color=COLOR_MAP.get(key, "grey")))
for key, value in ts.character_map.items()
])
return ts return ts