Thumbnail.

Hydrogenic Orbitals v1

Description

Made with Chatgpt 5, Python 3, Google Colab notebook, and DajaVu Sans.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Orbitals up to n = 8 (204 states), square tiles + fixed 24px DejaVu Sans labels with AA,
with TOP_MARGIN and ROW_SPACING controls, using all 24-bit RGB colors exactly once.
"""

import math, time, numpy as np
from PIL import Image, ImageDraw, ImageFont

# ----------------------------
# Layout controls
# ----------------------------
WIDTH, HEIGHT = 4096, 4096
GRID_COLS, GRID_ROWS = 17, 12
TOP_MARGIN   = 40   # px – adds empty space at the very top
ROW_SPACING  = 20   # px – uniform gap between rows (affects whitespace areas)

# ----------------------------
# Physics / render controls
# ----------------------------
N_MAX = 8
Z_SAMPLES = 17
GAMMA = 0.70
BLOCK_SIZE = 262144
SEED = 1337

# Typography (fixed)
FONT_SIZE = 24
FONT_PATHS = [
    "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
    "/usr/local/share/fonts/DejaVuSans.ttf",
    "/usr/share/fonts/truetype/dejavu/DejaVuSans-Book.ttf",
    "DejaVuSans.ttf",
]
LABEL_VERT_PAD   = 3    # top padding inside the label band
LABEL_SIDE_PAD   = 8    # horizontal padding (still centered)
LABEL_BOX_HEIGHT = 36   # fixed label band height (fits 24px + pads)
LABEL_GAP        = 6    # gap between tile bottom and label band

np.random.seed(SEED)

# ----------------------------
# Math (no SciPy)
# ----------------------------
def double_factorial(n: int) -> int:
    if n <= 0: return 1
    res = 1
    for k in range(n, 0, -2): res *= k
    return res

def assoc_legendre(l: int, m: int, x: np.ndarray) -> np.ndarray:
    m = abs(m)
    if l < m: return np.zeros_like(x, np.float32)
    x = x.astype(np.float32)
    Pmm = ((-1)**m) * double_factorial(2*m - 1) * (1.0 - x*x)**(0.5*m)
    if l == m: return Pmm
    Pm1m = x * (2*m + 1) * Pmm
    if l == m+1: return Pm1m
    p2, p1 = Pmm, Pm1m
    for L in range(m+2, l+1):
        p = ((2*L-1)*x*p1 - (L+m-1)*p2) / (L-m)
        p2, p1 = p1, p
    return p1

def real_sph_harm_sq(l: int, m: int, cos_theta: np.ndarray, phi: np.ndarray) -> np.ndarray:
    mabs = abs(m)
    num = math.factorial(l - mabs); den = math.factorial(l + mabs)
    Nlm = math.sqrt((2*l + 1)/(4*math.pi) * (num/den))
    Plm = assoc_legendre(l, mabs, cos_theta)
    if m == 0:
        Y = Nlm * Plm
    else:
        fac = math.sqrt(2.0) * Nlm * Plm
        Y = fac * (np.cos(mabs*phi) if m>0 else np.sin(mabs*phi))
    return (Y*Y).astype(np.float32)

def radial_envelope(n: int, l: int, r: np.ndarray) -> np.ndarray:
    rr = np.clip(r, 0.0, 1.0).astype(np.float32)
    nr = max(0, n - l - 1)
    cent = np.power(rr + 1e-6, 2*l)
    k = 3.0 / max(1, n)
    expo = np.exp(-2.0 * k * rr)
    nodes = np.cos(np.pi * nr * rr)**2 if nr>0 else 1.0
    return (cent * expo * nodes).astype(np.float32)

def tile_orbital_intensity(n: int, l: int, m: int, side: int, z_samples: int) -> np.ndarray:
    xs = np.linspace(-1.0, 1.0, side, dtype=np.float32)
    ys = np.linspace(-1.0, 1.0, side, dtype=np.float32)
    X, Y = np.meshgrid(xs, ys)
    intensity = np.zeros((side, side), dtype=np.float32)
    zs = np.linspace(-1.0, 1.0, z_samples, dtype=np.float32)
    XY2 = X*X + Y*Y
    phi = (np.arctan2(Y, X) + 2*np.pi) % (2*np.pi)
    for z in zs:
        r2 = XY2 + z*z
        inside = r2 <= 1.0
        r = np.sqrt(np.maximum(r2, 1e-9))
        cos_theta = np.zeros_like(r, np.float32); cos_theta[inside] = z / r[inside]
        A = np.zeros_like(r, np.float32); A[inside] = real_sph_harm_sq(l, m, cos_theta[inside], phi[inside])
        R = np.zeros_like(r, np.float32); R[inside] = radial_envelope(n, l, r[inside])
        intensity += A * R
    if intensity.max() > 0: intensity /= intensity.max()
    return intensity.astype(np.float32)

# ----------------------------
# Font + AA helpers
# ----------------------------
def load_dejavu(size: int) -> ImageFont.FreeTypeFont:
    last_err = None
    for p in FONT_PATHS:
        try:
            f = ImageFont.truetype(p, size)
            if hasattr(f, "getmetrics"):
                return f
        except Exception as e:
            last_err = e
            continue
    raise RuntimeError(
        "DejaVu Sans not found. Install DejaVuSans.ttf and update FONT_PATHS. "
        f"Last error: {last_err}"
    )

def render_text_alpha(text: str, max_w: int, max_h: int, side_pad: int, top_pad: int) -> np.ndarray:
    """Fixed 24px rendering; top-aligned inside the label band. Returns uint8 alpha array."""
    font = load_dejavu(FONT_SIZE)
    # measure
    scratch = Image.new("L", (max_w, max_h), 0)
    drw = ImageDraw.Draw(scratch)
    bbox = drw.textbbox((0, 0), text, font=font, anchor="lt")
    w = bbox[2] - bbox[0]
    # horizontal center; vertical top align
    x = max(0, (max_w - w)//2)
    y = max(0, top_pad)
    img = Image.new("L", (max_w, max_h), 0)
    drw = ImageDraw.Draw(img)
    drw.text((x, y), text, fill=255, font=font)
    return np.array(img, dtype=np.uint8)

# ----------------------------
# Layout helpers
# ----------------------------
def split_evenly(total: int, parts: int):
    base = total // parts; rem = total % parts
    return [base + 1 if i < rem else base for i in range(parts)]

def enumerate_orbitals(n_max: int):
    for n in range(1, n_max+1):
        for l in range(0, n):
            for m in range(-l, l+1):
                yield (n, l, m)

# ----------------------------
# Color space + matching
# ----------------------------
def rgb_cube_arrays():
    r = np.repeat(np.arange(256, dtype=np.uint16), 256*256)
    g = np.tile(np.repeat(np.arange(256, dtype=np.uint16), 256), 256)
    b = np.tile(np.arange(256, dtype=np.uint16), 256*256)
    Y = (0.2126*r + 0.7152*g + 0.0722*b).astype(np.float32)
    rf = (r/255.0).astype(np.float32); gf = (g/255.0).astype(np.float32); bf = (b/255.0).astype(np.float32)
    maxc = np.maximum(np.maximum(rf, gf), bf); minc = np.minimum(np.minimum(rf, gf), bf)
    v = maxc; delta = maxc - minc
    s = np.zeros_like(v); nz = v > 0; s[nz] = delta[nz] / v[nz]
    h = np.zeros_like(v); mask = delta > 1e-20
    mr = mask & (maxc == rf); mg = mask & (maxc == gf); mb = mask & (maxc == bf)
    h[mr] = ((gf[mr] - bf[mr]) / delta[mr]) % 6.0
    h[mg] = ((bf[mg] - rf[mg]) / delta[mg]) + 2.0
    h[mb] = ((rf[mb] - gf[mb]) / delta[mb]) + 4.0
    h = (h / 6.0) % 1.0
    return r.astype(np.uint8), g.astype(np.uint8), b.astype(np.uint8), h, s, v, Y

def circ_dist(a: np.ndarray, b: float) -> np.ndarray:
    d = np.abs(a - b); return np.minimum(d, 1.0 - d)

def pref_metric_for_group(h: np.ndarray, s: np.ndarray, L: int) -> np.ndarray:
    if L == 0: return (s + 0.02 * circ_dist(h, 0.5)).astype(np.float32)
    centers = {1:[0.00,0.08,0.17], 2:[0.33,0.45], 3:[0.66,0.83]}.get(L, [0.25,0.58,0.92])
    d = circ_dist(h, centers[0])
    for c in centers[1:]: d = np.minimum(d, circ_dist(h, c))
    return (d - 0.05*s).astype(np.float32)

# ----------------------------
# Build intensities + masks + label alpha (with TOP_MARGIN & ROW_SPACING)
# ----------------------------
def build_canvas_intensity_masks_labels():
    # Column widths stay evenly split
    col_widths = split_evenly(WIDTH, GRID_COLS)
    x_off = np.cumsum([0] + col_widths[:-1])

    # Row heights with explicit top margin and fixed spacing
    BOTTOM_MARGIN = ROW_SPACING  # guarantee at least this much
    usable_height = HEIGHT - TOP_MARGIN - BOTTOM_MARGIN - ROW_SPACING * (GRID_ROWS - 1)
    row_heights = split_evenly(usable_height, GRID_ROWS)

    # y offsets: start at TOP_MARGIN, then add row height + spacing per row
    y_off = [TOP_MARGIN]
    for rh in row_heights[:-1]:
      y_off.append(y_off[-1] + rh + ROW_SPACING)
    y_off = np.array(y_off)


    # Square tile side must fit width and (row height - label band - gap)
    side_by_row = [max(1, rh - LABEL_BOX_HEIGHT - LABEL_GAP) for rh in row_heights]
    side = min(min(side_by_row), min(col_widths))

    I = np.zeros((HEIGHT, WIDTH), np.float32)
    Lgroup = np.full((HEIGHT, WIDTH), 255, np.uint8)      # ℓ for tiles
    tile_mask = np.zeros((HEIGHT, WIDTH), bool)
    label_alpha = np.zeros((HEIGHT, WIDTH), np.uint8)     # AA for labels

    orbitals = list(enumerate_orbitals(N_MAX))
    assert len(orbitals) == GRID_COLS * GRID_ROWS

    idx = 0
    for r in range(GRID_ROWS):
        for c in range(GRID_COLS):
            n, l, m = orbitals[idx]
            rh = row_heights[r]; cw = col_widths[c]
            y0, y1 = y_off[r], y_off[r] + rh
            x0, x1 = x_off[c], x_off[c] + cw

            # Tile packed at the TOP of the row area
            ys = y0
            ye = ys + side
            xs = x0 + (cw - side)//2
            xe = xs + side

            # Orbital intensity
            tile = tile_orbital_intensity(n, l, m, side, Z_SAMPLES)
            I[ys:ye, xs:xe] = tile
            Lgroup[ys:ye, xs:xe] = l
            tile_mask[ys:ye, xs:xe] = True

            # Label band directly under the tile
            lbl_y0 = ye + LABEL_GAP
            lbl_y1 = lbl_y0 + LABEL_BOX_HEIGHT
            text = f"n={n} ℓ={l} m={m if m>=0 else '−'+str(abs(m))}"
            alpha = render_text_alpha(
                text=text,
                max_w=cw,
                max_h=LABEL_BOX_HEIGHT,
                side_pad=LABEL_SIDE_PAD,
                top_pad=LABEL_VERT_PAD
            )
            la = label_alpha[lbl_y0:lbl_y1, x0:x1]
            label_alpha[lbl_y0:lbl_y1, x0:x1] = np.maximum(la, alpha)

            idx += 1

    # Normalize/gamma over tile pixels only
    tile_vals = I[tile_mask]; vmax = tile_vals.max() if tile_vals.size else 0.0
    if vmax > 0: I[tile_mask] = (tile_vals / vmax) ** GAMMA
    return I, Lgroup, tile_mask, label_alpha, side

# ----------------------------
# Color assignment (labels → orbitals → whitespace)
# ----------------------------
def rgb_match_and_render(I: np.ndarray, Lgroup: np.ndarray, tile_mask: np.ndarray, label_alpha: np.ndarray):
    H, W = I.shape; N = H*W
    I_flat = I.reshape(-1); L_flat = Lgroup.reshape(-1)
    tmask = tile_mask.reshape(-1); la = label_alpha.reshape(-1)

    lbl_idx = np.nonzero(la > 0)[0]
    orb_idx = np.nonzero(tmask)[0]
    wht_idx = np.nonzero(~(tmask | (la > 0)))[0]
    N_lbl, N_orb, N_wht = len(lbl_idx), len(orb_idx), len(wht_idx)
    print(f"Orbital pixels: {N_orb:,} | Label pixels: {N_lbl:,} | Whitespace: {N_wht:,}")

    rC, gC, bC, hC, sC, vC, YC = rgb_cube_arrays()
    assert rC.size == N

    col_order = np.argsort(YC, kind="stable")

    # Labels: darkest → highest alpha (AA)
    col_lbl = col_order[:N_lbl]
    rest = col_order[N_lbl:]
    sort_lbl = np.argsort(-la[lbl_idx], kind="stable")
    lbl_sorted = lbl_idx[sort_lbl]
    outR = np.empty(N, np.uint8); outG = np.empty(N, np.uint8); outB = np.empty(N, np.uint8)
    outR[lbl_sorted] = rC[col_lbl]; outG[lbl_sorted] = gC[col_lbl]; outB[lbl_sorted] = bC[col_lbl]

    # Orbitals: global intensity → luminance with ℓ-hue nudges
    col_orb = rest[:N_orb]; col_wht = rest[N_orb:]
    orb_sorted = orb_idx[np.argsort(I_flat[orb_idx], kind="stable")]

    blocks = (N_orb + BLOCK_SIZE - 1) // BLOCK_SIZE
    for bi in range(blocks):
        s = bi*BLOCK_SIZE; e = min((bi+1)*BLOCK_SIZE, N_orb); Lb = e - s
        pix_block = orb_sorted[s:e]; cols_block = col_orb[s:e]
        groups = L_flat[pix_block]; h_block = hC[cols_block]; s_block = sC[cols_block]
        used = np.zeros(Lb, bool); chosen = {}
        uniq, counts = np.unique(groups, return_counts=True)
        for L in uniq[np.argsort(-counts)]:
            need = counts[uniq==L][0]; avail = np.where(~used)[0]
            P = pref_metric_for_group(h_block[avail], s_block[avail], int(L))
            chosen_rel = avail[np.argpartition(P, min(need-1, len(avail)-1))[:need]]
            used[chosen_rel] = True; chosen[int(L)] = chosen_rel
        assert used.all()
        for L in uniq:
            rel = chosen[int(L)]; cols_g = cols_block[rel]; pix_g = pix_block[groups==L]
            if pix_g.size>1: pix_g = pix_g[np.argsort(I_flat[pix_g], kind="stable")]
            if cols_g.size>1: cols_g = cols_g[np.argsort(YC[cols_g], kind="stable")]
            outR[pix_g] = rC[cols_g]; outG[pix_g] = gC[cols_g]; outB[pix_g] = bC[cols_g]

        if (bi+1)%8==0 or bi==blocks-1:
            print(f"  Blocks {bi+1}/{blocks} ({100*(bi+1)/blocks:.1f}%)")

    # Whitespace: leftover colors, shuffled (noise)
    cols_wht_shuf = col_wht[np.random.permutation(len(col_wht))]
    outR[wht_idx] = rC[cols_wht_shuf]; outG[wht_idx] = gC[cols_wht_shuf]; outB[wht_idx] = bC[cols_wht_shuf]

    img = np.stack([outR, outG, outB], axis=1).reshape(HEIGHT, WIDTH, 3)
    return img

# ----------------------------
# Main
# ----------------------------
def main():
    print("Building intensities, masks, and label AA with margins/spacings...")
    I, Lgroup, tile_mask, label_alpha, side = build_canvas_intensity_masks_labels()
    print(f"Tile side = {side}px | Label alpha pixels = {int((label_alpha>0).sum()):,}")

    print("Assigning colors (labels → orbitals → whitespace)...")
    img = rgb_match_and_render(I, Lgroup, tile_mask, label_alpha)

    expected_sum = 65536 * (255 * 256 // 2)  # 2,137,014,272
    chsum = img.reshape(-1, 3).sum(axis=0, dtype=np.uint64)
    print(f"Channel sums R,G,B = {tuple(int(x) for x in chsum)}; expected {expected_sum}")

    ts = time.strftime("%Y%m%d_%H%M%S")
    outfile = f"orbitals_n8_204_allRGB_labels24_margins_{ts}.png"
    print(f"Saving {outfile} ...")
    Image.fromarray(img, "RGB").save(outfile, compress_level=0)
    print("Done.")

if __name__ == "__main__":
    main()

Author

ACJ
65 entries

Stats

Date
Colors16,777,216
Pixels16,777,216
Dimensions4,096 × 4,096
Bytes50,351,853