331 lines
9.8 KiB
Python
331 lines
9.8 KiB
Python
#!/usr/bin/env python
|
|
#
|
|
# Copyright (c) 2014 Matthew Borgerson
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
# of this software and associated documentation files (the "Software"), to deal
|
|
# in the Software without restriction, including without limitation the rights
|
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
# copies of the Software, and to permit persons to whom the Software is
|
|
# furnished to do so, subject to the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be included in all
|
|
# copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
# SOFTWARE.
|
|
#
|
|
"""Texture Atlas and Map File Generation Utility Classes"""
|
|
|
|
import logging
|
|
from PIL import Image
|
|
import math
|
|
from typing import Dict, Tuple, List, Optional, Generator
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
DESCRIPTION = """Packs many smaller images into one larger image, a Texture
|
|
Atlas. A companion file (.map), is created that defines where each texture is
|
|
mapped in the atlas."""
|
|
|
|
|
|
class AtlasTooSmall(Exception):
|
|
pass
|
|
|
|
|
|
class Packable(object):
|
|
"""A two-dimensional object with position information."""
|
|
|
|
def __init__(self, width: int, height: int):
|
|
self._x = 0
|
|
self._y = 0
|
|
self._width = width
|
|
self._height = height
|
|
|
|
@property
|
|
def x(self) -> int:
|
|
return self._x
|
|
|
|
@x.setter
|
|
def x(self, value: int):
|
|
self._x = value
|
|
|
|
@property
|
|
def y(self) -> int:
|
|
return self._y
|
|
|
|
@y.setter
|
|
def y(self, value: int):
|
|
self._y = value
|
|
|
|
@property
|
|
def width(self) -> int:
|
|
return self._width
|
|
|
|
@property
|
|
def height(self) -> int:
|
|
return self._height
|
|
|
|
@property
|
|
def perimeter(self) -> int:
|
|
return 2 * self._width + 2 * self._height
|
|
|
|
|
|
class PackRegion(object):
|
|
"""A region that two-dimensional Packable objects can be packed into."""
|
|
|
|
def __init__(self, x: int, y: int, width: int, height: int):
|
|
"""Constructor."""
|
|
self._x = x
|
|
self._y = y
|
|
self._width = width
|
|
self._height = height
|
|
self._sub1: Optional[PackRegion] = None
|
|
self._sub2: Optional[PackRegion] = None
|
|
self._packable: Optional[Packable] = None
|
|
|
|
@property
|
|
def x(self) -> int:
|
|
return self._x
|
|
|
|
@property
|
|
def y(self) -> int:
|
|
return self._y
|
|
|
|
@property
|
|
def width(self) -> int:
|
|
return self._width
|
|
|
|
@property
|
|
def height(self) -> int:
|
|
return self._height
|
|
|
|
@property
|
|
def packable(self) -> Packable:
|
|
return self._packable
|
|
|
|
def get_all_packables(self):
|
|
"""Returns a list of all Packables in this region and sub-regions."""
|
|
if self._packable:
|
|
return (
|
|
[self._packable]
|
|
+ self._sub1.get_all_packables()
|
|
+ self._sub2.get_all_packables()
|
|
)
|
|
return []
|
|
|
|
def pack(self, packable: Packable, border: int):
|
|
"""Pack 2D packable into this region."""
|
|
if not self._packable:
|
|
# Is there room to pack this?
|
|
if (packable.width + border * 2 > self._width) or (
|
|
packable.height + border * 2 > self._height
|
|
):
|
|
return False
|
|
|
|
# Pack
|
|
self._packable = packable
|
|
|
|
# Set x, y on Packable
|
|
self._packable.x = self._x + border
|
|
self._packable.y = self._y + border
|
|
|
|
# Create sub-regions
|
|
self._sub1 = PackRegion(
|
|
self._x,
|
|
self._y + self._packable.height + border * 2,
|
|
self._packable.width + border * 2,
|
|
self._height - self._packable.height - border * 2,
|
|
)
|
|
self._sub2 = PackRegion(
|
|
self._x + self._packable.width + border * 2,
|
|
self._y,
|
|
self._width - self._packable.width - border * 2,
|
|
self._height,
|
|
)
|
|
return True
|
|
|
|
# Pack into sub-region
|
|
return self._sub1.pack(packable, border) or self._sub2.pack(packable, border)
|
|
|
|
|
|
class Frame(Packable):
|
|
"""An image file that can be packed into a PackRegion."""
|
|
|
|
def __init__(self, filename: str):
|
|
self._filename = filename
|
|
|
|
# Determine frame dimensions
|
|
image: Image.Image = Image.open(filename)
|
|
self._image: Image.Image = image.copy()
|
|
image.close()
|
|
|
|
width, height = self._image.size
|
|
|
|
super(Frame, self).__init__(width, height)
|
|
|
|
@property
|
|
def filename(self) -> str:
|
|
return self._filename
|
|
|
|
def draw(self, image: Image.Image, border: int):
|
|
"""Draw this frame into another Image."""
|
|
if border:
|
|
image.paste(
|
|
self._image.resize(tuple(s + border * 2 for s in self._image.size)),
|
|
(self.x - border, self.y - border),
|
|
)
|
|
image.paste(self._image, (self.x, self.y))
|
|
|
|
|
|
class Texture(object):
|
|
"""A collection of one or more frames."""
|
|
|
|
def __init__(self, name: str, frames: List[Frame]):
|
|
self._name = name
|
|
self._frames: List[Frame] = frames
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return self._name
|
|
|
|
@property
|
|
def frames(self) -> List[Frame]:
|
|
return self._frames
|
|
|
|
|
|
class TextureAtlas(PackRegion):
|
|
"""Texture Atlas generator."""
|
|
|
|
def __init__(self, width: int, height: int, border: int = 0):
|
|
super(TextureAtlas, self).__init__(0, 0, width, height)
|
|
self._textures: List[Texture] = []
|
|
self._border = border
|
|
|
|
@property
|
|
def textures(self) -> List[Texture]:
|
|
return self._textures
|
|
|
|
def pack(self, texture: Texture):
|
|
"""Pack a Texture into this atlas."""
|
|
self._textures.append(texture)
|
|
for frame in texture.frames:
|
|
if not super(TextureAtlas, self).pack(frame, self._border):
|
|
raise AtlasTooSmall("Failed to pack frame %s" % frame.filename)
|
|
|
|
def to_dict(self) -> Dict[str, Tuple[int, int, int, int]]:
|
|
return {
|
|
tex.name: (
|
|
tex.frames[0].x / self.width,
|
|
tex.frames[0].y / self.height,
|
|
(tex.frames[0].x + tex.frames[0].width) / self.width,
|
|
(tex.frames[0].y + min(tex.frames[0].height, tex.frames[0].width))
|
|
/ self.height,
|
|
)
|
|
for tex in self.textures
|
|
}
|
|
|
|
def generate(self, mode: str) -> Image.Image:
|
|
"""Generates the final texture atlas."""
|
|
out = Image.new(mode, (self.width, self.height))
|
|
for t in self._textures:
|
|
for f in t.frames:
|
|
f.draw(out, self._border)
|
|
return out
|
|
|
|
def write(self, filename: str, mode: str):
|
|
"""Generates and saves the final texture atlas."""
|
|
out = self.generate(mode)
|
|
out.save(filename)
|
|
|
|
|
|
class TextureAtlasMap(object):
|
|
"""Texture Atlas Map file generator."""
|
|
|
|
def __init__(self, atlas):
|
|
self._atlas = atlas
|
|
|
|
def write(self, fd):
|
|
"""Writes the texture atlas map file into file object fd."""
|
|
raise Exception("Not Implemented")
|
|
|
|
|
|
def create_atlas(
|
|
texture_tuple: Tuple[str, ...],
|
|
) -> Tuple[Image.Image, Dict[str, Tuple[float, float, float, float]]]:
|
|
atlas_iter = create_atlas_iter(texture_tuple)
|
|
try:
|
|
while True:
|
|
next(atlas_iter)
|
|
except StopIteration as e:
|
|
return e.value
|
|
|
|
|
|
def create_atlas_iter(texture_tuple: Tuple[str, ...]) -> Generator[
|
|
float,
|
|
None,
|
|
Tuple[Image.Image, Dict[str, Tuple[float, float, float, float]]],
|
|
]:
|
|
log.info("Creating texture atlas")
|
|
# Parse texture names
|
|
textures = []
|
|
for texture_index, texture in enumerate(texture_tuple):
|
|
if not texture_index % 100:
|
|
yield texture_index / (len(texture_tuple) * 2)
|
|
# Look for a texture name
|
|
name, frames = texture, [texture]
|
|
|
|
# Build frame objects
|
|
frames = [Frame(f) for f in frames]
|
|
|
|
# Add frames to texture object list
|
|
textures.append(Texture(name, frames))
|
|
|
|
# Sort textures by perimeter size in non-increasing order
|
|
textures = sorted(textures, key=lambda i: i.frames[0].perimeter, reverse=True)
|
|
|
|
height = 0
|
|
width = 0
|
|
pixels = 0
|
|
for t in textures:
|
|
for f in t.frames:
|
|
height = max(f.height, height)
|
|
width = max(f.width, width)
|
|
pixels += f.height * f.width
|
|
|
|
size = max(height, width, 1 << (math.ceil(pixels**0.5) - 1).bit_length())
|
|
|
|
atlas_created = False
|
|
atlas = None
|
|
while not atlas_created:
|
|
try:
|
|
# Create the atlas and pack textures in
|
|
log.info(f"Trying to pack textures into image of size {size}x{size}")
|
|
atlas = TextureAtlas(size, size)
|
|
|
|
for texture_index, texture in enumerate(textures):
|
|
if not texture_index % 30:
|
|
yield 0.5 + texture_index / (len(textures) / 2)
|
|
atlas.pack(texture)
|
|
atlas_created = True
|
|
except AtlasTooSmall:
|
|
log.info(f"Image was too small. Trying with a larger area")
|
|
size *= 2
|
|
|
|
log.info(f"Successfully packed textures into an image of size {size}x{size}")
|
|
|
|
texture_atlas = atlas.generate("RGBA")
|
|
|
|
texture_bounds = atlas.to_dict()
|
|
texture_bounds = {
|
|
texture_path: texture_bounds[texture_path] for texture_path in texture_tuple
|
|
}
|
|
|
|
log.info("Finished creating texture atlas")
|
|
return texture_atlas, texture_bounds
|