HexaGrid / utils /hex_grid.py
Surn's picture
Merge from Main repository
6ef117e
# utils/hex_grid.py
import os
import math
from PIL import Image
import cairocffi as cairo
import pangocffi
import pangocairocffi
from PIL import Image, ImageDraw, ImageChops, ImageFont #, ImageColor
#from pilmoji import Pilmoji # Import Pilmoji for handling emojis
from utils.excluded_colors import (
excluded_color_list,
)
from utils.image_utils import multiply_and_blend_images
from utils.color_utils import update_color_opacity, parse_hex_color, draw_text_with_emojis, hex_to_rgb
import random # For random text options
import utils.constants as constants # Import constants
import ast
def calculate_font_size(hex_size, padding=0.6, size_ceil=20, min_font_size=8):
"""
Calculate the font size based on the hexagon size.
Parameters:
hex_size (int): The size of the hexagon side.
padding (float): The fraction of the hex size to use for font size.
Returns:
int or None: The calculated font size or None if hex is too small.
"""
font_size = int(hex_size * padding)
if font_size < min_font_size:
return None # Hex is too small for text
return min(font_size, size_ceil)
def generate_hexagon_grid(hex_size, border_size, input_image=None, image_width=0, image_height=0, start_x=0, start_y=0, end_x=0, end_y=0, rotation=0, background_color="#ede9ac44", border_color="#12165380", fill_hex=True, excluded_color_list=excluded_color_list, filter_color=False, x_spacing=0, y_spacing=0):
if input_image:
image_width, image_height = input_image.size
# Use half hex_size, thus do not double border size
# Calculate the dimensions of the grid before rotation
if rotation != 0:
# Calculate rotated dimensions
# modified to rotate input image and process to reduce calculation errors at edge conditions
rotated_input_image = input_image.rotate(rotation, expand=True)
rotated_image_width, rotated_image_height = rotated_input_image.size
#rotated_image_height = abs(math.ceil((image_height ) * math.sin(math.radians(90 - rotation)) + (image_width ) * math.cos(math.radians(90 - rotation))))
#rotated_image_width = abs(math.ceil((image_width ) * math.sin(math.radians(90 - rotation)) + (image_height ) * math.cos(math.radians(90 - rotation))))
# Adjust hexagon size, spacing adjustments and border for rotation
hex_size = abs(math.ceil((hex_size // 2) * math.sin(math.radians(90 - abs(rotation))) + (hex_size // 2) * math.cos(math.radians(90 - abs(rotation)))))
hex_border_size = math.ceil(border_size * math.sin(math.radians(90 - abs(rotation))) + border_size * math.cos(math.radians(90 - abs(rotation))))
x_spacing = math.ceil(x_spacing * math.sin(math.radians(90 - abs(rotation))) + x_spacing * math.cos(math.radians(90 - abs(rotation))))
y_spacing = math.ceil(y_spacing * math.sin(math.radians(90 - abs(rotation))) + y_spacing * math.cos(math.radians(90 - abs(rotation))))
# Calculate additional width and height due to rotation
additional_width = rotated_image_width - image_width
additional_height = rotated_image_height - image_height
#rotated_input_image.show()
else:
rotated_input_image = input_image
rotated_image_width = image_width
rotated_image_height = image_height
hex_size = hex_size // 2
hex_border_size = border_size
additional_width = 0
additional_height = 0
# Create a new image with white background (adjusted for rotation)
image = Image.new("RGBA", (rotated_image_width, rotated_image_height), background_color)
draw = ImageDraw.Draw(image, mode="RGBA")
hex_width = hex_size * 2
hex_height = hex_size * 2
hex_horizontal_spacing = (hex_width ) + (hex_border_size if hex_border_size < 0 else 0) + x_spacing #* 0.8660254
hex_vertical_spacing = hex_height + (hex_border_size if hex_border_size < 0 else 0) + y_spacing
col = 0
row = 0
def draw_hexagon(x, y, color="#FFFFFFFF", rotation=0, outline_color="#12165380", outline_width=0, sides=6):
side_length = (hex_size * 2) / math.sqrt(3)
points = [(x + side_length * math.cos(math.radians(angle + rotation)), y + side_length * math.sin(math.radians(angle + rotation))) for angle in range(0, 360, (360 // sides))]
draw.polygon(points, fill=color, outline=outline_color, width=max(-5, outline_width))
# Function to range a floating number
def frange(start, stop, step):
i = start
while i < stop:
yield i
i += step
# Draw hexagons
for y in frange(start_y, max(image_height + additional_height, image_height, rotated_image_height) + (end_y - start_y), hex_vertical_spacing):
row += 1
col = 0
for x in frange(start_x, max(image_width + additional_width, image_width, rotated_image_width) + (end_x - start_x), hex_horizontal_spacing):
col += 1
x_offset = hex_width // 2
y_offset = (hex_height // 2) #* 1.15470054342517
# Adjust y_offset for columns 1 and 3 to overlap
if col % 2 == 1:
y_offset -= (hex_height // 2) #* 0.8660254
if rotated_input_image:
# Sample the colors of the pixels in the hexagon, if fill_hex is True
if fill_hex:
sample_size = max(2, math.ceil(math.sqrt(hex_size)))
sample_x = int(x + x_offset)
sample_y = int(y + y_offset)
sample_colors = []
for i in range(-sample_size // 2, sample_size // 2 + 1):
for j in range(-sample_size // 2, sample_size // 2 + 1):
print(f" Progress : {str(min(rotated_image_width - 1,max(1,sample_x + i)))} {str(min(rotated_image_height - 1,max(1,sample_y + j)))}", end="\r")
sample_colors.append(rotated_input_image.getpixel((min(rotated_image_width - 1,max(1,sample_x + i)), min(rotated_image_height - 1,max(1,sample_y + j)))))
if filter_color:
# Filter out the excluded colors
filtered_colors = [color for color in sample_colors if color not in excluded_color_list]
# Ensure there are colors left after filtering
if filtered_colors:
# Calculate the average color of the filtered colors
avg_color = tuple(int(sum(channel) / len(filtered_colors)) for channel in zip(*filtered_colors))
else:
avg_color = excluded_color_list[0] if excluded_color_list else (0,0,0,0)
else:
avg_color = tuple(int(sum(channel) / len(sample_colors)) for channel in zip(*sample_colors))
if avg_color in excluded_color_list:
print(f"color excluded: {avg_color}")
avg_color = (0,0,0,0)
else:
print(f"color found: {avg_color}")
#draw_hexagon(x + x_offset, y + y_offset, color="#{:02x}{:02x}{:02x}{:02x}".format(*avg_color if fill_hex else (0,0,0,0)), outline_color=border_color, outline_width=hex_border_size)
draw_hexagon(x + x_offset, y + y_offset, color="#{:02x}{:02x}{:02x}{:02x}".format(*avg_color), outline_color=border_color, outline_width=hex_border_size)
else:
draw_hexagon(x + x_offset, y + y_offset, color="#000000", outline_color=border_color, outline_width=hex_border_size)
else:
color = "#%02x%02x%02x%02x" % (128, math.ceil(y) % 255, math.ceil(x) % 255, 255) if fill_hex else (0,0,0,0)
draw_hexagon(x + x_offset, y + y_offset, color=color, outline_color=border_color, outline_width=hex_border_size)
if rotation != 0:
#image.show()
# Rotate the final image
rotated_image = image.rotate(-rotation, expand=True)
#rotated_image.show()
bbox = rotated_image.split()[3].getbbox(False)
if bbox:
final_image = rotated_image.crop(bbox).resize((image_width,image_height))
else:
final_image = rotated_image.resize((image_width,image_height))
else:
final_image = image
return final_image
def generate_hexagon_grid_with_text(hex_size, border_size, input_image=None, image_width=0, image_height=0, start_x=0, start_y=0, end_x=0, end_y=0, rotation=0, background_color="#ede9ac44", border_color="#12165380", fill_hex=True, excluded_color_list=excluded_color_list, filter_color=False, x_spacing=0, y_spacing=0,
add_hex_text_option=None, custom_text_list=None, custom_text_color_list=None):
if input_image:
image_width, image_height = input_image.size
# Use half hex_size, thus do not double border size
# Calculate the dimensions of the grid before rotation
if rotation != 0:
# Calculate rotated dimensions
# modified to rotate input image and process to reduce calculation errors at edge conditions
rotated_input_image = input_image.rotate(rotation, expand=True)
rotated_image_width, rotated_image_height = rotated_input_image.size
#rotated_image_height = abs(math.ceil((image_height ) * math.sin(math.radians(90 - rotation)) + (image_width ) * math.cos(math.radians(90 - rotation))))
#rotated_image_width = abs(math.ceil((image_width ) * math.sin(math.radians(90 - rotation)) + (image_height ) * math.cos(math.radians(90 - rotation))))
# Adjust hexagon size, spacing adjustments and border for rotation
hex_size = abs(math.ceil((hex_size // 2) * math.sin(math.radians(90 - abs(rotation))) + (hex_size // 2) * math.cos(math.radians(90 - abs(rotation)))))
hex_border_size = math.ceil(border_size * math.sin(math.radians(90 - abs(rotation))) + border_size * math.cos(math.radians(90 - abs(rotation))))
x_spacing = math.ceil(x_spacing * math.sin(math.radians(90 - abs(rotation))) + x_spacing * math.cos(math.radians(90 - abs(rotation))))
y_spacing = math.ceil(y_spacing * math.sin(math.radians(90 - abs(rotation))) + y_spacing * math.cos(math.radians(90 - abs(rotation))))
# Calculate additional width and height due to rotation
additional_width = rotated_image_width - image_width
additional_height = rotated_image_height - image_height
#rotated_input_image.show()
else:
rotated_input_image = input_image
rotated_image_width = image_width
rotated_image_height = image_height
hex_size = hex_size // 2
hex_border_size = border_size
additional_width = 0
additional_height = 0
# Create a new image with white background (adjusted for rotation)
image = Image.new("RGBA", (rotated_image_width, rotated_image_height), background_color)
font_image = Image.new("RGBA", (rotated_image_width, rotated_image_height), (0,0,0,0))
draw = ImageDraw.Draw(image, mode="RGBA")
hex_width = hex_size * 2
hex_height = hex_size * 2
hex_horizontal_spacing = (hex_width ) + (hex_border_size if hex_border_size < 0 else 0) + x_spacing #* 0.8660254
hex_vertical_spacing = hex_height + (hex_border_size if hex_border_size < 0 else 0) + y_spacing
col = 0
row = 0
## Function to draw optional text
if add_hex_text_option != "None":
# Load the emoji font
font_name = "Segoe UI Emoji"
if os.name == 'nt': # Windows
font_path = "./fonts/seguiemj.ttf"
else: # Other OS (Linux, macOS, etc.)
font_path = "./fonts/seguiemj.ttf"
if not os.path.exists(font_path):
raise FileNotFoundError("Emoji font not found in './fonts' directory.")
# Prepare the text and color lists
text_list = []
color_list = []
if add_hex_text_option == "Playing Cards Sequential":
text_list = constants.cards
color_list = constants.card_colors
elif add_hex_text_option == "Playing Cards Alternate Red and Black":
text_list = constants.cards_alternating
color_list = constants.card_colors_alternating
elif add_hex_text_option == "Custom List":
if custom_text_list:
#text_list = [text.strip() for text in custom_text_list.split(",")]
text_list = ast.literal_eval(custom_text_list) if custom_text_list else None
if custom_text_color_list:
#color_list = [color.strip() for color in custom_text_color_list.split(",")]
color_list = ast.literal_eval(custom_text_color_list) if custom_text_color_list else None
else:
# Coordinates will be generated dynamically
pass
hex_index = -1 # Initialize hex index
def draw_hexagon(x, y, color="#FFFFFFFF", rotation=0, outline_color="#12165380", outline_width=0, sides=6):
side_length = (hex_size * 2) / math.sqrt(3)
points = [(x + side_length * math.cos(math.radians(angle + rotation)), y + side_length * math.sin(math.radians(angle + rotation))) for angle in range(0, 360, (360 // sides))]
draw.polygon(points, fill=color, outline=outline_color, width=max(-5, outline_width))
# Function to range a floating number
def frange(start, stop, step):
i = start
while i < stop:
yield i
i += step
# Draw hexagons
for y in frange(start_y, max(image_height + additional_height, image_height, rotated_image_height) + (end_y - start_y), hex_vertical_spacing):
row += 1
col = 0
for x in frange(start_x, max(image_width + additional_width, image_width, rotated_image_width) + (end_x - start_x), hex_horizontal_spacing):
col += 1
hex_index += 1 # Increment hex index
x_offset = hex_width // 2
y_offset = (hex_height // 2) #* 1.15470054342517
# Adjust y_offset for columns 1 and 3 to overlap
if col % 2 == 1:
y_offset -= (hex_height // 2) #* 0.8660254
if rotated_input_image:
# Sample the colors of the pixels in the hexagon, if fill_hex is True
if fill_hex:
sample_size = max(2, math.ceil(math.sqrt(hex_size)))
sample_x = int(x + x_offset)
sample_y = int(y + y_offset)
sample_colors = []
for i in range(-sample_size // 2, sample_size // 2 + 1):
for j in range(-sample_size // 2, sample_size // 2 + 1):
print(f" Progress : {str(min(rotated_image_width - 1,max(1,sample_x + i)))} {str(min(rotated_image_height - 1,max(1,sample_y + j)))}", end="\r")
sample_colors.append(rotated_input_image.getpixel((min(rotated_image_width - 1,max(1,sample_x + i)), min(rotated_image_height - 1,max(1,sample_y + j)))))
if filter_color:
# Filter out the excluded colors
filtered_colors = [color for color in sample_colors if color not in excluded_color_list]
# Ensure there are colors left after filtering
if filtered_colors:
# Calculate the average color of the filtered colors
avg_color = tuple(int(sum(channel) / len(filtered_colors)) for channel in zip(*filtered_colors))
else:
avg_color = excluded_color_list[0] if excluded_color_list else (0,0,0,0)
else:
avg_color = tuple(int(sum(channel) / len(sample_colors)) for channel in zip(*sample_colors))
if avg_color in excluded_color_list:
print(f"color excluded: {avg_color}")
avg_color = (0,0,0,0)
else:
print(f"color found: {avg_color}")
#draw_hexagon(x + x_offset, y + y_offset, color="#{:02x}{:02x}{:02x}{:02x}".format(*avg_color if fill_hex else (0,0,0,0)), outline_color=border_color, outline_width=hex_border_size)
draw_hexagon(x + x_offset, y + y_offset, color="#{:02x}{:02x}{:02x}{:02x}".format(*avg_color), outline_color=border_color, outline_width=hex_border_size)
else:
draw_hexagon(x + x_offset, y + y_offset, color="#00000000", outline_color=border_color, outline_width=hex_border_size)
else:
color = "#%02x%02x%02x%02x" % (128, math.ceil(y) % 255, math.ceil(x) % 255, 255) if fill_hex else (0,0,0,0)
draw_hexagon(x + x_offset, y + y_offset, color=color, outline_color=border_color, outline_width=hex_border_size)
# Draw text in hexagon
if add_hex_text_option != None:
font_size = calculate_font_size(hex_size, 0.333, 20, 7)
# Skip drawing text if font size is too small
if font_size:
font = ImageFont.truetype(font_path, font_size)
# Determine the text to draw
if add_hex_text_option == "Row-Column Coordinates":
text = f"{col},{row}"
elif add_hex_text_option == "Sequential Numbers":
text = f"{hex_index}"
elif text_list:
text = text_list[hex_index % len(text_list)]
else:
text = None
# Determine the text color
if color_list:
# Extract the opacity from the border color and add to the color_list
if isinstance(border_color, str):
opacity = int(border_color[-2:], 16)
elif isinstance(border_color, tuple) and len(border_color) == 4:
opacity = border_color[3]
else:
opacity = 255 # Default to full opacity if format is unexpected
text_color = update_color_opacity(hex_to_rgb(color_list[hex_index % len(color_list)]), opacity)
else:
# Use border color and opacity
text_color = border_color
#text_color = "#{:02x}{:02x}{:02x}{:02x}".format(*text_color)
# Skip if text is empty
if text != None:
print(f"Drawing Text: {text} color: {text_color} size: {font_size}")
# Calculate text size using Pango
# Create a temporary surface to calculate text size
# temp_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 1, 1)
# temp_context = cairo.Context(temp_surface)
# temp_layout = pangocairocffi.create_layout(temp_context)
# temp_layout._set_text(text)
# temp_desc = pangocffi.FontDescription()
# temp_desc._set_family(font_name)
# temp_desc._set_size(pangocffi.units_from_double(font_size))
# temp_layout._set_font_description(temp_desc)
# pangocairocffi.show_layout(temp_context, temp_layout)
# ink_rect, logical_rect = temp_layout.get_extents()
# text_width = logical_rect.width
# text_height = logical_rect.height
# Calculate position to center text in hexagon
# text_x = x + x_offset - (text_width / 2)
# text_y = y + y_offset - (text_height / 2)
# Calculate position to center text in hexagon
text_x = x + x_offset - (hex_size / 1.75)
text_y = y + y_offset - (hex_size / 1.25)
# Draw the text directly onto the image
font_image = draw_text_with_emojis(
image=font_image,
text=text,
font_color=update_color_opacity(text_color,255),
offset_x=text_x,
offset_y=text_y,
font_name=font_name,
font_size=font_size
)
# # Use Pilmoji to draw text with emojis
# with Pilmoji(image) as pilmoji:
# # Calculate text size
# w, h = pilmoji.getsize(text, font=font)
# # Calculate position to center text in hexagon
# text_x = x + x_offset - w / 2
# text_y = y + y_offset - h / 2
# # Draw text
# pilmoji.text(
# (text_x, text_y),
# text,
# font=font,
# fill=text_color
# )
image.paste(font_image, (0, 0), font_image)
if rotation != 0:
# Rotate the final image
rotated_image = image.rotate(-rotation, expand=True, fillcolor=background_color)
bbox = rotated_image.split()[3].getbbox(alpha_only=False)
if bbox:
# Calculate the size of the rotated image
rotated_width, rotated_height = rotated_image.size
# Calculate the size of the cropped area
box_width = bbox[2] - bbox[0]
box_height = bbox[3] - bbox[1]
box_width_adjust = (box_width - image_width) / 2
bbox_height_adjust = (box_height - image_height) / 2
print(f"\nbbox: {bbox}: size: {(image_width, image_height)} estimated size: {(box_width, box_height)}")
# Calculate adjusted box coordinates
left = bbox[0] + box_width_adjust
upper = bbox[1] + bbox_height_adjust
right = bbox[2] - box_width_adjust
lower = bbox[3] - bbox_height_adjust
# Ensure coordinates are within image bounds
left = max(0, min(left, rotated_width))
upper = max(0, min(upper, rotated_height))
right = max(0, min(right, rotated_width))
lower = max(0, min(lower, rotated_height))
# Ensure the box has positive width and height
if right > left and lower > upper:
# Crop the image using the adjusted box
cropped_image = rotated_image.crop((left, upper, right, lower))
# Resize the cropped image to the desired size
final_image = cropped_image.resize((image_width, image_height))
else:
# If the box is invalid, resize the entire rotated image
final_image = rotated_image.resize((image_width, image_height))
else:
final_image = rotated_image.resize((image_width, image_height))
else:
final_image = image
return final_image
def generate_hexagon_grid_interface(hex_size, border_size, image, start_x, start_y, end_x, end_y, rotation, background_color, border_color, fill_hex, excluded_color_list, filter_color, x_spacing, y_spacing, add_hex_text_option=None, custom_text_list=None, custom_text_color_list=None):
print(f"Generating Hexagon Grid with Parameters: Hex Size: {hex_size}, Border Size: {border_size}, Start X: {start_x}, Start Y: {start_y}, End X: {end_x}, End Y: {end_y}, Rotation: {rotation}, Background Color: {background_color}, Border Color: {border_color}, Fill Hex: {fill_hex}, Excluded Color List: {excluded_color_list}, Filter Color: {filter_color}, X Spacing: {x_spacing}, Y Spacing: {y_spacing}, add Text Option {add_hex_text_option}\n")
hexagon_grid_image = generate_hexagon_grid_with_text(
hex_size=abs(hex_size),
border_size=border_size,
input_image=image,
start_x=start_x,
start_y=start_y,
end_x=end_x,
end_y=end_y,
rotation=rotation,
background_color=background_color,
border_color=border_color,
fill_hex = fill_hex,
excluded_color_list = excluded_color_list,
filter_color = filter_color,
x_spacing = x_spacing if abs(hex_size) > abs(x_spacing) else (hex_size if x_spacing >= 0 else -hex_size),
y_spacing = y_spacing if abs(hex_size) > abs(y_spacing) else (hex_size if y_spacing >= 0 else -hex_size),
add_hex_text_option = add_hex_text_option,
custom_text_list = custom_text_list,
custom_text_color_list= custom_text_color_list
)
overlay_image = multiply_and_blend_images(image, hexagon_grid_image, 50)
return hexagon_grid_image, overlay_image