Thumbnail.

Periodic System v3

Description

This is an evolution of Periodic System v1 and Periodic System v2, and they’re all based on another project of mine called Periodic System. This version bumps the number of elements up to 1024, and orders them in outward spiral. It also attempts to apply relevant colors to heavier element based on the chemical series they would be assumed to fall under, even though it is unlikely these elements could ever exist, at least under normal conditions.

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")

Author

ACJ
51 entries

Stats

Date
Colors16,777,216
Pixels16,777,216
Dimensions4,096 × 4,096
Bytes48,468,984