446 lines
13 KiB
Python
446 lines
13 KiB
Python
import time
|
|
import numpy as np
|
|
|
|
from ..constants import log
|
|
from ..constants import tol_path as tol
|
|
|
|
|
|
class RectangleBin:
|
|
"""
|
|
2D BSP tree node.
|
|
http://www.blackpawn.com/texts/lightmaps/
|
|
"""
|
|
|
|
def __init__(self, bounds=None, size=None):
|
|
"""
|
|
Create a rectangular bin.
|
|
|
|
Parameters
|
|
------------
|
|
bounds : (4,) float or None
|
|
(minx, miny, maxx, maxy)
|
|
size : (2,) float or None
|
|
Alternative method to set bounds
|
|
(X size, Y size)
|
|
"""
|
|
self.child = [None, None]
|
|
self.occupied = False
|
|
|
|
# bounds: (minx, miny, maxx, maxy)
|
|
if bounds is not None:
|
|
self.bounds = np.asanyarray(bounds,
|
|
dtype=np.float64)
|
|
elif size is not None:
|
|
self.bounds = np.append(
|
|
[0.0, 0.0], size).astype(np.float64)
|
|
else:
|
|
raise ValueError('need to pass size or bounds!')
|
|
|
|
@property
|
|
def extents(self):
|
|
"""
|
|
Bounding box size.
|
|
|
|
Returns
|
|
----------
|
|
extents : (2,) float
|
|
Edge lengths of bounding box
|
|
"""
|
|
bounds = self.bounds
|
|
return bounds[2:] - bounds[:2]
|
|
|
|
def insert(self, rectangle):
|
|
"""
|
|
Insert a rectangle into the bin.
|
|
|
|
Parameters
|
|
-------------
|
|
rectangle : (2,) float
|
|
Size of rectangle to insert
|
|
|
|
Returns
|
|
----------
|
|
inserted : None or (2,) float
|
|
Position of insertion in the tree
|
|
"""
|
|
for child in self.child:
|
|
if child is not None:
|
|
# try inserting into child cells
|
|
attempt = child.insert(rectangle)
|
|
if attempt is not None:
|
|
return attempt
|
|
|
|
# can't insert into occupied cells
|
|
if self.occupied:
|
|
return None
|
|
|
|
# compare the bin size to the insertion candidate size
|
|
bounds = self.bounds
|
|
# manually compute extents here to avoid function call
|
|
size_test = (bounds[2:] - bounds[:2]) - rectangle
|
|
|
|
# this means the inserted rectangle is too big for the cell
|
|
if any(size_test < -tol.zero):
|
|
return None
|
|
|
|
# since the cell is big enough for the current rectangle, either it
|
|
# is going to be inserted here, or the cell is going to be split
|
|
# either way the cell is now occupied.
|
|
self.occupied = True
|
|
|
|
# this means the inserted rectangle fits perfectly
|
|
# since we already checked to see if it was negative, no abs is needed
|
|
if all(size_test < tol.zero):
|
|
return self.bounds[:2]
|
|
|
|
# since the rectangle fits but the empty space is too big,
|
|
# we need to create some children to insert into
|
|
# first, we decide which way to split
|
|
vertical = size_test[0] > size_test[1]
|
|
length = rectangle[int(not vertical)]
|
|
child_bounds = self.split(length, vertical)
|
|
|
|
# create the child objects
|
|
self.child[0] = RectangleBin(bounds=child_bounds[0])
|
|
self.child[1] = RectangleBin(bounds=child_bounds[1])
|
|
|
|
return self.child[0].insert(rectangle)
|
|
|
|
def split(self, length, vertical=True):
|
|
"""
|
|
Returns two bounding boxes representing the current
|
|
bounds split into two smaller boxes.
|
|
|
|
Parameters
|
|
-------------
|
|
length : float
|
|
Length to split
|
|
vertical: bool
|
|
Split the box vertically rather than horizontally
|
|
|
|
Returns
|
|
-------------
|
|
box : (2, 4) float
|
|
Two bounding boxes with min-max:
|
|
[minx, miny, maxx, maxy]
|
|
"""
|
|
# also know as [minx, miny, maxx, maxy]
|
|
(left, bottom, right, top) = self.bounds
|
|
if vertical:
|
|
return [[left, bottom, left + length, top],
|
|
[left + length, bottom, right, top]]
|
|
else:
|
|
return [[left, bottom, right, bottom + length],
|
|
[left, bottom + length, right, top]]
|
|
|
|
|
|
def rectangles_single(rectangles, sheet_size=None, shuffle=False):
|
|
"""
|
|
Execute a single insertion order of smaller rectangles onto
|
|
a larger rectangle using a binary space partition tree.
|
|
|
|
Parameters
|
|
----------
|
|
rectangles : (n, 2) float
|
|
An array of (width, height) pairs
|
|
representing the rectangles to be packed.
|
|
sheet_size : (2,) float
|
|
Width, height of rectangular sheet
|
|
shuffle : bool
|
|
Whether or not to shuffle the insert order of the
|
|
smaller rectangles, as the final packing density depends
|
|
on insertion order.
|
|
|
|
Returns
|
|
---------
|
|
density : float
|
|
Area filled over total sheet area
|
|
offset : (m,2) float
|
|
Offsets to move rectangles to their packed location
|
|
inserted : (n,) bool
|
|
Which of the original rectangles were packed
|
|
consumed_box : (2,) float
|
|
Bounding box size of packed result
|
|
"""
|
|
offset = np.zeros((len(rectangles), 2))
|
|
inserted = np.zeros(len(rectangles), dtype=np.bool)
|
|
box_order = np.argsort(np.sum(rectangles**2, axis=1))[::-1]
|
|
area = 0.0
|
|
density = 0.0
|
|
|
|
# if no sheet size specified, make a large one
|
|
if sheet_size is None:
|
|
sheet_size = [rectangles[:, 0].sum(),
|
|
rectangles[:, 1].max() * 2]
|
|
|
|
if shuffle:
|
|
# maximum index to shuffle
|
|
max_idx = int(np.random.random() * len(rectangles)) - 1
|
|
# reorder with permutations
|
|
box_order[:max_idx] = np.random.permutation(box_order[:max_idx])
|
|
|
|
# start the tree
|
|
sheet = RectangleBin(size=sheet_size)
|
|
for index in box_order:
|
|
insert_location = sheet.insert(rectangles[index])
|
|
if insert_location is not None:
|
|
area += np.prod(rectangles[index])
|
|
offset[index] += insert_location
|
|
inserted[index] = True
|
|
consumed_box = np.max((offset + rectangles)[inserted], axis=0)
|
|
density = area / np.product(consumed_box)
|
|
|
|
return density, offset[inserted], inserted, consumed_box
|
|
|
|
|
|
def paths(paths, **kwargs):
|
|
"""
|
|
Pack a list of Path2D objects into a rectangle.
|
|
|
|
Parameters
|
|
------------
|
|
paths: (n,) Path2D
|
|
Geometry to be packed
|
|
|
|
Returns
|
|
------------
|
|
packed : trimesh.path.Path2D
|
|
Object containing input geometry
|
|
inserted : (m,) int
|
|
Indexes of paths inserted into result
|
|
"""
|
|
from .util import concatenate
|
|
|
|
quantity = []
|
|
for path in paths:
|
|
if 'quantity' in path.metadata:
|
|
quantity.append(path.metadata['quantity'])
|
|
else:
|
|
quantity.append(1)
|
|
|
|
# pack using exterior polygon (will OBB)
|
|
packable = [i.polygons_closed[i.root[0]] for i in paths]
|
|
|
|
# pack the polygons using rectangular bin packing
|
|
inserted, transforms = polygons(polygons=packable,
|
|
quantity=quantity,
|
|
**kwargs)
|
|
|
|
multi = []
|
|
for i, T in zip(inserted, transforms):
|
|
multi.append(paths[i].copy())
|
|
multi[-1].apply_transform(T)
|
|
# append all packed paths into a single Path object
|
|
packed = concatenate(multi)
|
|
|
|
return packed, inserted
|
|
|
|
|
|
def polygons(polygons,
|
|
sheet_size=None,
|
|
iterations=50,
|
|
density_escape=.95,
|
|
spacing=0.094,
|
|
quantity=None,
|
|
**kwargs):
|
|
"""
|
|
Pack polygons into a rectangle by taking each Polygon's OBB
|
|
and then packing that as a rectangle.
|
|
|
|
Parameters
|
|
------------
|
|
polygons : (n,) shapely.geometry.Polygon
|
|
Source geometry
|
|
sheet_size : (2,) float
|
|
Size of rectangular sheet
|
|
iterations : int
|
|
Number of times to run the loop
|
|
density_escape : float
|
|
When to exit early (0.0 - 1.0)
|
|
spacing : float
|
|
How big a gap to leave between polygons
|
|
quantity : (n,) int, or None
|
|
Quantity of each Polygon
|
|
|
|
Returns
|
|
-------------
|
|
overall_inserted : (m,) int
|
|
Indexes of inserted polygons
|
|
packed : (m, 3, 3) float
|
|
Homogeonous transforms from original frame to packed frame
|
|
"""
|
|
|
|
from .polygons import polygons_obb
|
|
|
|
if quantity is None:
|
|
quantity = np.ones(len(polygons), dtype=np.int64)
|
|
else:
|
|
quantity = np.asanyarray(quantity, dtype=np.int64)
|
|
if len(quantity) != len(polygons):
|
|
raise ValueError('quantity must match polygons')
|
|
|
|
# find the oriented bounding box of the polygons
|
|
obb, rect = polygons_obb(polygons)
|
|
|
|
# pad all sides of the rectangle
|
|
rect += 2.0 * spacing
|
|
# move the OBB transform so the polygon is centered
|
|
# in the padded rectangle
|
|
for i, r in enumerate(rect):
|
|
obb[i][0:2, 2] += r * .5
|
|
|
|
# for polygons occurring multiple times
|
|
indexes = np.hstack([np.ones(q, dtype=np.int64) * i
|
|
for i, q in enumerate(quantity)])
|
|
# stack using advanced indexing
|
|
obb = obb[indexes]
|
|
rect = rect[indexes]
|
|
|
|
# store timing
|
|
tic = time.time()
|
|
density = 0.0
|
|
|
|
# run packing for a number of iterations
|
|
(density,
|
|
offset,
|
|
inserted,
|
|
sheet) = rectangles(
|
|
rectangles=rect,
|
|
sheet_size=sheet_size,
|
|
spacing=spacing,
|
|
density_escape=density_escape,
|
|
iterations=iterations, **kwargs)
|
|
|
|
toc = time.time()
|
|
log.debug('packing finished %i iterations in %f seconds',
|
|
i + 1,
|
|
toc - tic)
|
|
log.debug('%i/%i parts were packed successfully',
|
|
np.sum(inserted),
|
|
quantity.sum())
|
|
|
|
# transformations to packed positions
|
|
packed = obb[inserted]
|
|
# apply the offset and inter- polygon spacing
|
|
packed.reshape(-1, 9)[:, [2, 5]] += offset + spacing
|
|
|
|
return indexes[inserted], packed
|
|
|
|
|
|
def rectangles(rectangles,
|
|
sheet_size=None,
|
|
density_escape=0.9,
|
|
spacing=0.0,
|
|
iterations=50,
|
|
quanta=None):
|
|
"""
|
|
Run multiple iterations of rectangle packing.
|
|
|
|
Parameters
|
|
------------
|
|
rectangles : (n, 2) float
|
|
Size of rectangles to be packed
|
|
sheet_size : None or (2,) float
|
|
Size of sheet to pack onto
|
|
density_escape : float
|
|
Exit early if density is above this threshold
|
|
spacing : float
|
|
Distance to allow between rectangles
|
|
iterations : int
|
|
Number of iterations to run
|
|
quanta : None or float
|
|
|
|
|
|
Returns
|
|
---------
|
|
density : float
|
|
Area filled over total sheet area
|
|
offset : (m,2) float
|
|
Offsets to move rectangles to their packed location
|
|
inserted : (n,) bool
|
|
Which of the original rectangles were packed
|
|
consumed_box : (2,) float
|
|
Bounding box size of packed result
|
|
"""
|
|
rectangles = np.array(rectangles)
|
|
|
|
# best density percentage in 0.0 - 1.0
|
|
best_density = 0.0
|
|
# how many rectangles were inserted
|
|
best_insert = 0
|
|
|
|
for i in range(iterations):
|
|
# run a single insertion order
|
|
# don't shuffle the first run, shuffle subsequent runs
|
|
packed = rectangles_single(
|
|
rectangles,
|
|
sheet_size=sheet_size,
|
|
shuffle=(i != 0))
|
|
density = packed[0]
|
|
insert = packed[2].sum()
|
|
|
|
if quanta is not None:
|
|
# compute the density using an upsized quanta
|
|
box = np.ceil(packed[3] / quanta) * quanta
|
|
# scale the density result
|
|
density *= (np.product(packed[3]) / np.product(box))
|
|
|
|
# compare this packing density against our best
|
|
if density > best_density or insert > best_insert:
|
|
best_density = density
|
|
best_insert = insert
|
|
# save the result
|
|
result = packed
|
|
# exit early if everything is inserted and
|
|
# we have exceeded our target density
|
|
if density > density_escape and packed[2].all():
|
|
break
|
|
|
|
return result
|
|
|
|
|
|
def images(images, power_resize=False):
|
|
"""
|
|
Pack a list of images and return result and offsets.
|
|
|
|
Parameters
|
|
------------
|
|
images : (n,) PIL.Image
|
|
Images to be packed
|
|
deduplicate : bool
|
|
If True deduplicate images before packing
|
|
|
|
Returns
|
|
-----------
|
|
packed : PIL.Image
|
|
Multiple images packed into result
|
|
offsets : (n, 2) int
|
|
Offsets for original image to pack
|
|
"""
|
|
from PIL import Image
|
|
|
|
# use the number of pixels as the rectangle size
|
|
rect = np.array([i.size for i in images])
|
|
|
|
(density,
|
|
offset,
|
|
insert,
|
|
sheet) = rectangles(rectangles=rect)
|
|
# really should have inserted all the rectangles
|
|
assert insert.all()
|
|
|
|
# offsets should be integer multiple of pizels
|
|
offset = offset.round().astype(int)
|
|
|
|
size = sheet.round().astype(int)
|
|
if power_resize:
|
|
size = (2 ** np.ceil(np.log2(size))).astype(np.int64)
|
|
|
|
# create the image
|
|
result = Image.new('RGB', tuple(size))
|
|
# paste each image into the result
|
|
for img, off in zip(images, offset):
|
|
result.paste(img, tuple(off))
|
|
|
|
return result, offset
|