Source code for jasy.asset.SpritePacker

#
# Jasy - Web Tooling Framework
# Copyright 2010-2012 Zynga Inc.
# Copyright 2013-2014 Sebastian Werner
#

from jasy.asset.ImageInfo import ImgInfo
from jasy.asset.sprite.Block import Block
from jasy.asset.sprite.BlockPacker import BlockPacker
from jasy.asset.sprite.File import SpriteFile
from jasy.asset.sprite.Sheet import SpriteSheet
from jasy.core.Config import writeConfig

import jasy.core.Console as Console

import os
import itertools
import math


[docs]class PackerScore(): def __init__(self, sheets, external): self.sheets = sheets self.external = external # TODO choose quadratic over non?? self.sizes = ['%dx%dpx' % (s.width, s.height) for s in sheets] self.indexSize = sum([s.width / 128 + s.height / 128 for s in sheets]) # the total area used self.area = int(sum([s.area for s in sheets]) * 0.0001) self.exArea = sum([s.area for s in external]) * 0.0001 self.usedArea = int(sum([s.usedArea for s in sheets]) * 0.0001) self.count = len(sheets) # we only factor in left out images # if their size is less than 50% of the total spritesheet size we have right now # everything else is included as it would blow up the sheet way too much self.excount = len([i for i in external if i.w * i.h * 0.0001 < self.area * 0.5]) + 1 # Calculate in efficiency self.efficency = (100 / self.area) * self.usedArea self.value = self.efficency / (self.area * (self.excount * self.excount)) / (self.count ** self.count)
[docs] def data(self): return (self.sheets, self.external)
def __lt__(self, other): # Merge index sizes! if less images # Only go with bigger index size (n^2 more space taken) if we're score at least # 10% better if self.value > other.value * 1.1: return True # Otherwise sort against the index size elif self.value >= other.value: if self.indexSize < other.indexSize: return True elif self.indexSize == other.indexSize and self.sheets[0].width > other.sheets[0].width: return True else: return False else: if other.area == 1 and self.area > 1: return True return False def __gt__(self, other): return not self < other def __repr__(self): return '<PackerScore %d sheets #%d (%s) Area: %d Used: %d (%2.f%%) External: %d Count: %d Value: %2.5f>' % (self.count, self.indexSize, ', '.join(self.sizes), self.area, self.usedArea, self.efficency, self.excount - 1, self.count ** self.count, self.value)
[docs]class SpritePacker(): """Packs single images into sprite images automatically.""" def __init__(self, base, types=('png'), width=1024, height=1024): self.base = base self.files = [] self.types = types self.dataFormat = 'yaml'
[docs] def clear(self): """Removes all generated sprite files found in the base directory.""" Console.info("Cleaning sprite files...") Console.indent() for dirPath, dirNames, fileNames in os.walk(self.base): for fileName in fileNames: if fileName.startswith("jasysprite"): filePath = os.path.join(dirPath, fileName) Console.debug("Removing file: %s", filePath) os.remove(filePath) Console.outdent()
[docs] def addDir(self, directory, recursive=False): """Adds all images within a directory to the sprite packer.""" path = os.path.join(self.base, directory) if not os.path.exists(path): return if recursive: dirs = os.walk(path) else: dirs = [(os.path.join(self.base, directory), os.listdir(path), [])] # Iteratre over all directories for dirPath, dirNames, fileNames in dirs: Console.debug('Scanning directory for images: %s' % dirPath) # go through all dirs for dirName in dirNames: # Filter dotted directories like .git, .bzr, .hg, .svn, etc. if dirName.startswith("."): dirNames.remove(dirName) relDirPath = os.path.relpath(dirPath, path) # Add all the files within the dir for fileName in fileNames: if fileName[0] == "." or fileName.split('.')[-1] not in self.types or fileName.startswith('jasysprite'): continue relPath = os.path.normpath(os.path.join(relDirPath, fileName)).replace(os.sep, "/") fullPath = os.path.join(dirPath, fileName) self.addFile(relPath, fullPath)
[docs] def addFile(self, relPath, fullPath): """Adds the specific file to the sprite packer.""" fileType = relPath.split('.')[-1] if fileType not in self.types: raise Exception('Unsupported image format: %s' % fileType) # Load image and grab required information img = ImgInfo(fullPath) width, height = img.getSize() checksum = img.getChecksum() self.files.append(SpriteFile(width, height, relPath, fullPath, checksum)) Console.debug('- Found image "%s" (%dx%dpx)' % (relPath, width, height))
[docs] def packBest(self): """Pack blocks into a sprite sheet by trying multiple settings.""" sheets, extraBlocks = [], [] score = 0 best = { 'score': 0, 'area': 10000000000000000000, 'count': 10000000000000, 'eff': 0 } # Sort Functions def sortHeight(block): return (block.w, block.h, block.image.checksum) def sortWidth(block): return (block.h, block.w, block.image.checksum) def sortArea(block): return (block.w * block.h, block.w, block.h, block.image.checksum) sorts = [sortHeight, sortWidth, sortArea] # Determine minimum size for spritesheet generation # by averaging the widths and heights of all images # while taking the ones in the sorted middile higher into account # then the ones at the outer edges of the spectirum l = len(self.files) mw = [(l - abs(i - l / 2)) / l * v for i, v in enumerate(sorted([i.width for i in self.files]))] mh = [(l - abs(i - l / 2)) / l * v for i, v in enumerate(sorted([i.height for i in self.files]))] minWidth = max(128, math.pow(2, math.ceil(math.log(sum(mw) / l, 2)))) minHeight = max(128, math.pow(2, math.ceil(math.log(sum(mh) / l, 2)))) # try to skip senseless generation of way to small sprites baseArea = sum([minWidth * minHeight for i in self.files]) while baseArea / (minWidth * minHeight) >= 20: # basically an estimate of the number of sheets needed minWidth *= 2 minHeight *= 2 Console.debug('Minimal size is %dx%dpx' % (minWidth, minHeight)) sizes = list(itertools.product([w for w in [128, 256, 512, 1024, 2048] if w >= minWidth], [h for h in [128, 256, 512, 1024, 2048] if h >= minHeight])) methods = list(itertools.product(sorts, sizes)) Console.debug('Packing sprite sheet variants...') Console.indent() scores = [] for sort, size in methods: # pack with current settings sh, ex, _ = self.pack(size[0], size[1], sort, silent=True) if len(sh): score = PackerScore(sh, ex) # Determine score, highest wins scores.append(score) else: Console.debug('No sprite sheets generated, no image fit into the sheet') Console.outdent() scores.sort() Console.debug('Generated the following sheets:') for i in scores: Console.debug('- ' + str(i)) sheets, external = scores[0].data() if external: for block in external: Console.info('Ignored file %s (%dx%dpx)' % (block.image.relPath, block.w, block.h)) return sheets, len(scores)
[docs] def pack(self, width=1024, height=1024, sort=None, silent=False): """Packs all sprites within the pack into sheets of the given size.""" Console.debug('Packing %d images...' % len(self.files)) allBlocks = [] duplicateCount = 0 checkBlocks = {} for f in self.files: f.block = None if not f.checksum in checkBlocks: checkBlocks[f.checksum] = f.block = Block(f.width, f.height, f) allBlocks.append(f.block) else: src = checkBlocks[f.checksum] Console.debug('Detected duplicate of "%s" (using "%s" as reference)' % (f.relPath, src.image.relPath)) src.duplicates.append(f) duplicateCount += 1 f.block = checkBlocks[f.checksum] Console.debug('Found %d unique blocks (mapping %d duplicates)' % (len(allBlocks), duplicateCount)) # Sort Functions def sortHeight(img): return (img.w, img.h) def sortWidth(img): return (img.w, img.h) def sortArea(img): return (img.w * img.h, img.w, img.h) # Filter out blocks which are too big blocks = [] extraBlocks = [] for b in allBlocks: if b.w > width or b.h > height: extraBlocks.append(b) else: blocks.append(b) sheets = [] fitted = 0 while len(blocks): Console.debug('Sorting %d blocks...' % len(blocks)) Console.indent() sortedSprites = sorted(blocks, key=sort if sort is not None else sortHeight) sortedSprites.reverse() # Pack stuff packer = BlockPacker(width, height) packer.fit(sortedSprites) # Filter fit vs non-fit blocks blocks = [s for s in sortedSprites if not s.fit] fitBlocks = [s for s in sortedSprites if s.fit] fitted += len(fitBlocks) # Create a new sprite sheet with the given blocks if len(fitBlocks) > 1: sheet = SpriteSheet(packer, fitBlocks) sheets.append(sheet) Console.debug('Created new sprite sheet (%dx%dpx, %d%% used)' % (sheet.width, sheet.height, sheet.used)) else: Console.debug('Only one image fit into sheet, ignoring.') extraBlocks.append(fitBlocks[0]) Console.outdent() Console.debug('Packed %d images into %d sheets. %d images were found to be too big and did not fit.' % (fitted, len(sheets), len(extraBlocks))) return (sheets, extraBlocks, 0)
[docs] def setDataFormat(self, format='yaml'): """Sets format for metadata output - for format support take a look at jasy.core.Config.""" self.dataFormat = format
[docs] def generate(self, path='', debug=False): """Generate sheets/variants.""" Console.info('Generating sprite sheet variants...') Console.indent() sheets, count = self.packBest() # Write PNG files data = {} for pos, sheet in enumerate(sheets): Console.info('Writing image (%dx%dpx) with %d images' % (sheet.width, sheet.height, len(sheet))) name = 'jasysprite_%d.png' % pos # Export sheet.write(os.path.join(self.base, path, name), debug) data[name] = sheet.export() Console.outdent() # Generate config file Console.info('Exporting data...') script = os.path.join(self.base, path, 'jasysprite.%s' % self.dataFormat) writeConfig(data, script)
[docs] def packDir(self, path='', recursive=True, debug=False): """Pack images inside a dir into sprite sheets.""" Console.info('Packing sprites in: %s' % os.path.join(self.base, path)) Console.indent() self.files = [] self.addDir(path, recursive=recursive) Console.info('Found %d images' % len(self.files)) if len(self.files) > 0: self.generate(path, debug) Console.outdent()