

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")
Date | |
---|---|
Colors | 16,777,216 |
Pixels | 16,777,216 |
Dimensions | 4,096 × 4,096 |
Bytes | 48,661,795 |