Stav 23.06.2026
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
'''
|
||||
Atlas
|
||||
=====
|
||||
|
||||
.. versionadded:: 1.1.0
|
||||
|
||||
Atlas manages texture atlases: packing multiple textures into
|
||||
one. With it, you reduce the number of images loaded and speedup the
|
||||
application loading. This module contains both the Atlas class and command line
|
||||
processing for creating an atlas from a set of individual PNG files. The
|
||||
command line section requires the Pillow library, or the defunct Python Imaging
|
||||
Library (PIL), to be installed.
|
||||
|
||||
An Atlas is composed of 2 or more files:
|
||||
- a json file (.atlas) that contains the image file names and texture
|
||||
locations of the atlas.
|
||||
- one or multiple image files containing textures referenced by the .atlas
|
||||
file.
|
||||
|
||||
Definition of .atlas files
|
||||
--------------------------
|
||||
|
||||
A file with ``<basename>.atlas`` is a json file formatted like this::
|
||||
|
||||
{
|
||||
"<basename>-<index>.png": {
|
||||
"id1": [ <x>, <y>, <width>, <height> ],
|
||||
"id2": [ <x>, <y>, <width>, <height> ],
|
||||
# ...
|
||||
},
|
||||
# ...
|
||||
}
|
||||
|
||||
Example from the Kivy ``data/images/defaulttheme.atlas``::
|
||||
|
||||
{
|
||||
"defaulttheme-0.png": {
|
||||
"progressbar_background": [431, 224, 59, 24],
|
||||
"image-missing": [253, 344, 48, 48],
|
||||
"filechooser_selected": [1, 207, 118, 118],
|
||||
"bubble_btn": [83, 174, 32, 32],
|
||||
# ... and more ...
|
||||
}
|
||||
}
|
||||
|
||||
In this example, "defaulttheme-0.png" is a large image, with the pixels in the
|
||||
rectangle from (431, 224) to (431 + 59, 224 + 24) usable as
|
||||
``atlas://data/images/defaulttheme/progressbar_background`` in
|
||||
any image parameter.
|
||||
|
||||
How to create an Atlas
|
||||
----------------------
|
||||
|
||||
.. warning::
|
||||
|
||||
The atlas creation requires the Pillow library (or the defunct Imaging/PIL
|
||||
library). This requirement will be removed in the future when the Kivy core
|
||||
Image is able to support loading, blitting, and saving operations.
|
||||
|
||||
You can directly use this module to create atlas files with this command::
|
||||
|
||||
$ python -m kivy.atlas <basename> <size> <list of images...>
|
||||
|
||||
|
||||
Let's say you have a list of images that you want to put into an Atlas. The
|
||||
directory is named ``images`` with lots of 64x64 png files inside::
|
||||
|
||||
$ ls
|
||||
images
|
||||
$ cd images
|
||||
$ ls
|
||||
bubble.png bubble-red.png button.png button-down.png
|
||||
|
||||
You can combine all the png's into one and generate the atlas file with::
|
||||
|
||||
$ python -m kivy.atlas myatlas 256x256 *.png
|
||||
Atlas created at myatlas.atlas
|
||||
1 image has been created
|
||||
$ ls
|
||||
bubble.png bubble-red.png button.png button-down.png myatlas.atlas
|
||||
myatlas-0.png
|
||||
|
||||
As you can see, we get 2 new files: ``myatlas.atlas`` and ``myatlas-0.png``.
|
||||
``myatlas-0.png`` is a new 256x256 .png composed of all your images. If the
|
||||
size you specify is not large enough to fit all of the source images, more
|
||||
atlas images will be created as required e.g. ``myatlas-1.png``,
|
||||
``myatlas-2.png`` etc.
|
||||
|
||||
.. note::
|
||||
|
||||
When using this script, the ids referenced in the atlas are the base names
|
||||
of the images without the extension. So, if you are going to name a file
|
||||
``../images/button.png``, the id for this image will be ``button``.
|
||||
|
||||
If you need path information included, you should include ``use_path`` as
|
||||
follows::
|
||||
|
||||
$ python -m kivy.atlas -- --use_path myatlas 256 *.png
|
||||
|
||||
In which case the id for ``../images/button.png`` will be ``images_button``
|
||||
|
||||
|
||||
How to use an Atlas
|
||||
-------------------
|
||||
|
||||
Usually, you would specify the images by supplying the path::
|
||||
|
||||
a = Button(background_normal='images/button.png',
|
||||
background_down='images/button_down.png')
|
||||
|
||||
In our previous example, we have created the atlas containing both images and
|
||||
put them in ``images/myatlas.atlas``. You can use url notation to reference
|
||||
them::
|
||||
|
||||
a = Button(background_normal='atlas://images/myatlas/button',
|
||||
background_down='atlas://images/myatlas/button_down')
|
||||
|
||||
In other words, the path to the images is replaced by::
|
||||
|
||||
atlas://path/to/myatlas/id
|
||||
# will search for the ``path/to/myatlas.atlas`` and get the image ``id``
|
||||
|
||||
.. note::
|
||||
|
||||
In the atlas url, there is no need to add the ``.atlas`` extension. It will
|
||||
be automatically append to the filename.
|
||||
|
||||
Manual usage of the Atlas
|
||||
-------------------------
|
||||
|
||||
::
|
||||
|
||||
>>> from kivy.atlas import Atlas
|
||||
>>> atlas = Atlas('path/to/myatlas.atlas')
|
||||
>>> print(atlas.textures.keys())
|
||||
['bubble', 'bubble-red', 'button', 'button-down']
|
||||
>>> print(atlas['button'])
|
||||
<kivy.graphics.texture.TextureRegion object at 0x2404d10>
|
||||
'''
|
||||
|
||||
__all__ = ('Atlas', )
|
||||
|
||||
import json
|
||||
from os.path import basename, dirname, join, splitext
|
||||
from kivy.event import EventDispatcher
|
||||
from kivy.logger import Logger
|
||||
from kivy.properties import AliasProperty, DictProperty, ListProperty
|
||||
import os
|
||||
|
||||
|
||||
# late import to prevent recursion
|
||||
CoreImage = None
|
||||
|
||||
|
||||
class Atlas(EventDispatcher):
|
||||
'''Manage texture atlas. See module documentation for more information.
|
||||
'''
|
||||
|
||||
original_textures = ListProperty([])
|
||||
'''List of original atlas textures (which contain the :attr:`textures`).
|
||||
|
||||
:attr:`original_textures` is a :class:`~kivy.properties.ListProperty` and
|
||||
defaults to [].
|
||||
|
||||
.. versionadded:: 1.9.1
|
||||
'''
|
||||
|
||||
textures = DictProperty({})
|
||||
'''List of available textures within the atlas.
|
||||
|
||||
:attr:`textures` is a :class:`~kivy.properties.DictProperty` and defaults
|
||||
to {}.
|
||||
'''
|
||||
|
||||
def _get_filename(self):
|
||||
return self._filename
|
||||
|
||||
filename = AliasProperty(_get_filename, None)
|
||||
'''Filename of the current Atlas.
|
||||
|
||||
:attr:`filename` is an :class:`~kivy.properties.AliasProperty` and defaults
|
||||
to None.
|
||||
'''
|
||||
|
||||
def __init__(self, filename):
|
||||
self._filename = filename
|
||||
super(Atlas, self).__init__()
|
||||
self._load()
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.textures[key]
|
||||
|
||||
def _load(self):
|
||||
# late import to prevent recursive import.
|
||||
global CoreImage
|
||||
if CoreImage is None:
|
||||
from kivy.core.image import Image as CoreImage
|
||||
|
||||
# must be a name finished by .atlas ?
|
||||
filename = self._filename
|
||||
assert filename.endswith('.atlas')
|
||||
filename = filename.replace('/', os.sep)
|
||||
|
||||
Logger.debug('Atlas: Load <%s>' % filename)
|
||||
with open(filename, 'r') as fd:
|
||||
meta = json.load(fd)
|
||||
|
||||
Logger.debug('Atlas: Need to load %d images' % len(meta))
|
||||
d = dirname(filename)
|
||||
textures = {}
|
||||
for subfilename, ids in meta.items():
|
||||
subfilename = join(d, subfilename)
|
||||
Logger.debug('Atlas: Load <%s>' % subfilename)
|
||||
|
||||
# load the image
|
||||
ci = CoreImage(subfilename)
|
||||
atlas_texture = ci.texture
|
||||
self.original_textures.append(atlas_texture)
|
||||
|
||||
# for all the uid, load the image, get the region, and put
|
||||
# it in our dict.
|
||||
for meta_id, meta_coords in ids.items():
|
||||
x, y, w, h = meta_coords
|
||||
textures[meta_id] = atlas_texture.get_region(*meta_coords)
|
||||
|
||||
self.textures = textures
|
||||
|
||||
@staticmethod
|
||||
def create(outname, filenames, size, padding=2, use_path=False):
|
||||
'''This method can be used to create an atlas manually from a set of
|
||||
images.
|
||||
|
||||
:Parameters:
|
||||
`outname`: str
|
||||
Basename to use for ``.atlas`` creation and ``-<idx>.png``
|
||||
associated images.
|
||||
`filenames`: list
|
||||
List of filenames to put in the atlas.
|
||||
`size`: int or list (width, height)
|
||||
Size of the atlas image. If the size is not large enough to
|
||||
fit all of the source images, more atlas images will created
|
||||
as required.
|
||||
`padding`: int, defaults to 2
|
||||
Padding to put around each image.
|
||||
|
||||
Be careful. If you're using a padding < 2, you might have
|
||||
issues with the borders of the images. Because of the OpenGL
|
||||
linearization, it might use the pixels of the adjacent image.
|
||||
|
||||
If you're using a padding >= 2, we'll automatically generate a
|
||||
"border" of 1px around your image. If you look at
|
||||
the result, don't be scared if the image inside is not
|
||||
exactly the same as yours :).
|
||||
|
||||
`use_path`: bool, defaults to False
|
||||
If True, the relative path of the source png
|
||||
file names will be included in the atlas ids rather
|
||||
that just in the file names. Leading dots and slashes will be
|
||||
excluded and all other slashes in the path will be replaced
|
||||
with underscores. For example, if `use_path` is False
|
||||
(the default) and the file name is
|
||||
``../data/tiles/green_grass.png``, the id will be
|
||||
``green_grass``. If `use_path` is True, it will be
|
||||
``data_tiles_green_grass``.
|
||||
|
||||
.. versionchanged:: 1.8.0
|
||||
Parameter use_path added
|
||||
'''
|
||||
# Thanks to
|
||||
# omnisaurusgames.com/2011/06/texture-atlas-generation-using-python/
|
||||
# for its initial implementation.
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
Logger.critical('Atlas: Imaging/PIL are missing')
|
||||
raise
|
||||
|
||||
if isinstance(size, (tuple, list)):
|
||||
size_w, size_h = list(map(int, size))
|
||||
else:
|
||||
size_w = size_h = int(size)
|
||||
|
||||
# open all of the images
|
||||
ims = list()
|
||||
for f in filenames:
|
||||
fp = open(f, 'rb')
|
||||
im = Image.open(fp)
|
||||
im.load()
|
||||
fp.close()
|
||||
ims.append((f, im))
|
||||
|
||||
# sort by image area
|
||||
ims = sorted(ims, key=lambda im: im[1].size[0] * im[1].size[1],
|
||||
reverse=True)
|
||||
|
||||
# free boxes are empty space in our output image set
|
||||
# the freebox tuple format is: outidx, x, y, w, h
|
||||
freeboxes = [(0, 0, 0, size_w, size_h)]
|
||||
numoutimages = 1
|
||||
|
||||
# full boxes are areas where we have placed images in the atlas
|
||||
# the full box tuple format is: image, outidx, x, y, w, h, filename
|
||||
fullboxes = []
|
||||
|
||||
# do the actual atlasing by sticking the largest images we can
|
||||
# have into the smallest valid free boxes
|
||||
for imageinfo in ims:
|
||||
im = imageinfo[1]
|
||||
imw, imh = im.size
|
||||
imw += padding
|
||||
imh += padding
|
||||
if imw > size_w or imh > size_h:
|
||||
Logger.error(
|
||||
'Atlas: image %s (%d by %d) is larger than the atlas size!'
|
||||
% (imageinfo[0], imw, imh))
|
||||
return
|
||||
|
||||
inserted = False
|
||||
while not inserted:
|
||||
for idx, fb in enumerate(freeboxes):
|
||||
# find the smallest free box that will contain this image
|
||||
if fb[3] >= imw and fb[4] >= imh:
|
||||
# we found a valid spot! Remove the current
|
||||
# freebox, and split the leftover space into (up to)
|
||||
# two new freeboxes
|
||||
del freeboxes[idx]
|
||||
if fb[3] > imw:
|
||||
freeboxes.append((
|
||||
fb[0], fb[1] + imw, fb[2],
|
||||
fb[3] - imw, imh))
|
||||
|
||||
if fb[4] > imh:
|
||||
freeboxes.append((
|
||||
fb[0], fb[1], fb[2] + imh,
|
||||
fb[3], fb[4] - imh))
|
||||
|
||||
# keep this sorted!
|
||||
freeboxes = sorted(freeboxes,
|
||||
key=lambda fb: fb[3] * fb[4])
|
||||
fullboxes.append((im,
|
||||
fb[0], fb[1] + padding,
|
||||
fb[2] + padding, imw - padding,
|
||||
imh - padding, imageinfo[0]))
|
||||
inserted = True
|
||||
break
|
||||
|
||||
if not inserted:
|
||||
# oh crap - there isn't room in any of our free
|
||||
# boxes, so we have to add a new output image
|
||||
freeboxes.append((numoutimages, 0, 0, size_w, size_h))
|
||||
numoutimages += 1
|
||||
|
||||
# now that we've figured out where everything goes, make the output
|
||||
# images and blit the source images to the appropriate locations
|
||||
Logger.info('Atlas: create an {0}x{1} rgba image'.format(size_w,
|
||||
size_h))
|
||||
outimages = [Image.new('RGBA', (size_w, size_h))
|
||||
for i in range(0, int(numoutimages))]
|
||||
for fb in fullboxes:
|
||||
x, y = fb[2], fb[3]
|
||||
out = outimages[fb[1]]
|
||||
out.paste(fb[0], (fb[2], fb[3]))
|
||||
w, h = fb[0].size
|
||||
if padding > 1:
|
||||
out.paste(fb[0].crop((0, 0, w, 1)), (x, y - 1))
|
||||
out.paste(fb[0].crop((0, h - 1, w, h)), (x, y + h))
|
||||
out.paste(fb[0].crop((0, 0, 1, h)), (x - 1, y))
|
||||
out.paste(fb[0].crop((w - 1, 0, w, h)), (x + w, y))
|
||||
|
||||
# save the output images
|
||||
for idx, outimage in enumerate(outimages):
|
||||
outimage.save('%s-%d.png' % (outname, idx))
|
||||
|
||||
# write out an json file that says where everything ended up
|
||||
meta = {}
|
||||
for fb in fullboxes:
|
||||
fn = '%s-%d.png' % (basename(outname), fb[1])
|
||||
if fn not in meta:
|
||||
d = meta[fn] = {}
|
||||
else:
|
||||
d = meta[fn]
|
||||
|
||||
# fb[6] contain the filename
|
||||
if use_path:
|
||||
# use the path with separators replaced by _
|
||||
# example '../data/tiles/green_grass.png' becomes
|
||||
# 'data_tiles_green_grass'
|
||||
uid = splitext(fb[6])[0]
|
||||
# remove leading dots and slashes
|
||||
uid = uid.lstrip('./\\')
|
||||
# replace remaining slashes with _
|
||||
uid = uid.replace('/', '_').replace('\\', '_')
|
||||
else:
|
||||
# for example, '../data/tiles/green_grass.png'
|
||||
# just get only 'green_grass' as the uniq id.
|
||||
uid = splitext(basename(fb[6]))[0]
|
||||
|
||||
x, y, w, h = fb[2:6]
|
||||
d[uid] = x, size_h - y - h, w, h
|
||||
|
||||
outfn = '%s.atlas' % outname
|
||||
with open(outfn, 'w') as fd:
|
||||
json.dump(meta, fd)
|
||||
|
||||
return outfn, meta
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
""" Main line program. Process command line arguments
|
||||
to make a new atlas. """
|
||||
|
||||
import sys
|
||||
from glob import glob
|
||||
argv = sys.argv[1:]
|
||||
# earlier import of kivy has already called getopt to remove kivy system
|
||||
# arguments from this line. That is all arguments up to the first '--'
|
||||
if len(argv) < 3:
|
||||
print('Usage: python -m kivy.atlas [-- [--use-path] '
|
||||
'[--padding=2]] <outname> '
|
||||
'<size|512x256> <img1.png> [<img2.png>, ...]')
|
||||
sys.exit(1)
|
||||
|
||||
options = {'use_path': False}
|
||||
while True:
|
||||
option = argv[0]
|
||||
if option == '--use-path':
|
||||
options['use_path'] = True
|
||||
elif option.startswith('--padding='):
|
||||
options['padding'] = int(option.split('=', 1)[-1])
|
||||
elif option[:2] == '--':
|
||||
print('Unknown option {}'.format(option))
|
||||
sys.exit(1)
|
||||
else:
|
||||
break
|
||||
argv = argv[1:]
|
||||
|
||||
outname = argv[0]
|
||||
try:
|
||||
if 'x' in argv[1]:
|
||||
size = list(map(int, argv[1].split('x', 1)))
|
||||
else:
|
||||
size = int(argv[1])
|
||||
except ValueError:
|
||||
print('Error: size must be an integer or <integer>x<integer>')
|
||||
sys.exit(1)
|
||||
|
||||
filenames = [fname for fnames in argv[2:] for fname in glob(fnames)]
|
||||
ret = Atlas.create(outname, filenames, size, **options)
|
||||
if not ret:
|
||||
print('Error while creating atlas!')
|
||||
sys.exit(1)
|
||||
|
||||
fn, meta = ret
|
||||
print('Atlas created at', fn)
|
||||
print('%d image%s been created' % (len(meta),
|
||||
's have' if len(meta) > 1 else ' has'))
|
||||
Reference in New Issue
Block a user