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 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 = ""

View File

@ -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}")

View File

@ -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)):

View File

@ -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