I wrote a 2D greedy bin packing algorithm using Python 3.6
Heres a quick summary:
The algorithm consists of two classes (which I will attach at the end of this file along with a link to my github repo): BinPack and BinTree. The BinTree class is a tree that represents a single 2D bin. The BinPack class is the main interface, ranks BinTrees by available space, and selects BinTrees for item insertion.
Item insertion into a BinTree is done by traversing down from the root node until an unoccupied node is found which can fit the item. Then that node is resized to the size of the item, and two child nodes are created (if necessary) the size of the remainder space on the left and the bottom of the resized node.
BinPack picks which BinTree to insert into using an AVL Tree. Each BinTree Tree maintains a value for its largest unoccupied child. These values are used to rank all the BinTrees Trees in the AVL Tree. Then the best fit heuristic is used to pick the Tree where an insertion would result in the smallest leftover space. If all the Trees are full a new Tree is created.
Here is an example of usage:
In [15]: import binpack
In [34]: pack = binpack.BinPack(bin_size=(4, 8))
In [16]: pack.insert((2,2), (2,4), (4,5), (10,5))
In [17]: pack.print_stats()
Out[17]:
{0: {'area': 32,
'efficiency': 0.875,
'height': 8,
'items': [(CornerPoint(x=0, y=0), Item(width=4, height=5)),
(CornerPoint(x=0, y=5), Item(width=2, height=2)),
(CornerPoint(x=2, y=5), Item(width=2, height=2))],
'width': 4},
1: {'area': 32,
'efficiency': 0.25,
'height': 8,
'items': [(CornerPoint(x=0, y=0), Item(width=2, height=4))],
'width': 4},
2: {'area': 32,
'efficiency': 0.625,
'height': 8,
'items': [(CornerPoint(x=0, y=0), Item(width=4, height=5))],
'width': 4},
3: {'area': 32,
'efficiency': 0.25,
'height': 8,
'items': [(CornerPoint(x=0, y=0), Item(width=2, height=4))],
'width': 4},
'oversized': [Item(width=10, height=5)]}
My Questions:
Is my BinTree class a good way of representing 2D bins? It seemed really intuitive it to me but it does create complications such as:
Inserting a (3,4) item then a (1,8) item into a (4,8) Tree the (1,8) item wont fit. The (3,4) insertion will cause the BinTree to be resized to (3,4) with a right child sized (1,4) and bottom child sized (4,4). This leaves no single node with the space for the (1,8) item.
I know that AVL Trees are recommended for ranking bins in one dimensional greedy binpack algorithms. But with two dimensions the largest available space becomes less relevant. One Tree could have a bigger largest child area but then a second Tree but the second Tree could have a smaller empty node that would fit the item better. Rather then ranking each bin, would it be better to rank all the unoccupied nodes of all the trees in the AVL tree?
Lastly, are there any huge code smells or bad ideas in my implementation? I've largely come up with this algorithm based on 1D greedy algorithm examples i've seen online. Everything I find talking about 2+ dimensions tends to be highly technical and focus on mathematical proofs over implementation details.
My Code:
BinTree
from typing import NamedTuple
from collections import deque
class CornerPoint(NamedTuple):
"""
namedtuple representing the top left corner of each
BinTree object.
"""
x: int
y: int
class Item(NamedTuple):
"""
namedtuple representing objects added to BinTree.
"""
width: int
height: int
class BinTree:
"""
Each BinTree instance has two children (left and bottom)
and width (int), height (int), and occupied (bool) properties.
"""
def __init__(self, dims: tuple = (4, 8)) -> None:
self.corner = CornerPoint(0, 0)
self.dims = dims
self.occupied = False
self.parent = None
self.right = None
self.bottom = None
if not self.occupied:
self.largest_child = tuple(self.dims)
else:
self.largest_child = None
def _check_dim(item_dim: int, bin_dim: int) -> bool:
"""
Checks if the item will fit the bin in the specified
dimension.
"""
pass
def insert(self, item: Item) -> bool:
"""
Recursive item insertion
Takes an Item namedtuple
Inserts recursively as a side-effect
Returns True or False if Item fit in bin
"""
if not self.occupied and item.width <= self.dims[0] and item.height <= self.dims[1]:
if self.dims[1] - item.height > 0:
self.bottom = BinTree(dims=[self.dims[0], self.dims[1]-item.height])
self.bottom.parent = self
if self.dims[0] - item.width > 0:
self.right = BinTree(dims=[self.dims[0]-item.width, item.height])
self.right.parent = self
self.dims = (item.width, item.height)
self.occupied = item
if self.right:
self.right.corner = CornerPoint(self.dims[0] + self.corner.x, self.corner.y)
if self.bottom:
self.bottom.corner = CornerPoint(self.corner.x, self.dims[1] + self.corner.y)
self.calc_largest_child()
return True
else:
if (self.right and self.right.largest_child[0] >= item.width and
self.right.largest_child[1] >= item.height):
self.right.insert(item)
elif (self.bottom and self.bottom.largest_child[0] >= item.width and
self.bottom.largest_child[1] >= item.height):
self.bottom.insert(item)
else:
return False
def calc_largest_child(self) -> None:
"""
Updates self.largest_child for each node recursively
back to the root node
"""
choices = []
if not self.occupied:
choices.append(self.dims)
else:
choices.append((0, 0))
if self.right:
choices.append(self.right.largest_child)
else:
choices.append((0, 0))
if self.bottom:
choices.append(self.bottom.largest_child)
else:
choices.append((0, 0))
self.largest_child = max(choices, key=lambda t: t[0]*t[1])
if self.parent:
self.parent.calc_largest_child()
def bin_stats(root: BinTree) -> dict:
"""
Returns a dictionary with compiled stats on the bin tree
"""
stats = {
'width': 0,
'height': 0,
'area': 0,
'efficiency': 1.0,
'items': [],
}
stack = deque([root])
while stack:
node = stack.popleft()
stats['width'] += node.dims[0]
if node.right:
stack.append(node.right)
stack = deque([root])
while stack:
node = stack.popleft()
stats['height'] += node.dims[1]
if node.bottom:
stack.append(node.bottom)
stats['area'] = stats['width'] * stats['height']
stack = deque([root])
occupied = 0
while stack:
node = stack.popleft()
if node.occupied:
stats['items'].append((node.corner, node.occupied))
occupied += node.dims[0]*node.dims[1]
if node.right:
stack.append(node.right)
if node.bottom:
stack.append(node.bottom)
stats['efficiency'] = occupied / stats['area']
return stats
Binpack:
from collections import deque
from operator import mul as product
from . import avl_tree
from . import bintree
class BinPack:
"""
Bin Ranking System. the product of BinTree.largest_child
is used as a key value in an AVL Tree for ranking the bins.
"""
def __init__(self, bin_size: tuple = (4, 8), sorting: bool = True):
self.bin_dict = {'oversized': []}
self.bin_size = bin_size
self.tree = avl_tree.AvlTree()
self.bin_count = 0
self.sorting = sorting
self._add_bin()
def _add_bin(self) -> None:
"""
private method. Creates a new BinTree and adds
it to bin_dict and the AVL tree.
"""
self.bin_dict[self.bin_count] = bintree.BinTree(list(self.bin_size))
bin_key = product(*self.bin_dict[self.bin_count].largest_child)
self.tree.insert(bin_key)
self.tree[bin_key].data.append(self.bin_count)
self.bin_count += 1
def insert(self, *items: tuple, heuristic: str = 'best_fit') -> None:
if self.sorting == True:
items = sorted(items, key=lambda a: product(*a), reverse=True)
for item in items:
if heuristic == 'best_fit':
self.best_fit(item)
elif heuristic == 'next_fit':
self.next_fit(item)
def _check_oversized(self, item: bintree.Item) -> bool:
"""
Catch oversized items
"""
if item[0] > self.bin_size[0] or item[1] > self.bin_size[1]:
self.bin_dict['oversized'].append(item)
return False
return True
def best_fit(self, item_dims: tuple) -> bool:
"""
Best Fit Bin Selection
Public method.
Selects optimal BinTree (or creates
a new BinTree) for item insertion using first fit.
Returns BinTree ID.
"""
item_area = product(*item_dims)
item = bintree.Item(*item_dims)
# Catch oversized items:
if self._check_oversized(item) == False:
return False
queue = deque([self.tree.root])
best_fit = None
best_fit_node = None
while queue:
current_node = queue.popleft()
current_bintree = self.bin_dict[current_node.data[-1]]
largest_child = current_bintree.largest_child
if (largest_child[0] >= item.width and
largest_child[1] >= item.height):
if not best_fit or largest_child < best_fit.largest_child:
best_fit = current_bintree
best_fit_node = current_node
if current_node.left:
queue.append(current_node.left)
else:
if current_node.right:
queue.append(current_node.right)
if best_fit:
best_fit.insert(item)
# delete and reinsert node to update position in tree
nodeid = best_fit_node.data[-1]
old_key = best_fit_node.key
new_key = product(*best_fit.largest_child)
self.tree.delete(key=old_key)
self.tree.insert(key=new_key)
self.tree[new_key].data.append(nodeid)
return True
else:
self._add_bin()
self.bin_dict[self.bin_count-1].insert(item)
return True
def next_fit(self, item_dims: tuple) -> bool:
"""
First Fit Bin Selection
Public method.
Selects optimal BinTree (or creates
a new BinTree) for item insertion using first fit.
Returns BinTree ID.
"""
item_area = product(*item_dims)
item = bintree.Item(*item_dims)
# Catch oversized items:
if self._check_oversized(item) == False:
return False
queue = deque([self.tree.root])
while queue:
current_node = queue.popleft()
current_bintree = self.bin_dict[current_node.data[-1]]
largest_child = current_bintree.largest_child
if (largest_child[0] >= item.width and
largest_child[1] >= item.height):
current_bintree.insert(item)
# delete and reinsert node to update position in tree
nodeid = current_node.data[-1]
old_key = current_node.key
new_key = product(*current_bintree.largest_child)
self.tree.delete(key=old_key)
self.tree.insert(key=new_key)
self.tree[new_key].data.append(nodeid)
return True
else:
if current_node.right:
queue.append(current_node.right)
else:
self._add_bin()
self.next_fit(item_dims)
return False
def print_stats(self) -> None:
"""
Returns layouts for all BinTrees
"""
result = {}
for key, bin in self.bin_dict.items():
if key != 'oversized':
result[key] = bintree.bin_stats(bin)
result['oversized'] = self.bin_dict['oversized']
return result
anything I could do
No.BinTree
is frequently used for binary tree - you're screwed for good trying to getbin
read as bin.) Start with a short&simple description of the task to accomplish - reference welcome.BinTree
is funny in documenting an attributeleft
going on to useright
. It bears a whiff of Guillotine - the examples near the bottom of Cutting stock problem may be an example where that limits result quality.) \$\endgroup\$