Surn's picture
Add customizable colors for maze drawing in CLI/API
7bfe358
import math
import logging
from PIL import Image, ImageDraw
def draw_maze(maze, path, cell=20, save_path=None, show_doors=False, color_dict={'wall':'black', 'floor':'white', 'start':'green', 'door':'#B0B0B0', 'path':'red'}):
"""
Draw the maze and an optional path using PIL.
Args:
maze (list[list[int]]): 2D grid with 1 for walls and 0 for open cells.
path (list[tuple[int,int]]): sequence of (x, y) coordinates forming a path through the maze.
cell (int): pixel size of one maze cell when rendering. Default is 20.
save_path (str|None): if given, save the rendered image to this file path; if None, show the image instead.
show_doors (bool): if True, draw thin grey outlines around each cell to indicate doors.
color_dict (dict): optional dict to specify colors for 'wall', 'floor', 'start', 'door', and 'path'.
"""
h,w = len(maze),len(maze[0])
img = Image.new('RGB', (w*cell,h*cell), color_dict.get('floor', 'white'))
draw = ImageDraw.Draw(img)
# fill start cell (0,0) with green floor
draw.rectangle([0, 0, cell, cell], fill=color_dict.get('start', 'green'))
# Optional thin grey outlines for each cell (like hex show_doors)
if show_doors:
for y in range(h):
for x in range(w):
draw.rectangle(
[x * cell, y * cell, (x + 1) * cell, (y + 1) * cell],
outline=color_dict.get('door', '#B0B0B0'),
width=1
)
# Draw solid walls
for y in range(h):
for x in range(w):
if maze[y][x]:
draw.rectangle([x*cell,y*cell,(x+1)*cell,(y+1)*cell], fill=color_dict.get('wall', 'black'))
# Draw 2px exterior border, leaving an opening at the exit cell (w-1, h-1)
img_w, img_h = w * cell, h * cell
border_width = 4
draw.rectangle([0, 0, img_w - 1, img_h - 1], outline=color_dict.get('wall', 'black'), width=border_width)
ex0, ey0 = (w - 1) * cell, (h - 1) * cell
draw.line([(img_w - 1, ey0), (img_w - 1, ey0 + cell)], fill=color_dict.get('door', '#B0B0B0'), width=border_width + 2)
draw.line([(ex0, img_h - 1), (ex0 + cell, img_h - 1)], fill=color_dict.get('door', '#B0B0B0'), width=border_width + 2)
if path:
for i in range(len(path)-1):
x1,y1 = path[i]; x2,y2 = path[i+1]
draw.line([(x1*cell+cell//2,y1*cell+cell//2),(x2*cell+cell//2,y2*cell+cell//2)], fill=color_dict.get('path', 'red'), width=3)
if save_path:
try:
img.save(save_path)
logging.info("Saved rectangular maze image to %s", save_path)
except Exception:
logging.exception("Failed to save image to %s", save_path)
return img
def draw_hex_maze(maze, path, cell_size=45, rotation_degrees=0, save_path=None, show_doors=False, color_dict={'wall':'black', 'floor':'white', 'start':'green', 'door':'#B0B0B0', 'path':'red'}):
"""
Draw a hex maze (walls only where no passage) and optional path using PIL.
Args:
maze (dict): mapping (q, r) -> list of (dq, dr) neighbor offsets.
path (list[tuple[int,int]]): sequence of axial coordinates to draw as red line.
cell_size (int): approximate pixel size of a hex cell.
save_path (str|None): if given, save image to file instead of showing.
rotation_degrees (float): rotation of hexagons in degrees (default 0 = flat-topped).
save_path (str|None): if provided, save the rendered image to this path; if None, show the image.
show_doors (bool): if True, draw thin grey outlines around each hex to show doors.
color_dict (dict): optional dict to specify colors for 'wall', 'floor', 'start', and 'door'.
"""
rot = math.radians(rotation_degrees)
if not maze: return
r_max = max((max(abs(q),abs(r)) for q,r in maze), default=5)
w = int(3*(r_max+2)*cell_size)
h = int(math.sqrt(3)*2*(r_max+2)*cell_size)
img = Image.new('RGB', (w,h), color_dict.get('floor', 'white'))
d = ImageDraw.Draw(img)
dirs = [(1,0),(0,1),(-1,1),(-1,0),(0,-1),(1,-1)]
if show_doors:
for q,r in maze: # light grey outlines
cx = cell_size*1.5*q + w//2
cy = cell_size*math.sqrt(3)*(r + q/2) + h//2
pts = [(cx + cell_size*math.cos(rot + i*math.pi/3),
cy + cell_size*math.sin(rot + i*math.pi/3)) for i in range(6)]
d.polygon(pts, outline=color_dict.get('door', '#B0B0B0'), width=2)
# fill start hex (0,0) with green floor so the starting cell is visibly marked
if (0, 0) in maze:
cx = w//2
cy = h//2
start_pts = [(cx + cell_size*math.cos(rot + i*math.pi/3),
cy + cell_size*math.sin(rot + i*math.pi/3)) for i in range(6)]
d.polygon(start_pts, fill=color_dict.get('start', 'green'))
for q,r in maze: # black blocked walls
cx = cell_size*1.5*q + w//2
cy = cell_size*math.sqrt(3)*(r + q/2) + h//2
for i,(dq,dr) in enumerate(dirs):
if (dq,dr) not in maze.get((q,r),[]):
a = rot + i*math.pi/3
x1 = cx + cell_size*math.cos(a)
y1 = cy + cell_size*math.sin(a)
x2 = cx + cell_size*math.cos(a+math.pi/3)
y2 = cy + cell_size*math.sin(a+math.pi/3)
d.line([(x1,y1),(x2,y2)], fill=color_dict.get('wall', 'black'), width=6)
# force clear exit to outside on end cell (outer side with no neighbor)
end = max(maze, key=lambda p: abs(p[0])+abs(p[1])+abs(p[0]+p[1]))
cx = cell_size*1.5*end[0] + w//2
cy = cell_size*math.sqrt(3)*(end[1] + end[0]/2) + h//2
for i,(dq,dr) in enumerate(dirs):
neigh = (end[0]+dq, end[1]+dr)
if neigh not in maze: # outer side
a = rot + i*math.pi/3
x1 = cx + cell_size*math.cos(a)
y1 = cy + cell_size*math.sin(a)
x2 = cx + cell_size*math.cos(a+math.pi/3)
y2 = cy + cell_size*math.sin(a+math.pi/3)
d.line([(x1,y1),(x2,y2)], fill=color_dict.get('floor', 'white'), width=26)
break
if path:
pts = [(cell_size*1.5*q + w//2, cell_size*math.sqrt(3)*(r + q/2) + h//2) for q,r in path]
d.line(pts, fill=color_dict.get('path', 'red'), width=7)
if save_path:
try:
img.save(save_path)
logging.info("Saved hex maze image to %s", save_path)
except Exception:
logging.exception("Failed to save image to %s", save_path)
return img
def draw_tri_maze(maze, path, cell=40, save_path=None, show_doors=False, color_dict={'wall':'black', 'floor':'white', 'start':'green', 'door':'#B0B0B0', 'path':'red'}):
"""
Draw a triangular-grid maze using PIL.
Maze format expected:
maze (dict): mapping (x, y) -> list of (dx, dy) neighbor offsets that are open.
Each cell is an equilateral triangle. Coordinates are grid indices (x:col, y:row).
Args:
maze (dict): triangle adjacency mapping.
path (list[tuple[int,int]]): list of (x,y) cells forming a path.
cell (int): side length of each triangle in pixels.
save_path (str|None): save to file if provided, otherwise show.
show_doors (bool): if True draw thin grey outlines around each triangle.
color_dict (dict): optional dict to specify colors for 'wall', 'floor', 'start', 'door', and 'path'.
"""
if not maze:
return
# determine bounds
xs = [p[0] for p in maze]
ys = [p[1] for p in maze]
min_x, max_x = min(xs), max(xs)
min_y, max_y = min(ys), max(ys)
w = max_x - min_x + 1
h = max_y - min_y + 1
side = cell
h_tri = side * math.sqrt(3) / 2.0
offset_x = side # margin
offset_y = side
img_w = int(offset_x*2 + (w-1) * (side/2.0) + side + 0.5)
img_h = int(offset_y*2 + h * h_tri + 0.5)
img = Image.new('RGB', (img_w, img_h), color_dict.get('floor', 'white'))
d = ImageDraw.Draw(img)
def cell_vertices(x,y):
cx = offset_x + (x - min_x) * (side/2.0)
cy = offset_y + (y - min_y) * h_tri
up = ((x + y) % 2 == 0)
if up:
v0 = (cx, cy)
v1 = (cx - side/2.0, cy + h_tri)
v2 = (cx + side/2.0, cy + h_tri)
else:
v0 = (cx, cy + h_tri)
v1 = (cx - side/2.0, cy)
v2 = (cx + side/2.0, cy)
return up, v0, v1, v2
# fill start triangle (0,0) with green floor so the starting cell is visibly marked
if (0, 0) in maze:
_, v0_0, v1_0, v2_0 = cell_vertices(0, 0)
d.polygon([v0_0, v1_0, v2_0], fill=color_dict.get('start', 'green'))
# optional thin grey outlines for each triangle
if show_doors:
for (x,y) in maze:
up, v0, v1, v2 = cell_vertices(x,y)
pts = [v0, v1, v2]
d.polygon(pts, outline=color_dict.get('door', '#B0B0B0'), width=1)
# draw black walls where no passage
for (x,y) in maze:
up, v0, v1, v2 = cell_vertices(x,y)
# map direction offsets to edge vertex pairs
if up:
edge_map = {
(-1,0): (v0, v1), # left
(1,0): (v0, v2), # right
(0,1): (v1, v2), # down/base
}
else:
edge_map = {
(-1,0): (v1, v0), # left
(1,0): (v2, v0), # right
(0,-1): (v1, v2), # up/base
}
for off,verts in edge_map.items():
if off not in maze.get((x,y), []):
d.line([verts[0], verts[1]], fill=color_dict.get('wall', 'black'), width=6)
# force clear exit on end cell (outer side with no neighbor)
# choose end as max by x+y
end = max(maze, key=lambda p: p[0] + p[1])
ex, ey = end
up, v0, v1, v2 = cell_vertices(ex, ey)
if up:
dir_order = [(-1,0),(1,0),(0,1)]
edge_order = [(v0,v1),(v0,v2),(v1,v2)]
else:
dir_order = [(-1,0),(1,0),(0,-1)]
edge_order = [(v1,v0),(v2,v0),(v1,v2)]
for off,edge in zip(dir_order, edge_order):
neigh = (ex + off[0], ey + off[1])
if neigh not in maze:
d.line([edge[0], edge[1]], fill=color_dict.get('floor', 'white'), width=26)
break
# draw path if provided (connect centroids)
if path:
pts = []
for x,y in path:
up, v0, v1, v2 = cell_vertices(x,y)
# centroid average of vertices
cx = (v0[0] + v1[0] + v2[0]) / 3.0
cy = (v0[1] + v1[1] + v2[1]) / 3.0
pts.append((cx, cy))
d.line(pts, fill=color_dict.get('path', 'red'), width=7)
if save_path:
try:
img.save(save_path)
logging.info("Saved triangular maze image to %s", save_path)
except Exception:
logging.exception("Failed to save tri image to %s", save_path)
return img