Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions PyTexturePacker/ImageRect.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ def bbox(self):
else:
return tuple(0, 0, self.width, self.height)

@property
def hash(self):
import hashlib

md5hash = hashlib.md5(self.image.tobytes())
return md5hash.hexdigest()

def load_image(self, image_path):
image = Image.open(image_path)
self.image = image.copy()
Expand All @@ -69,6 +76,14 @@ def load_image(self, image_path):
self._rotated = False
self._trimmed = False

def scale(self, scale, scale_filter):
size = tuple(round(scale * dim) for dim in self.image.size)
self.image = self.image.resize(size, scale_filter)

self.width, self.height = self.image.size
self.source_size = self.image.size
self.source_box = (0, 0, self.width, self.height)

def rotate(self):
self._rotated = not self._rotated

Expand Down Expand Up @@ -114,6 +129,14 @@ def clone(self):
tmp._extrude_size = self._extrude_size
return tmp

def __eq__(self, other):
if self.image == other.image:
return True
elif self.image and other.image:
return self.image.tobytes() == other.image.tobytes()
else:
return (not self.image) == (not other.image)


def main():
img_rect = ImageRect("test.jpg")
Expand Down
3 changes: 3 additions & 0 deletions PyTexturePacker/MaxRectsPacker/MaxRectsAtlas.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ def place_image_rect(self, rect_index, image_rect):
image_rect.x, image_rect.y = rect.x + \
self.inner_padding, rect.y + self.inner_padding

image_rect.x = max(image_rect.x, self.border_padding + self.inner_padding)
image_rect.y = max(image_rect.y, self.border_padding + self.inner_padding)

