import numpy as np
from PIL import Image, ImageDraw, ImageFont
import colorsys
from collections import defaultdict
from scipy.ndimage import binary_dilation
import random
# === Constants ===
WIDTH, HEIGHT = 4096, 4096
TOTAL_COLORS = WIDTH * HEIGHT
ELEMENT_COUNT = 1024
GRID_COLS = 32
GRID_ROWS = (ELEMENT_COUNT + GRID_COLS - 1) // GRID_COLS
CELL_W = WIDTH // GRID_COLS
CELL_H = HEIGHT // GRID_ROWS
TEXT_MARGIN = 4
# === Fonts ===
font_small = ImageFont.truetype("DejaVuSans-Bold.ttf", 12)
font_regular = ImageFont.truetype("DejaVuSans-Bold.ttf", 16)
font_bold = ImageFont.truetype("DejaVuSans-Bold.ttf", 32)
# === Known data ===
ELEMENT_SYMBOLS = [ # Z=1 to 118
"H", "He", "Li", "Be", "B", "C", "N", "O", "F", "Ne",
"Na", "Mg", "Al", "Si", "P", "S", "Cl", "Ar", "K", "Ca",
"Sc", "Ti", "V", "Cr", "Mn", "Fe", "Co", "Ni", "Cu", "Zn",
"Ga", "Ge", "As", "Se", "Br", "Kr", "Rb", "Sr", "Y", "Zr",
"Nb", "Mo", "Tc", "Ru", "Rh", "Pd", "Ag", "Cd", "In", "Sn",
"Sb", "Te", "I", "Xe", "Cs", "Ba", "La", "Ce", "Pr", "Nd",
"Pm", "Sm", "Eu", "Gd", "Tb", "Dy", "Ho", "Er", "Tm", "Yb",
"Lu", "Hf", "Ta", "W", "Re", "Os", "Ir", "Pt", "Au", "Hg",
"Tl", "Pb", "Bi", "Po", "At", "Rn", "Fr", "Ra", "Ac", "Th",
"Pa", "U", "Np", "Pu", "Am", "Cm", "Bk", "Cf", "Es", "Fm",
"Md", "No", "Lr", "Rf", "Db", "Sg", "Bh", "Hs", "Mt", "Ds",
"Rg", "Cn", "Nh", "Fl", "Mc", "Lv", "Ts", "Og"
]
ELEMENT_NAMES = [ # Z=1 to 118
"Hydrogen", "Helium", "Lithium", "Beryllium", "Boron", "Carbon", "Nitrogen", "Oxygen", "Fluorine", "Neon",
"Sodium", "Magnesium", "Aluminium", "Silicon", "Phosphorus", "Sulfur", "Chlorine", "Argon", "Potassium", "Calcium",
"Scandium", "Titanium", "Vanadium", "Chromium", "Manganese", "Iron", "Cobalt", "Nickel", "Copper", "Zinc",
"Gallium", "Germanium", "Arsenic", "Selenium", "Bromine", "Krypton", "Rubidium", "Strontium", "Yttrium", "Zirconium",
"Niobium", "Molybdenum", "Technetium", "Ruthenium", "Rhodium", "Palladium", "Silver", "Cadmium", "Indium", "Tin",
"Antimony", "Tellurium", "Iodine", "Xenon", "Caesium", "Barium", "Lanthanum", "Cerium", "Praseodymium", "Neodymium",
"Promethium", "Samarium", "Europium", "Gadolinium", "Terbium", "Dysprosium", "Holmium", "Erbium", "Thulium", "Ytterbium",
"Lutetium", "Hafnium", "Tantalum", "Tungsten", "Rhenium", "Osmium", "Iridium", "Platinum", "Gold", "Mercury",
"Thallium", "Lead", "Bismuth", "Polonium", "Astatine", "Radon", "Francium", "Radium", "Actinium", "Thorium",
"Protactinium", "Uranium", "Neptunium", "Plutonium", "Americium", "Curium", "Berkelium", "Californium", "Einsteinium", "Fermium",
"Mendelevium", "Nobelium", "Lawrencium", "Rutherfordium", "Dubnium", "Seaborgium", "Bohrium", "Hassium", "Meitnerium", "Darmstadtium",
"Roentgenium", "Copernicium", "Nihonium", "Flerovium", "Moscovium", "Livermorium", "Tennessine", "Oganesson"
]
def generate_symbol(n):
if n <= 118: return ELEMENT_SYMBOLS[n - 1]
roots = ['n','u','b','t','q','p','h','s','o','e']
return ''.join(roots[int(d)] for d in str(n)).capitalize()[:3]
def generate_name(n):
if n <= 118: return ELEMENT_NAMES[n - 1]
roots = ['nil','un','bi','tri','quad','pent','hex','sept','oct','enn']
digits = [int(d) for d in str(n)]
parts = [roots[d] for d in digits]
if parts[-1] in ('bi', 'tri'):
parts[-1] = parts[-1][:-1]
for i in range(len(parts) - 1):
if parts[i] == 'enn' and parts[i + 1] == 'nil':
parts[i] = 'en'
return ''.join(parts).capitalize() + "ium"
def generate_weight(n): return f"[{n:.1f}]"
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,119]: return "alkali"
if n in [4,12,20,38,56,88,120]: 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 121 <= n <= 138: return "actinide"
if 139 <= n <= 152: return "transition"
if 153 <= n <= 172: return "post_transition"
if 173 <= n <= 200: return "noble_gas" if n % 18 == 2 else "halogen"
if n > 200: return "unknown"
return "unknown"
# === Spiral grid generator ===
def generate_spiral_grid(cols, rows):
grid = [[-1] * cols for _ in range(rows)]
cx, cy = cols // 2, rows // 2
x, y = cx, cy
dx = [1, 0, -1, 0]
dy = [0, 1, 0, -1]
steps = 1
dir_idx = 0
count = 1
while count <= cols * rows:
for _ in range(2):
for _ in range(steps):
if 0 <= x < cols and 0 <= y < rows:
grid[y][x] = count
count += 1
if count > cols * rows:
return grid
x += dx[dir_idx]
y += dy[dir_idx]
dir_idx = (dir_idx + 1) % 4
steps += 1
return grid
spiral_grid = generate_spiral_grid(GRID_COLS, GRID_ROWS)
position_by_element = {}
for y in range(GRID_ROWS):
for x in range(GRID_COLS):
n = spiral_grid[y][x]
if n != -1 and n <= ELEMENT_COUNT:
position_by_element[n] = (x, y)
# === Reserve layout ===
image_array = np.zeros((HEIGHT, WIDTH, 3), dtype=np.uint8)
used_pixels = np.zeros((HEIGHT, WIDTH), dtype=bool)
label_coords, outline_coords, border_coords = [], [], []
cell_fill_coords = defaultdict(list)
series_element_ids = defaultdict(list)
for n in range(1, ELEMENT_COUNT + 1):
col, row = position_by_element[n]
x0, y0 = col * CELL_W, row * CELL_H
lines = [
(str(n), font_regular),
(generate_symbol(n), font_bold),
(generate_weight(n), font_regular),
(generate_name(n), font_small)
]
y_cursor = y0 + TEXT_MARGIN
for text, font in lines:
mask = Image.new("1", (CELL_W, CELL_H), 0)
draw = ImageDraw.Draw(mask)
draw.text((TEXT_MARGIN, 0), text, font=font, fill=1)
arr = np.array(mask)
outline = binary_dilation(arr) & ~arr
for dy in range(arr.shape[0]):
for dx in range(arr.shape[1]):
px, py = x0 + dx, y_cursor + dy
if 0 <= px < WIDTH and 0 <= py < HEIGHT:
if arr[dy, dx]:
label_coords.append((py, px))
used_pixels[py, px] = True
elif outline[dy, dx]:
outline_coords.append((py, px))
used_pixels[py, px] = True
y_cursor += font.size + 4
for x in range(x0, x0 + CELL_W):
for y in (y0, y0 + CELL_H - 1):
if not used_pixels[y, x]:
border_coords.append((y, x))
used_pixels[y, x] = True
for y in range(y0 + 1, y0 + CELL_H - 1):
for x in (x0, x0 + CELL_W - 1):
if not used_pixels[y, x]:
border_coords.append((y, x))
used_pixels[y, x] = True
series = classify_element(n)
series_element_ids[series].append(n)
for y in range(y0, y0 + CELL_H):
for x in range(x0, x0 + CELL_W):
if not used_pixels[y, x]:
cell_fill_coords[n].append((y, x))
used_pixels[y, x] = True
# === Color allocation ===
all_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 brightness(rgb): return 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]
def rgb_to_hsv(rgb): return colorsys.rgb_to_hsv(rgb[0]/255, rgb[1]/255, rgb[2]/255)
sorted_by_brightness = sorted(all_colors.tolist(), key=brightness)
label_colors = sorted_by_brightness[:len(label_coords)]
outline_colors = sorted_by_brightness[-len(outline_coords):]
border_colors = sorted_by_brightness[len(label_colors):-len(outline_coords)][:len(border_coords)]
used_colors = set(tuple(c) for c in label_colors + outline_colors + border_colors)
for (py, px), c in zip(label_coords, label_colors): image_array[py, px] = c
for (py, px), c in zip(outline_coords, outline_colors): image_array[py, px] = c
for (py, px), c in zip(border_coords, border_colors): image_array[py, px] = c
remaining_colors = [tuple(c) for c in all_colors.tolist() if tuple(c) not in used_colors]
remaining_sorted = sorted(remaining_colors, key=lambda c: (-rgb_to_hsv(c)[1], rgb_to_hsv(c)[0], brightness(c)))
series_hue_ranges = {
'transition': (0.00, 0.10), 'noble_gas': (0.50, 0.60), 'halogen': (0.30, 0.40),
'alkali': (0.90, 1.00), 'alkaline': (0.10, 0.20), 'lanthanide': (0.55, 0.65),
'actinide': (0.65, 0.75), 'post_transition': (0.20, 0.30), 'metalloid': (0.75, 0.85),
'nonmetal': (0.05, 0.10)
}
series_rgb_pool = defaultdict(list)
unassigned_pool = []
for c in remaining_sorted:
h, s, _ = rgb_to_hsv(c)
for sname, (hmin, hmax) in series_hue_ranges.items():
if hmin <= h <= hmax:
series_rgb_pool[sname].append(c)
break
else:
unassigned_pool.append(c)
used_known_colors = set()
for series, ids in series_element_ids.items():
if series in series_hue_ranges:
coords = sum((cell_fill_coords[n] for n in ids), [])
needed = len(coords)
pool = series_rgb_pool[series]
if len(pool) < needed:
pool += unassigned_pool[:needed - len(pool)]
unassigned_pool = unassigned_pool[needed - len(pool):]
random.shuffle(pool)
for (y, x), c in zip(coords, pool[:needed]):
image_array[y, x] = c
used_known_colors.add(c)
remaining_pool = [c for c in remaining_sorted if c not in used_known_colors and c not in used_colors]
cursor = 0
for n in series_element_ids.get("unknown", []):
coords = cell_fill_coords[n]
chunk = remaining_pool[cursor:cursor + len(coords)]
cursor += len(coords)
random.shuffle(chunk)
for (y, x), c in zip(coords, chunk):
image_array[y, x] = c
used_pixel_count = np.count_nonzero(np.any(image_array != 0, axis=2))
print(f"\n? Total used pixels: {used_pixel_count} / {TOTAL_COLORS}")
Image.fromarray(image_array, 'RGB').save("periodic_table_allrgb_spiral.png")
print("✅ Saved as periodic_table_allrgb_spiral.png")