From 2cd340d29429e7b9396d7db3d694a5f1eea2c091 Mon Sep 17 00:00:00 2001 From: Karakas Zlatko Date: Tue, 6 Jan 2026 21:03:54 +0100 Subject: [PATCH] feat: add image scaling, duplicate detection, and atlas cropping options - Duplicate Detection: Implement detect_duplicates to identify pixel-identical sprites and alias them in the output configuration, saving texture space. - Image Scaling: Add scale and scale_filter options to resize input images using PIL filters (e.g., BICUBIC, NEAREST) before packing. - Atlas Optimization: Add crop_atlas to trim the final texture to the actual content size and ignore_blank to exclude fully transparent images. - Debugging: Add draw_border option to visualize sprite boundaries in the generated atlas. - API Enhancements: Update pack() and multi_pack() to return atlas data and add support for post_process_routine hooks. - Refactoring: Centralize path extraction logic in MaxRectsAtlas and update documentation for new parameters. --- PyTexturePacker/ImageRect.py | 23 ++++ .../MaxRectsPacker/MaxRectsAtlas.py | 3 + .../MaxRectsPacker/MaxRectsPacker.py | 3 +- .../PackerInterface/AtlasInterface.py | 58 +++++++-- .../PackerInterface/PackerInterface.py | 116 ++++++++++++++++-- README.rst | 31 ++++- docs/2_options.rst | 35 +++++- tests/TestImageRect.py | 4 + tests/TestPacker.py | 80 ++++++++++++ 9 files changed, 323 insertions(+), 30 deletions(-) create mode 100644 tests/TestPacker.py diff --git a/PyTexturePacker/ImageRect.py b/PyTexturePacker/ImageRect.py index 9699513..eef344e 100644 --- a/PyTexturePacker/ImageRect.py +++ b/PyTexturePacker/ImageRect.py @@ -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() @@ -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 @@ -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") diff --git a/PyTexturePacker/MaxRectsPacker/MaxRectsAtlas.py b/PyTexturePacker/MaxRectsPacker/MaxRectsAtlas.py index 0c3c52e..b15852b 100644 --- a/PyTexturePacker/MaxRectsPacker/MaxRectsAtlas.py +++ b/PyTexturePacker/MaxRectsPacker/MaxRectsAtlas.py @@ -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 diff --git a/PyTexturePacker/MaxRectsPacker/MaxRectsPacker.py b/PyTexturePacker/MaxRectsPacker/MaxRectsPacker.py index a677ff4..f379c4d 100644 --- a/PyTexturePacker/MaxRectsPacker/MaxRectsPacker.py +++ b/PyTexturePacker/MaxRectsPacker/MaxRectsPacker.py @@ -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 diff --git a/PyTexturePacker/PackerInterface/AtlasInterface.py b/PyTexturePacker/PackerInterface/AtlasInterface.py index af0beaf..b57ce08 100644 --- a/PyTexturePacker/PackerInterface/AtlasInterface.py +++ b/PyTexturePacker/PackerInterface/AtlasInterface.py @@ -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) @@ -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 = {} @@ -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( @@ -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( @@ -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 diff --git a/PyTexturePacker/PackerInterface/PackerInterface.py b/PyTexturePacker/PackerInterface/PackerInterface.py index a9791b6..4053ef9 100644 --- a/PyTexturePacker/PackerInterface/PackerInterface.py +++ b/PyTexturePacker/PackerInterface/PackerInterface.py @@ -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): @@ -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. @@ -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 @@ -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): @@ -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: @@ -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: @@ -153,21 +218,24 @@ 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)): @@ -175,6 +243,16 @@ def pack(self, input_images, output_name, output_path="", input_base_path=None): 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) @@ -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 @@ -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 diff --git a/README.rst b/README.rst index d0c4c1b..ab87ff0 100644 --- a/README.rst +++ b/README.rst @@ -139,7 +139,7 @@ There are two uses for this: atlas_format ------------ -Choose the texture config format that file will use. Available options "plist", "json" and "csv". Aditionally, you can use a custom function that will receive a dictionary and a path and handle the format in some custom way. +Choose the texture config format that file will use. Available options "plist", "json" and "csv". Additionally, you can use a custom function that will receive a dictionary and a path and handle the format in some custom way. The default texture config output format is "plist". atlas_ext @@ -148,6 +148,35 @@ atlas_ext Forces the atlas to use this extension regardless of the format. If not provided, the atlas will use the default extension for the chosen format. +detect_duplicates +----------------- + +Detects duplicate sprites (pixel-identical) and stores them only once in the texture but multiple times in the output config file. +They will reference the same pixels as the original sprites. + +ignore_blank +------------ + +Blank images (completely transparent) will not be added to the texture. + +draw_border +----------- + +Draws a border around the sprites in the final texture. Useful for debugging purposes. + +scale +----- +Scale factor to apply to input images before packing. + +scale_filter +------------ +Scaling algorithm to use when scaling input images. Possible values are NEAREST, BOX, BILINEAR, HAMMING, BICUBIC, LANZCOS. +They correspond to Pillow filters. Default is BICUBIC, or NEAREST for images with 256 or less colors. + +crop_atlas +---------- +Crop resulting texture atlas to the actual content size. + Contribute ========== diff --git a/docs/2_options.rst b/docs/2_options.rst index 6a38d57..bf69185 100644 --- a/docs/2_options.rst +++ b/docs/2_options.rst @@ -88,11 +88,42 @@ There are two uses for this: atlas_format ------- -Choose the texture config format that file will use. Available options "plist", "json" and "csv". Aditionally, you can use a custom function that will receive a dictionary and a path and handle the format in some custom way. +Choose the texture config format that file will use. Available options "plist", "json" and "csv". Additionally, you can use a custom function that will receive a dictionary and a path and handle the format in some custom way. The default texture config output format is "plist". atlas_ext ------- Forces the atlas to use this extension regardless of the format. -If not provided, the atlas will use the default extension for the chosen format. \ No newline at end of file +If not provided, the atlas will use the default extension for the chosen format. + +detect_duplicates +----------------- + +Detects duplicate sprites (pixel-identical) and stores them only once in the texture but multiple times in the +output config file. They will reference the same pixels of the original sprites. + +ignore_blank +------------ + +Blank images (completely transparent) will not be added to the texture. + +draw_border +----------- + +Draws a border around the sprites in the final texture. Useful for debugging purposes. + +scale +----- +Scale factor to apply to input images before packing. + +scale_filter +------------ +Scaling algorithm to use when scaling input images. Possible values are NEAREST, BOX, BILINEAR, HAMMING, +BICUBIC, LANZCOS. They correspond to Pillow filters. Default is BICUBIC, or NEAREST for images with 256 or +less colors. + +crop_atlas +---------- + +Crop resulting texture atlas to the actual content size. diff --git a/tests/TestImageRect.py b/tests/TestImageRect.py index e5b6c4f..c2fc447 100644 --- a/tests/TestImageRect.py +++ b/tests/TestImageRect.py @@ -49,6 +49,10 @@ def test_clone(self): clone_object = self.test_object.clone() self.check_equal(clone_object) + def test_eq(self): + clone_object = self.test_object.clone() + self.assertEqual(self.test_object, clone_object) + def test_properties(self): self.test_object = ImageRect.ImageRect(TEST_IMAGE_PATH) diff --git a/tests/TestPacker.py b/tests/TestPacker.py new file mode 100644 index 0000000..fa5934b --- /dev/null +++ b/tests/TestPacker.py @@ -0,0 +1,80 @@ +import os +import unittest +from unittest.mock import Mock + +from PIL import Image +from PyTexturePacker import Packer, Utils +from PyTexturePacker.ImageRect import ImageRect + + +# char image '!' +TEST_IMAGE_PATH = "test_image/33.png" +TEST_IMAGE_PATH2 = "test_image/129.png" +EMPTY_IMAGE_PATH = "test_image/32.png" +TEST_IMAGE_FILENAME = os.path.split(TEST_IMAGE_PATH)[1] + + +class TestPacker(unittest.TestCase): + def setUp(self): + self.save_image_mock = Mock() + Utils.save_image = self.save_image_mock + self.input_images = [] + Utils.load_images_from_dir = self._return_images + + def tearDown(self): + self.save_image_mock.assert_called_once() + self.assertEqual(len(self.atlas_data), 1) + self.assertEqual(self.atlas_data[0]['meta']['image'], 'charset0.png') + + def _return_images(self, dir_path=None): + return self.input_images + + def _pack(self, detect_duplicates=False, ignore_blank=False, scale=None, scale_filter=None): + packer = Packer.create(max_width=2048, max_height=2048, border_padding=0, shape_padding=0, + trim_mode=100, detect_duplicates=detect_duplicates, ignore_blank=ignore_blank, scale=scale, + scale_filter=scale_filter, atlas_format=Utils.ATLAS_FORMAT_JSON) + + self.atlas_data = packer.pack("$$$", "charset%d", output_path="output", save_atlas_data=False) + + def test_detect_duplicates(self): + image = ImageRect(TEST_IMAGE_PATH) + image2 = image.clone() + image3 = image.clone() + image2.image_path = image2.image_path.replace('33', '34') + image3.image_path = image3.image_path.replace('33', '35') + self.input_images = [image, image2, image3] + + self._pack(detect_duplicates=True) + + self.assertEqual(len(self.atlas_data[0]['frames']), 3) + + original = self.atlas_data[0]['frames'][TEST_IMAGE_FILENAME] + + for frame in self.atlas_data[0]['frames'].values(): + self.assertEqual(frame, original) + + def test_ignore_blank(self): + empty = ImageRect(EMPTY_IMAGE_PATH) + emptyRgba = empty.clone() + emptyRgba.image = emptyRgba.image.convert('RGBA') + self.input_images = [empty, ImageRect(TEST_IMAGE_PATH), emptyRgba] + + self._pack(ignore_blank=True) + + self.assertEqual(len(self.atlas_data[0]['frames']), 1) + self.assertIn(TEST_IMAGE_FILENAME, self.atlas_data[0]['frames']) + + def test_scale(self): + import plistlib + + self.input_images = [ImageRect(TEST_IMAGE_PATH), ImageRect(TEST_IMAGE_PATH2)] + original_images = list(map(lambda rect_image: (rect_image.image_path, rect_image.image.copy()), self.input_images)) + + self._pack(scale=0.5, scale_filter='LANCZOS') + + self.assertEqual(len(self.atlas_data[0]['frames']), 2) + for path, image in original_images: + filename = os.path.split(path)[1] + size = self.atlas_data[0]['frames'][filename]['sourceSize'] + self.assertEqual(size['w'], round(image.size[0] / 2)) + self.assertEqual(size['h'], round(image.size[1] / 2))