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:
parent
78785fbea5
commit
4b02cc4c9d
|
@ -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 = ""
|
||||||
|
|
|
@ -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."),
|
||||||
):
|
):
|
||||||
|
@ -57,10 +56,10 @@ def inspect(source: Path = typer.Argument(help="The battle map text file to laod
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
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}")
|
||||||
|
|
||||||
|
|
|
@ -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)):
|
||||||
|
|
|
@ -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
|
||||||
|
@ -193,7 +203,7 @@ class ImageTile(Tile):
|
||||||
if not size:
|
if not size:
|
||||||
size = self.image.width
|
size = self.image.width
|
||||||
rendered = self.image.copy().resize((size, size))
|
rendered = self.image.copy().resize((size, size))
|
||||||
border_size = int(size/4)
|
border_size = int(size / 4)
|
||||||
|
|
||||||
if nw:
|
if nw:
|
||||||
rendered.paste(nw.image, (0, 0), nw.image)
|
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
|
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
|
||||||
|
|
Loading…
Reference in New Issue
Block a user