fake_image_rect = image_rect.clone()
fake_image_rect.left -= self.inner_padding
fake_image_rect.right += self.inner_padding + self.shape_padding
Expand Down
3 changes: 2 additions & 1 deletion PyTexturePacker/MaxRectsPacker/MaxRectsPacker.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ def _pack(self, image_rect_list):
force_square=self.force_square,
border_padding=self.border_padding,
shape_padding=self.shape_padding,
inner_padding=self.inner_padding
inner_padding=self.inner_padding,
crop_to_content=self.crop_atlas
)
)
best_atlas = len(atlas_list) - 1
Expand Down
58 changes: 45 additions & 13 deletions PyTexturePacker/PackerInterface/AtlasInterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ class AtlasInterface(object):
"""

def __init__(self, width=1, height=1, max_width=MAX_WIDTH, max_height=MAX_HEIGHT,
force_square=False, border_padding=0, shape_padding=0, inner_padding=0):
force_square=False, border_padding=0, shape_padding=0, inner_padding=0,
crop_to_content=False):
if force_square:
width = height = max(width, height)
max_width = max_height = max(max_width, max_height)
Expand All @@ -35,12 +36,22 @@ def __init__(self, width=1, height=1, max_width=MAX_WIDTH, max_height=MAX_HEIGHT
self.inner_padding = inner_padding

self.force_square = force_square
self.crop_to_content = crop_to_content

self.image_rect_list = []

def dump_plist(self, texture_file_name="", input_base_path=None, atlas_format=ATLAS_FORMAT_PLIST):
@staticmethod
def _extract_path(path, input_base_path):
import os

if input_base_path is None:
_, path = os.path.split(path)
else:
path = os.path.relpath(os.path.abspath(path), os.path.abspath(input_base_path))

return path

def dump_plist(self, aliases, texture_file_name="", input_base_path=None, atlas_format=ATLAS_FORMAT_PLIST):
plist_data = {}

frames = {}
Expand All @@ -53,12 +64,7 @@ def dump_plist(self, texture_file_name="", input_base_path=None, atlas_format=AT
center_offset = (image_rect.source_box[0] + width / 2. - image_rect.source_size[0] / 2.,
- (image_rect.source_box[1] + height / 2. - image_rect.source_size[1] / 2.))

path = image_rect.image_path
if input_base_path is None:
_, path = os.path.split(path)
else:
path = os.path.relpath(os.path.abspath(
path), os.path.abspath(input_base_path))
path = self._extract_path(image_rect.image_path, input_base_path)

if atlas_format == ATLAS_FORMAT_PLIST:
frames[path] = dict(
Expand All @@ -83,6 +89,11 @@ def dump_plist(self, texture_file_name="", input_base_path=None, atlas_format=AT
w=image_rect.source_size[0], h=image_rect.source_size[1])
)

alias_list = aliases.get(id(image_rect), [])
for alias_path in alias_list:
alias_path = self._extract_path(alias_path, input_base_path)
frames[alias_path] = frames[path]

plist_data["frames"] = frames
if atlas_format == ATLAS_FORMAT_PLIST:
plist_data["metadata"] = dict(
Expand All @@ -101,15 +112,36 @@ def dump_plist(self, texture_file_name="", input_base_path=None, atlas_format=AT

return plist_data

def dump_image(self, bg_color=0xffffffff):
from PIL import Image
packed_image = Image.new('RGBA', self.size, bg_color)
def _get_cropped_size(self):
max_x = 0
max_y = 0

for image_rect in self.image_rect_list:
max_x = max(max_x, image_rect.right)
max_y = max(max_y, image_rect.bottom)

return max_x + self.border_padding + self.inner_padding, max_y + self.border_padding + self.inner_padding

def dump_image(self, bg_color=0xffffffff, draw_border=False):
from PIL import Image, ImageDraw

size = self.size
if self.crop_to_content:
size = self._get_cropped_size()

packed_image = Image.new('RGBA', size, bg_color)

if draw_border:
draw = ImageDraw.Draw(packed_image)

for image_rect in self.image_rect_list:
image = image_rect.image.crop()
if image_rect.rotated:
image = image.transpose(Image.ROTATE_270)
packed_image.paste(
image, (image_rect.left, image_rect.top, image_rect.right, image_rect.bottom))

dest_rect = (image_rect.left, image_rect.top, image_rect.right, image_rect.bottom)
packed_image.paste(image, dest_rect)
if draw_border:
draw.rectangle(dest_rect, outline=(255, 0, 0, 255))

return packed_image
116 changes: 103 additions & 13 deletions PyTexturePacker/PackerInterface/PackerInterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ def multi_pack_handler(args):
packer, args = args

if isinstance(args, (list, tuple)):
packer.pack(*args)
return packer.pack(*args)
elif isinstance(args, dict):
packer.pack(**args)
return packer.pack(**args)


class PackerInterface(object):
Expand All @@ -34,7 +34,9 @@ class PackerInterface(object):

def __init__(self, bg_color=0x00000000, texture_format=".png", max_width=4096, max_height=4096, enable_rotated=True,
force_square=False, border_padding=2, shape_padding=2, inner_padding=0, trim_mode=0,
reduce_border_artifacts=False, extrude=0, atlas_format=Utils.ATLAS_FORMAT_PLIST, atlas_ext=None):
reduce_border_artifacts=False, extrude=0, atlas_format=Utils.ATLAS_FORMAT_PLIST,
atlas_ext=None, detect_duplicates=False, ignore_blank=False, draw_border=False, scale=None,
scale_filter='BICUBIC', crop_atlas=False):
"""
init a packer
:param bg_color: background color of output image.
Expand All @@ -50,6 +52,13 @@ def __init__(self, bg_color=0x00000000, texture_format=".png", max_width=4096, m
:param reduce_border_artifacts: adds color to transparent pixels by repeating a sprite's outer color values
:param extrude: extrude repeats the sprite's pixels at the border. Sprite's size is not changed.
:param atlas_format: texture config output type: 'plist' or 'json'. default: 'plist'
:param detect_duplicates: detect and remove duplicate sprites
:param ignore_blank: blank images (completely transparent) will not be added to the texture
:param draw_border: draw border around sprites for debugging purposes
:param scale: scale factor to apply to input images before packing
:param scale_filter: scaling algorithm to use when scaling input images
:param crop_atlas: crop resulting texture atlas to the actual content size

"""

self.bg_color = bg_color
Expand All @@ -66,6 +75,26 @@ def __init__(self, bg_color=0x00000000, texture_format=".png", max_width=4096, m
self.reduce_border_artifacts = reduce_border_artifacts
self.atlas_format = atlas_format
self.atlas_ext = atlas_ext
self.detect_duplicates = detect_duplicates
self.ignore_blank = ignore_blank
self.draw_border = draw_border
self.scale = scale
if self.scale:
self.scale_filter = self._get_scale_filter(scale_filter)
self.crop_atlas = crop_atlas

@staticmethod
def _get_scale_filter(scale_filter):
from PIL import Image

FILTERS = { 'NEAREST': Image.NEAREST, 'BOX': Image.BOX, 'BILINEAR': Image.BILINEAR,
'HAMMING': Image.HAMMING, 'BICUBIC': Image.BICUBIC, 'LANCZOS': Image.LANCZOS }

filter = FILTERS.get(scale_filter, None)
if not filter:
raise ValueError(f'Unknown scale filter: {scale_filter}')

return filter

@staticmethod
def _calculate_area(image_rect_list, inner_padding):
Expand Down Expand Up @@ -118,6 +147,41 @@ def _cal_init_size(area, min_width, min_height, max_width, max_height):
else:
return tuple((short, long))

@staticmethod
def _detect_duplicates(image_rect_list):
from collections import defaultdict

previous_instances = defaultdict(list)
aliases = defaultdict(list)

def _is_unique(image_rect):
instances = previous_instances[image_rect.hash]
try:
original = next(instance for instance in instances)
aliases[id(original)].append(image_rect.image_path)
return False
except StopIteration:
instances.append(image_rect)
return True

image_rect_list = list(filter(_is_unique, image_rect_list))
return image_rect_list, aliases

@staticmethod
def _rescale_images(image_rects, scale, scale_filter):
for image_rect in image_rects:
image_rect.scale(scale, scale_filter)

def _remove_blank(self, image_rect_list):
def _is_non_empty_image(image_rect):
image = image_rect.image
colors = image.getcolors(1)
if not colors:
return True
return colors[0][1] != self.bg_color and (image.mode != 'RGBA' or colors[0][1][3])

return list(filter(_is_non_empty_image, image_rect_list))

def _init_atlas_list(self, image_rect_list):
min_width, min_height = 0, 0
for image_rect in image_rect_list:
Expand All @@ -144,7 +208,8 @@ def _init_atlas_list(self, image_rect_list):

atlas_list.append(self.ATLAS_TYPE(w, h, self.max_width, self.max_height,
force_square=self.force_square, border_padding=self.border_padding,
shape_padding=self.shape_padding, inner_padding=self.inner_padding))
shape_padding=self.shape_padding, inner_padding=self.inner_padding,
crop_to_content=self.crop_atlas))

area = area - w * h
while area > 0:
Expand All @@ -153,28 +218,41 @@ def _init_atlas_list(self, image_rect_list):
area = area - w * h
atlas_list.append(self.ATLAS_TYPE(w, h, self.max_width, self.max_height,
force_square=self.force_square, border_padding=self.border_padding,
shape_padding=self.shape_padding, inner_padding=self.inner_padding))
shape_padding=self.shape_padding, inner_padding=self.inner_padding,
crop_to_content=self.crop_atlas))

return atlas_list

def _pack(self, image_rect_list):
raise NotImplementedError

def pack(self, input_images, output_name, output_path="", input_base_path=None):
def pack(self, input_images, output_name, output_path="", input_base_path=None, post_process_routine=None, save_atlas_data=True):
"""
pack the input images to sheets
:param input_images: a list of input image paths or a input dir path
:param output_name: the output file name
:param output_path: the output file path
:param input_base_path: the base path of input files
:return:
:param post_process_routine: routine to post-process atlas image(s), input: image, atlas data, output: new image
:param save_atlas_data: save atlas data to disk if true
:return: list of atlas data
"""

if isinstance(input_images, (tuple, list)):
image_rects = Utils.load_images_from_paths(input_images)
else:
image_rects = Utils.load_images_from_dir(input_images)

if self.ignore_blank:
image_rects = self._remove_blank(image_rects)

aliases = {}
if self.detect_duplicates:
image_rects, aliases = self._detect_duplicates(image_rects)

if self.scale:
self._rescale_images(image_rects, self.scale, self.scale_filter)

if self.trim_mode:
for image_rect in image_rects:
image_rect.trim(self.trim_mode)
Expand All @@ -188,23 +266,33 @@ def pack(self, input_images, output_name, output_path="", input_base_path=None):
assert "%d" in output_name or len(
atlas_list) == 1, 'more than one output image, but no "%d" in output_name'

atlas_data = []

for i, atlas in enumerate(atlas_list):
texture_file_name = output_name if "%d" not in output_name else output_name % i

packed_plist = atlas.dump_plist("%s%s" % (texture_file_name, self.texture_format), input_base_path,
self.atlas_format)
packed_image = atlas.dump_image(self.bg_color)
packed_plist = atlas.dump_plist(aliases, "%s%s" % (texture_file_name, self.texture_format),
input_base_path, self.atlas_format)
packed_image = atlas.dump_image(self.bg_color, self.draw_border)

if self.reduce_border_artifacts:
packed_image = Utils.alpha_bleeding(packed_image)

if post_process_routine:
packed_image = post_process_routine(packed_image, packed_plist)

atlas_data_ext = self.atlas_ext or Utils.get_atlas_data_ext(
self.atlas_format)
Utils.save_atlas_data(packed_plist, os.path.join(output_path, "%s%s" % (texture_file_name, atlas_data_ext)),
self.atlas_format)
if save_atlas_data:
Utils.save_atlas_data(packed_plist, os.path.join(output_path, "%s%s" % (texture_file_name, atlas_data_ext)),
self.atlas_format)
Utils.save_image(packed_image, os.path.join(
output_path, "%s%s" % (texture_file_name, self.texture_format)))

atlas_data.append(packed_plist)

return atlas_data

def multi_pack(self, pack_args_list):
"""
pack with multiprocessing
Expand All @@ -217,7 +305,9 @@ def multi_pack(self, pack_args_list):
pool_size = multiprocessing.cpu_count() * 2
pool = multiprocessing.Pool(processes=pool_size)

pool.map(multi_pack_handler, zip(
result = pool.map(multi_pack_handler, zip(
[self] * len(pack_args_list), pack_args_list))
pool.close()
pool.join()

return result
Loading