Thumbnail.

Periodic System v2

Description

This one required quite a bit of back and forth with Chatgpt to get right. It’s not perfect, but is already a lot prettier than Periodic System v1, and—most importantly—also technically correct.

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 = 512
GRID_COLS = 32
CELL_W = WIDTH // GRID_COLS
CELL_H = HEIGHT // ((ELEMENT_COUNT + GRID_COLS - 1) // GRID_COLS)
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)

# === Periodic data ===
ELEMENT_SYMBOLS = [
    "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 = [
    "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]

    # Rule 1: Drop final "i" from "bi"/"tri" if followed by "-ium"
    if parts[-1] in ('bi', 'tri'):
        parts[-1] = parts[-1][:-1]

    # Rule 2: Drop one "n" if "enn" is followed by "nil"
    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]: 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"

# === Canvas setup ===
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)

# === Layout reservation ===
for n in range(1, ELEMENT_COUNT + 1):
    row, col = divmod(n - 1, GRID_COLS)
    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

# === Full RGB space ===
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)

# === Allocate darkest/lightest for labels ===
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

# === Prepare color pool ===
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)))

# === Adjusted hue ranges ===
series_hue_ranges = {
    'transition': (0.00, 0.10),       # widen red-orange band
    '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),
}

# === Precompute pixel demands ===
series_pixel_counts = {s: sum(len(cell_fill_coords[n]) for n in ids)
                       for s, ids in series_element_ids.items()}

series_rgb_pool = defaultdict(list)
unassigned_pool = []

# === Pool classification ===
for c in remaining_sorted:
    h, s, _ = rgb_to_hsv(c)
    matched = False
    for sname, (hmin, hmax) in series_hue_ranges.items():
        if hmin <= h <= hmax:
            series_rgb_pool[sname].append(c)
            matched = True
            break
    if not matched:
        unassigned_pool.append(c)

# === Report available vs needed ===
print("\n--- Color Pool Summary ---")
for series in series_pixel_counts:
    needed = series_pixel_counts.get(series, 0)
    available = len(series_rgb_pool.get(series, []))
    print(f"{series:<16} Needs: {needed:6d} | Available: {available:6d}")

# === Inject overflow into undersized pools ===
used_known_colors = set()
for series, ids in series_element_ids.items():
    if series in series_hue_ranges:
        all_coords = sum((cell_fill_coords[n] for n in ids), [])
        needed = len(all_coords)
        pool = series_rgb_pool[series]
        if len(pool) < needed:
            shortfall = needed - len(pool)
            print(f"⚠️  {series} short by {shortfall} colors — adding overflow from unassigned pool")
            pool.extend(unassigned_pool[:shortfall])
            unassigned_pool = unassigned_pool[shortfall:]
        random.shuffle(pool)
        chunk = pool[:needed]
        used_known_colors.update(chunk)
        for (y, x), c in zip(all_coords, chunk):
            image_array[y, x] = c

# === Fill unknown and superactinide ===
remaining_pool = [c for c in remaining_sorted if c not in used_known_colors and c not in used_colors]
cursor = 0
for series in ['unknown', 'superactinide']:
    for n in series_element_ids.get(series, []):
        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

# === Final report and save ===
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_fast_correct.png")
print("✅ Saved as periodic_table_allrgb_fast_correct.png")

Author

ACJ
41 entries

Stats

Date
Colors16,777,216
Pixels16,777,216
Dimensions4,096 × 4,096
Bytes48,661,795