import numpy as np
from PIL import Image, ImageDraw, ImageFont
import colorsys
from collections import defaultdict
# === Constants ===
WIDTH, HEIGHT = 4096, 4096
TOTAL_COLORS = WIDTH * HEIGHT
ELEMENT_COUNT = 512
GRID_COLS = 32
CELL_W = WIDTH // GRID_COLS
CELL_H = HEIGHT // ((ELEMENT_COUNT + GRID_COLS - 1) // GRID_COLS)
TEXT_MARGIN = 4 # Increased margin for label padding
# === Generate and sort all RGB colors ===
colors = np.array([[r, g, b] for r in range(256)
for g in range(256)
for b in range(256)], dtype=np.uint8)
def rgb_to_hsb(c):
r, g, b = [x / 255.0 for x in c]
return colorsys.rgb_to_hsv(r, g, b)
colors_sorted_hsb = sorted(colors, key=rgb_to_hsb)
colors_sorted_brightness = sorted(colors, key=lambda rgb: 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2])
# === Font setup ===
try:
font = ImageFont.truetype("PixelOperator8.ttf", 12)
except:
font = ImageFont.load_default()
# === Symbol generator ===
def generate_element_symbol(n):
roots = ['n', 'u', 'b', 't', 'q', 'p', 'h', 's', 'o', 'e']
digits = list(map(int, str(n)))
return ''.join(roots[d] for d in digits)[:3].capitalize()
# === Chemical classification ===
def classify_element(n):
if n == 1: return "nonmetal"
if n in [2, 10, 18, 36, 54, 86, 118]: return "noble_gas"
if n in [5, 14, 32, 33, 51, 52, 84]: return "metalloid"
if n in [9, 17, 35, 53, 85, 117]: return "halogen"
if n in [6, 7, 8, 15, 16, 34]: return "nonmetal"
if n in [3, 11, 19, 37, 55, 87]: return "alkali"
if n in [4, 12, 20, 38, 56, 88]: return "alkaline"
if 57 <= n <= 71: return "lanthanide"
if 89 <= n <= 103: return "actinide"
if 104 <= n <= 112 or (21 <= n <= 30) or (39 <= n <= 48) or (72 <= n <= 80): return "transition"
if (13 <= n <= 16) or (31 <= n <= 34) or (49 <= n <= 52) or (81 <= n <= 84): return "post_transition"
if n >= 119: return "superactinide"
return "unknown"
element_categories = [classify_element(n) for n in range(1, ELEMENT_COUNT + 1)]
unique_categories = sorted(set(element_categories))
category_counts = {cat: element_categories.count(cat) for cat in unique_categories}
# === Reserve image canvas ===
image_array = np.zeros((HEIGHT, WIDTH, 3), dtype=np.uint8)
used_pixels = np.zeros((HEIGHT, WIDTH), dtype=bool)
text_coords, border_coords = [], []
element_by_category = defaultdict(list)
# === Step 1: Reserve text label coordinates ===
for n in range(1, ELEMENT_COUNT + 1):
row, col = (n - 1) // GRID_COLS, (n - 1) % GRID_COLS
x0, y0 = col * CELL_W, row * CELL_H
symbol = generate_element_symbol(n)
label = f"{n}\n{symbol}"
y_cursor = y0 + 4
element_by_category[classify_element(n)].append((n, x0, y0))
for line in label.split('\n'):
bbox = font.getbbox(line)
x_min, y_min, x_max, y_max = bbox
width = x_max - x_min + 2 * TEXT_MARGIN
height = y_max - y_min + 2 * TEXT_MARGIN
x_cursor = x0 + (CELL_W - width) // 2
mask = Image.new("1", (width, height), 0)
draw = ImageDraw.Draw(mask)
draw.text((TEXT_MARGIN - x_min, TEXT_MARGIN - y_min), line, fill=1, font=font)
mask_array = np.array(mask)
for dy in range(height):
for dx in range(width):
if mask_array[dy, dx] == 1:
px, py = x_cursor + dx, y_cursor + dy
if 0 <= px < WIDTH and 0 <= py < HEIGHT:
text_coords.append((py, px))
used_pixels[py, px] = True
y_cursor += height
# === Step 2: Reserve 1px cell borders ===
for n in range(1, ELEMENT_COUNT + 1):
row, col = (n - 1) // GRID_COLS, (n - 1) % GRID_COLS
x0, y0 = col * CELL_W, row * CELL_H
x1, y1 = x0 + CELL_W, y0 + CELL_H
for x in range(x0, x1):
for y in (y0, y1 - 1): # top and bottom
if not used_pixels[y, x]:
border_coords.append((y, x))
used_pixels[y, x] = True
for y in range(y0 + 1, y1 - 1):
for x in (x0, x1 - 1): # left and right
if not used_pixels[y, x]:
border_coords.append((y, x))
used_pixels[y, x] = True
# === Step 3: Assign RGBs to text and borders ===
colors_for_text = colors_sorted_brightness[:len(text_coords)]
colors_for_border = colors_sorted_brightness[len(text_coords):len(text_coords) + len(border_coords)]
for i, (py, px) in enumerate(text_coords):
image_array[py, px] = colors_for_text[i]
for i, (py, px) in enumerate(border_coords):
image_array[py, px] = colors_for_border[i]
used_color_set = set(tuple(c) for c in colors_for_text + colors_for_border)
# === Step 4: Fill remaining cell pixels, clustered by series ===
remaining_colors = [tuple(c) for c in colors_sorted_hsb if tuple(c) not in used_color_set]
color_index = 0
for category in unique_categories:
for n, x0, y0 in element_by_category[category]:
for y in range(y0, y0 + CELL_H):
for x in range(x0, x0 + CELL_W):
if not used_pixels[y, x]:
image_array[y, x] = remaining_colors[color_index]
used_pixels[y, x] = True
color_index += 1
# === Final Validation ===
#assert np.count_nonzero(used_pixels) == TOTAL_COLORS, "❌ Pixel count mismatch"
#assert color_index + len(text_coords) + len(border_coords) == TOTAL_COLORS, "❌ Color count mismatch"
print(np.count_nonzero(used_pixels))
print(color_index + len(text_coords) + len(border_coords))
# === Save image ===
Image.fromarray(image_array, 'RGB').save("periodic_table_series_clustered.png")
print("✅ Saved as 'periodic_table_series_clustered.png'")