

I took another stab at generating a Penrose diagram using Chatgpt 5. The resulting Python script was executed in a Google Colab notebook:
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Penrose (conformal) diagram using every 24-bit RGB color exactly once. This version includes: • STIX Two Math auto-download & use for all labels (covers ? and en dashes). • Superscripts (+, −, 0) composed with font metrics (no clipping, no tofu). • MANY semantic labels (?±, i±/i0, H± along horizons, ER bridge, etc.). • Crisp dark outline around the Penrose diamond (|x'| + |y'| = 1). • EVEN, matched r=const curves in all four regions (II/IV mirror I/III). • Center-bright radial gradients per region + low-chroma ripple background. • Sub-pixel horizon margin (no offsets). • Strict exclusive layer partitioning ⇒ exact all-RGB accounting. Maximally extended Schwarzschild spacetime (Kruskal–Szekeres), units G=c=1, M=1. """ import os, time, math, pathlib from datetime import datetime import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageFilter, Image as PILImage # Optional network fetch for font (works in Colab) try: import requests except Exception: requests = None # graceful fallback if blocked # ---------------------------- # Canvas & appearance # ---------------------------- W = H = 4096 MARGIN_FRAC = 0.06 LINE_PX = 4 # horizons/singularities thickness GRID_LINE_PX = 2 FRAME_PX = 6 # dark outline around the diamond SING_MARGIN_FRAC = 0.08 LABEL_FONT_SIZE = 48 LABEL_STROKE_PIX = 2 VERIFY_UNIQUENESS = True OUTPUT_DIR = "." # ---------------------------- # Grid controls # ---------------------------- DRAW_GRID = True GRID_SAMPLES = 1700 KRUSKAL_K = 0.60 # r=const families N_R_EXT = 14 # r>2 (Regions I & III) N_R_INT = N_R_EXT # r<2 (Regions II & IV), must match exterior Y_REF_INT = 0.50 # interior reference level (|y'|), mid-wedge for robust spread # Constant-t lines N_T = 13 THETA_MAX_DEG = 42.0 # Optional non-radial null geodesics (OFF by default) DRAW_CURVED_NULL = False GEO_CURVE_PX = 2 GEODESIC_IMPACT_PARAMS = [5.6, 6.5, 8.0, 11.0, 16.0] GEODESIC_RMAX = 60.0 GEODESIC_SAMPLES = 1800 COMPACT_K_NULL = 0.40 # Region hue personalities REGION_HUE_PREFS = { "I": [(210, 300)], # blue–violet "III": [(20, 70)], # orange–yellow "II": [(300, 360), (0, 20)], # magenta–red "IV": [(70, 200)], # green–cyan } MIN_CHROMA_FOR_REGIONS = 25.0 # ---------------------------- # sRGB → LAB helpers (D65) # ---------------------------- def srgb_to_linear(c): a = 0.055 return np.where(c <= 0.04045, c/12.92, ((c + a) / (1 + a))**2.4) def rgb01_to_lab(rgb01): M = np.array([[0.4124564, 0.3575761, 0.1804375], [0.2126729, 0.7151522, 0.0721750], [0.0193339, 0.1191920, 0.9503041]]) Xn, Yn, Zn = 0.95047, 1.0, 1.08883 lin = srgb_to_linear(rgb01) XYZ = np.dot(lin, M.T) X = XYZ[...,0] / Xn Y = XYZ[...,1] / Yn Z = XYZ[...,2] / Zn eps = (6/29)**3 kappa = (29/3)**2/3 def f(t): return np.where(t > eps, np.cbrt(t), (kappa*t + 16)/116) fX, fY, fZ = f(X), f(Y), f(Z) L = 116*fY - 16 a = 500*(fX - fY) b = 200*(fY - fZ) return L, a, b def atan2_deg(y, x): ang = np.degrees(np.arctan2(y, x)) return np.where(ang < 0, ang + 360.0, ang) # ---------------------------- # Palette allocator (64^3 bins; 64 colors/bin) # ---------------------------- class BinPalette: def __init__(self): rb = np.arange(64, dtype=np.uint16) gb = np.arange(64, dtype=np.uint16) bb = np.arange(64, dtype=np.uint16) Rb, Gb, Bb = np.meshgrid(rb, gb, bb, indexing='ij') Rc = (Rb*4 + 2).astype(np.float32) Gc = (Gb*4 + 2).astype(np.float32) Bc = (Bb*4 + 2).astype(np.float32) rgb01 = np.stack([Rc, Gc, Bc], axis=-1) / 255.0 L, a, b = rgb01_to_lab(rgb01) C = np.sqrt(a*a + b*b) h = atan2_deg(b, a) self.L = L.ravel(); self.C = C.ravel(); self.h = h.ravel() self.bin_count = 64**3 self.consumed = np.zeros(self.bin_count, dtype=np.uint8) # intra-bin sequence (Morton-like) to spread subcolors offs = [] for dr in range(4): for dg in range(4): for db in range(4): morton = ((dr & 1) | ((dg & 1) << 1) | ((db & 1) << 2) | (((dr>>1)&1) << 3) | (((dg>>1)&1) << 4) | (((db>>1)&1) << 5)) offs.append((morton, dr, dg, db)) offs.sort(key=lambda t: t[0]) self.intra = np.array([[dr,dg,db] for _,dr,dg,db in offs], dtype=np.uint8) self._rb = np.repeat(np.arange(64, dtype=np.uint16), 64*64) self._gb = np.tile(np.repeat(np.arange(64, dtype=np.uint16), 64), 64) self._bb = np.tile(np.arange(64, dtype=np.uint16), 64*64) self._dark_bins = np.argsort(self.L) self._light_bins = np.argsort(-self.L) self._gray_bins = np.lexsort((self.L, self.C)) def _bin_base_rgb(self, bin_id): return np.array([int(self._rb[bin_id])*4, int(self._gb[bin_id])*4, int(self._bb[bin_id])*4], dtype=np.uint16) def _take_from_bin(self, bin_id, n): used = int(self.consumed[bin_id]) if used >= 64: return None take = min(n, 64 - used) base = self._bin_base_rgb(bin_id) sl = self.intra[used:used+take].astype(np.uint16) cols = (base[None,:] + sl).astype(np.uint8) self.consumed[bin_id] = used + take return cols def allocate_from_bins(self, order, count): out = np.empty((count,3), dtype=np.uint8); filled = 0 for bin_id in order: if filled >= count: break got = self._take_from_bin(bin_id, count - filled) if got is None: continue n = got.shape[0]; out[filled:filled+n] = got; filled += n if filled != count: for bin_id in range(self.bin_count): if filled >= count: break got = self._take_from_bin(bin_id, count - filled) if got is None: continue n = got.shape[0]; out[filled:filled+n] = got; filled += n assert filled == count, f"Allocator shortage: {filled}/{count}" return out def allocate_dark(self, count): return self.allocate_from_bins(self._dark_bins, count) def allocate_light(self, count): return self.allocate_from_bins(self._light_bins, count) def allocate_low_chroma(self, count): return self.allocate_from_bins(self._gray_bins, count) def bins_by_hue_preference(self, ranges, min_chroma=0.0): h, C, L = self.h, self.C, self.L def dist_to_ranges(hv): best = 180.0 for a,b in ranges: if a<=b: if a<=hv<=b: return 0.0 best = min(best, min(abs(hv-a), abs(hv-b))) else: if (hv>=a) or (hv<=b): return 0.0 d1 = (a - hv) % 360.0; d2 = (hv - b) % 360.0 best = min(best, min(d1, d2)) return best d = np.vectorize(dist_to_ranges)(h) order = np.lexsort((np.abs(L-50.0), -C, d)) if min_chroma>0: hi = np.where(C[order] >= min_chroma)[0] lo = np.where(C[order] < min_chroma)[0] order = np.concatenate([order[hi], order[lo]]) return order # ---------------------------- # Geometry & masks (diamond + regions + horizons) # ---------------------------- def make_coordinate_grids(w, h, margin_frac): y, x = np.mgrid[0:h, 0:w].astype(np.float32) cx, cy = (w - 1) / 2.0, (h - 1) / 2.0 half = min(w, h) * (0.5 - margin_frac) xp = (x - cx) / half yp = (cy - y) / half return xp, yp, half, cx, cy def make_masks(w, h, margin_frac, line_px, sing_margin_frac): xp, yp, half, cx, cy = make_coordinate_grids(w, h, margin_frac) diamond = (np.abs(xp) + np.abs(yp) <= 1.0) s1 = yp - xp; s2 = yp + xp t = line_px / half horizons = ((np.abs(s1) <= t) | (np.abs(s2) <= t)) & diamond reg_II = (s1 > t) & (s2 > t) & diamond reg_IV = (s1 < -t) & (s2 < -t) & diamond reg_I = (s1 < -t) & (s2 > t) & diamond reg_III = (s1 > t) & (s2 < t) & diamond y_top = 1.0 - sing_margin_frac y_bot = -1.0 + sing_margin_frac singular = ((np.abs(yp - y_top) <= t) | (np.abs(yp - y_bot) <= t)) & diamond outside = ~diamond return { "diamond": diamond, "I": reg_I, "II": reg_II, "III": reg_III, "IV": reg_IV, "horizons": horizons, "singular": singular, "outside": outside, "xp": xp, "yp": yp, "half": half, "cx": cx, "cy": cy } # ---------------------------- # Font: STIX Two Math (auto-download) + robust fallbacks # ---------------------------- STIX_MATH_URLS = [ # CTAN mirrors hosting the OTF "https://mirrors.ibiblio.org/pub/mirrors/CTAN/fonts/stix2-otf/STIXTwoMath-Regular.otf", "https://ftp.jaist.ac.jp/pub/CTAN/fonts/stix2-otf/STIXTwoMath-Regular.otf", "https://mirrors.ctan.org/fonts/stix2-otf/otf/STIXTwoMath-Regular.otf", ] def ensure_stix_two_math_otf(local_dir="fonts", filename="STIXTwoMath-Regular.otf"): """Download STIX Two Math OTF if missing; return filesystem path.""" pdir = pathlib.Path(local_dir); pdir.mkdir(parents=True, exist_ok=True) fpath = pdir / filename if fpath.exists() and fpath.stat().st_size > 0: return str(fpath) if requests is None: raise RuntimeError("requests not available to fetch STIXTwoMath-Regular.otf") last_err = None for url in STIX_MATH_URLS: try: r = requests.get(url, stream=True, timeout=30) r.raise_for_status() with open(fpath, "wb") as out: for chunk in r.iter_content(1 << 14): if chunk: out.write(chunk) if fpath.stat().st_size > 0: return str(fpath) except Exception as e: last_err = e raise RuntimeError(f"Could not fetch STIXTwoMath-Regular.otf. Last error: {last_err}") def load_font_main(size): """Prefer STIX Two Math (download if needed), then robust fallbacks.""" try: path = ensure_stix_two_math_otf() return ImageFont.truetype(path, size) except Exception: for p in [ "/usr/share/fonts/opentype/stix-two/STIXTwoMath-Regular.otf", "/usr/share/fonts/truetype/noto/NotoSansMath-Regular.ttf", "/usr/share/fonts/opentype/xits-math/XITSMath-Regular.otf", "/usr/share/fonts/opentype/lm-math/LatinModernMath-Regular.otf", "/usr/share/fonts/truetype/freefont/FreeSerif.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf", "DejaVuSerif.ttf", ]: try: return ImageFont.truetype(p, size) except Exception: continue return ImageFont.load_default() # ---------------------------- # Labels → fill & stroke masks (rotations + composed superscripts) # ---------------------------- def place_labels_masks(w, h, masks, font_size=48, stroke_px=2): """Return (fill_mask, stroke_mask) as boolean arrays, using STIX Two Math.""" img = Image.new("L", (w, h), 0) drw = ImageDraw.Draw(img) font_main = load_font_main(font_size) cx, cy, half = masks["cx"], masks["cy"], masks["half"] def xy_from_norm(xp, yp): return (int(round(cx + xp * half)), int(round(cy - yp * half))) def draw_text(text, xp, yp, anchor="mm", angle=0, font=None): """Plain run (no superscript composition).""" if font is None: font = font_main if angle == 0: drw.text(xy_from_norm(xp, yp), text, fill=255, font=font, anchor=anchor) else: pad = max(12, int(0.45 * font.size)) tw = drw.textlength(text, font=font) a, d = font.getmetrics() th = a + d tmp = Image.new("L", (int(tw)+2*pad, int(th)+2*pad), 0) ImageDraw.Draw(tmp).text((pad, pad + a), text, fill=255, font=font, anchor="ls") tmp = tmp.rotate(angle, expand=1, resample=Image.NEAREST) x, y = xy_from_norm(xp, yp); bx, by = tmp.size[0]//2, tmp.size[1]//2 img.paste(tmp, (x - bx, y - by), tmp) def draw_with_superscript(base_txt, super_txt, xp, yp, tail_txt="", angle=0, anchor="mm", base_size=font_size, super_scale=0.72, super_raise=0.55, gap_px=0, base_font=None, super_font=None, tail_font=None): """ Compose (base)(superscript)(tail) with proper metrics so nothing clips. All runs use STIX Two Math (or fallbacks). """ base_font = base_font or load_font_main(int(round(base_size))) super_font = super_font or load_font_main(int(round(base_size*super_scale))) tail_font = tail_font or base_font pad = max(12, int(0.45 * base_size)) # widths canvas = Image.new("L", (2,2), 0) meas = ImageDraw.Draw(canvas) w_base = meas.textlength(base_txt, font=base_font) if base_txt else 0 w_sup = meas.textlength(super_txt, font=super_font) if super_txt else 0 w_tail = meas.textlength(tail_txt, font=tail_font) if tail_txt else 0 a_b, d_b = base_font.getmetrics() a_s, d_s = super_font.getmetrics() # vertical sizing so the superscript never clips raise_px = int(round(base_size * super_raise)) top_over = max(0, (raise_px + a_s) - a_b) # extra above baseline height = a_b + d_b + top_over Wtmp = int(math.ceil(w_base + (gap_px if super_txt else 0) + w_sup + w_tail)) + 2*pad Htmp = height + 2*pad tmp = Image.new("L", (max(1, Wtmp), max(1, Htmp)), 0) tdr = ImageDraw.Draw(tmp) # baselines y_base = pad + top_over + a_b x = pad if base_txt: tdr.text((x, y_base), base_txt, fill=255, font=base_font, anchor="ls") x += w_base if super_txt: tdr.text((x + gap_px, y_base - raise_px), super_txt, fill=255, font=super_font, anchor="ls") x += gap_px + w_sup if tail_txt: tdr.text((x, y_base), tail_txt, fill=255, font=tail_font, anchor="ls") if angle != 0: tmp = tmp.rotate(angle, expand=1, resample=Image.NEAREST) X, Y = xy_from_norm(xp, yp); bx, by = tmp.size[0]//2, tmp.size[1]//2 img.paste(tmp, (X - bx, Y - by), tmp) # ---------------- Labels ---------------- # Regions draw_text("Region I (exterior)", 0.67, 0.00) draw_text("Region III (exterior)", -0.67, 0.00) draw_text("Region II (black hole interior, r < 2M)", 0.00, 0.67) draw_text("Region IV (white hole interior, r < 2M)", 0.00, -0.67) # Singularities draw_text("Spacelike singularity r = 0", 0.00, 1.0 - SING_MARGIN_FRAC + 0.03) draw_text("Spacelike singularity r = 0", 0.00, -1.0 + SING_MARGIN_FRAC - 0.03) # Horizons / bifurcation sphere draw_text("Event horizons r = 2M", 0.00, 0.00) draw_text("Bifurcation 2-sphere", 0.00, 0.08) # Rotated horizon labels H^± draw_with_superscript("H", "+", 0.38, 0.38, angle=-45) draw_with_superscript("H", "−", -0.38, -0.38, angle=-45) draw_with_superscript("H", "+", -0.38, 0.38, angle=+45) draw_with_superscript("H", "−", 0.38, -0.38, angle=+45) # ?^± with tail text draw_with_superscript("?", "+", 0.58, 0.46, tail_txt=" (future null infinity)", angle=-45) draw_with_superscript("?", "−", 0.58, -0.46, tail_txt=" (past null infinity)", angle=+45) draw_with_superscript("?", "+", -0.58, 0.46, angle=+45) draw_with_superscript("?", "−", -0.58, -0.46, angle=-45) # Timelike/spacelike infinities: i^±, i^0 draw_with_superscript("i", "+", 0.00, 1.02) draw_with_superscript("i", "−", 0.00, -1.02) draw_with_superscript("i", "0", 1.02, 0.00) draw_with_superscript("i", "0", -1.02, 0.00) # Asymptotically flat directions draw_text("r → ∞", 0.90, 0.00) draw_text("r → ∞", -0.90, 0.00) # ER bridge — note the en dash “–” draw_text("t = 0 (Einstein–Rosen bridge)", 0.00, -0.06) # Build masks fill_mask = (np.array(img, dtype=np.uint8) > 0) # Stroke = dilation of glyphs minus fill rad = max(1, int(round(stroke_px))) size = 2*rad + 1 dilated = img.filter(ImageFilter.MaxFilter(size)) stroke_mask = (np.array(dilated, dtype=np.uint8) > 0) & (~fill_mask) return fill_mask, stroke_mask # ---------------------------- # Kruskal relations & compactification # ---------------------------- def C_of_r(r): # (1 - r/2) e^{r/2} return (1.0 - 0.5*r) * math.exp(0.5*r) def compactify_UV_to_xy(U, V, k=KRUSKAL_K): Uc = (2.0/np.pi) * np.arctan(k * U) Vc = (2.0/np.pi) * np.arctan(k * V) xprime = 0.5*(Vc - Uc) yprime = 0.5*(Vc + Uc) return xprime, yprime # ---------------------------- # EVEN spacing helpers for r-curves # ---------------------------- def r_from_xprime_exterior(xp_target, k=KRUSKAL_K): # Exterior (I,III): ensure each r>2 curve hits y'=0 at evenly spaced x'. X_target = math.tan(0.5*math.pi * xp_target) / k S = X_target * X_target lo = 2.0 + 1e-8; hi = 6.0 def g(r): return C_of_r(r) + S # want C(r) = -S while g(hi) > 0.0: hi *= 1.7 if hi > 1e6: raise RuntimeError("r_from_xprime_exterior bracket fail") for _ in range(80): mid = 0.5*(lo+hi) if g(mid) > 0: lo = mid else: hi = mid return 0.5*(lo+hi) def xprime_at_y_for_r(r, y_ref, k=KRUSKAL_K): """ For r in (0,2), find x'(r) at the compactified horizontal level y'=y_ref (|y_ref|<1): arctan(kU) + arctan(kV) = y_ref * π, with UV = C(r) > 0. """ C = C_of_r(r) A = y_ref * math.pi TA = math.tan(A) S = (TA / k) * (1.0 - (k*k) * C) disc = S*S - 4.0*C if disc <= 0: return None rt = math.sqrt(disc) U1 = 0.5*(S + rt) U2 = 0.5*(S - rt) def pick(U): V = S - U # y_ref>0 (II): U,V < 0 ; y_ref<0 (IV): U,V > 0 sgn = -1.0 if y_ref > 0 else +1.0 if (U*sgn > 0) and (V*sgn > 0): Uc = (2.0/np.pi) * math.atan(k * U) Vc = (2.0/np.pi) * math.atan(k * V) return 0.5*(Vc - Uc) return None x1 = pick(U1); x2 = pick(U2) if x1 is None and x2 is None: return None return x1 if x1 is not None else x2 def interior_r_list_even_x(n_int, y_ref_mag=Y_REF_INT, k=KRUSKAL_K, scan_pts=1200, edge_trim=0.06): """ Build r<2 list by scanning x'(r; +y_ref_mag) over r∈(0,2), then invert by interpolation to place n_int curves at evenly spaced x' across the achievable band. """ y_ref = float(np.clip(y_ref_mag, 0.05, 0.90)) rmin, rmax = 1e-7, 2.0 - 1e-7 rs = np.linspace(rmin, rmax, scan_pts) xs = np.array([xprime_at_y_for_r(float(r), +y_ref, k) for r in rs], dtype=np.float64) ok = ~np.isnan(xs) rs, xs = rs[ok], xs[ok] if xs.size < 16: # Fallback: evenly spaced by C in (0,2) Cs = np.linspace(0.95, 0.05, n_int) return [invert_C_to_r(float(C)) for C in Cs] order = np.argsort(xs) xs = xs[order]; rs = rs[order] x_lo, x_hi = xs[0], xs[-1] span = x_hi - x_lo if span < 0.2: Cs = np.linspace(0.95, 0.05, n_int) return [invert_C_to_r(float(C)) for C in Cs] x_lo2 = x_lo + edge_trim * span x_hi2 = x_hi - edge_trim * span x_targets = np.linspace(x_lo2, x_hi2, n_int) r_targets = np.interp(x_targets, xs, rs) for i in range(1, len(r_targets)): if not (r_targets[i] > r_targets[i-1] + 1e-8): r_targets[i] = r_targets[i-1] + 1e-6 return list(map(float, r_targets)) def invert_C_to_r(C): # Monotone on (0,2): bisection lo, hi = 1e-10, 2.0 - 1e-10 def f(r): return C_of_r(r) - C for _ in range(80): mid = 0.5*(lo+hi) if f(mid) > 0: lo = mid else: hi = mid return 0.5*(lo+hi) def choose_even_r_lists(n_ext, n_int, k=KRUSKAL_K, edge_xp=0.06, y_ref_int=Y_REF_INT): # Exterior r>2 via even x' at y'=0 x_targets_ext = np.linspace(edge_xp, 1.0 - edge_xp, n_ext, dtype=np.float64) r_ext = [r_from_xprime_exterior(float(xp), k=k) for xp in x_targets_ext] # Interior r<2 via robust monotonic inversion at mid-wedge r_int = interior_r_list_even_x(n_int, y_ref_mag=y_ref_int, k=k) return r_ext, r_int # ---------------------------- # Constant-t list (even “angle”) # ---------------------------- def build_even_t_list(n_t, theta_max_deg=THETA_MAX_DEG): if n_t <= 0: return [] th_max = math.radians(theta_max_deg) thetas = np.linspace(-th_max, th_max, n_t) out = [] for th in thetas: m = math.tan(th) if abs(m) >= 1.0: m = math.copysign(0.999999, m) t = 4.0 * np.arctanh(m) out.append(float(t)) return out # ---------------------------- # Grid rasterization (sub-pixel horizon margin) # ---------------------------- def make_coord_grid_mask(masks, line_px, n_samples, r_ext_list, r_int_list, t_list, k_scale=KRUSKAL_K, eps_hor_uc=1e-6, eps_inf_uc=0.003): """ eps_hor_uc: tiny exclusion around U=0 in compactified Uc units (sub-pixel). eps_inf_uc: exclusion near |Uc|=1 to avoid degeneracy near corners. """ xp_grid, yp_grid, half, cx, cy = masks["xp"], masks["yp"], masks["half"], masks["cx"], masks["cy"] H, W = xp_grid.shape img = PILImage.new("L", (W, H), 0) draw = ImageDraw.Draw(img) def to_px(xp, yp): x = np.rint(cx + xp * half).astype(np.int32) y = np.rint(cy - yp * half).astype(np.int32) return x, y def draw_parametric_from_Uc(Uc_lo, Uc_hi, V_from_U, keep_region=None): # Chebyshev nodes → denser near ends t = np.linspace(0.0, 1.0, n_samples) Uc = 0.5*((Uc_hi+Uc_lo) + (Uc_hi-Uc_lo)*np.cos((1.0 - t)*math.pi)) U = (1.0/k_scale) * np.tan(0.5*np.pi * Uc) V = V_from_U(U) if keep_region: uv = U * V ok = (uv < 0.0) if keep_region == "ext" else (uv > 0.0) U = U[ok]; V = V[ok] finite = np.isfinite(U) & np.isfinite(V) U, V = U[finite], V[finite] if U.size < 2: return xp, yp = compactify_UV_to_xy(U, V, k=k_scale) keep = (np.abs(xp) + np.abs(yp) <= 1.02) if np.count_nonzero(keep) < 2: return xpx, ypx = to_px(xp[keep].astype(np.float32), yp[keep].astype(np.float32)) pts = list(map(tuple, np.stack([xpx, ypx], axis=1))) if len(pts) >= 2: draw.line(pts, fill=255, width=line_px, joint="curve") # Constant-r hyperbolae: UV = C(r) for r in r_ext_list: # I and III (C<0) C = C_of_r(r) draw_parametric_from_Uc(-1.0+eps_inf_uc, -eps_hor_uc, lambda U: C / U) # Region I draw_parametric_from_Uc( eps_hor_uc, 1.0-eps_inf_uc, lambda U: C / U) # Region III for r in r_int_list: # II and IV (C>0) C = C_of_r(r) draw_parametric_from_Uc(-1.0+eps_inf_uc, -eps_hor_uc, lambda U: C / U) # Region II draw_parametric_from_Uc( eps_hor_uc, 1.0-eps_inf_uc, lambda U: C / U) # Region IV # Constant-t (region-aware) for t in t_list: eta = math.exp(0.5*t) draw_parametric_from_Uc(-1.0+eps_inf_uc, -eps_hor_uc, lambda U: -eta*U, keep_region="ext") # I draw_parametric_from_Uc( eps_hor_uc, 1.0-eps_inf_uc, lambda U: -eta*U, keep_region="ext") # III draw_parametric_from_Uc(-1.0+eps_inf_uc, -eps_hor_uc, lambda U: eta*U, keep_region="int") # II draw_parametric_from_Uc( eps_hor_uc, 1.0-eps_inf_uc, lambda U: eta*U, keep_region="int") # IV return (np.array(img, dtype=np.uint8) > 0) & masks["diamond"] # ---------------------------- # Optional: sparse curved non-radial null rays # ---------------------------- def r_star_of_r(r): return r + 2.0 * np.log(abs(r/2.0 - 1.0)) def f_null_radial_factor(b, r): return 1.0 - (1.0 - 2.0/r) * (b*b) / (r*r) def dt_dr(b, r): f = f_null_radial_factor(b, r) return 1.0 / ((1.0 - 2.0/r) * np.sqrt(np.maximum(f, 1e-16))) def find_turning_radius(b, r_hi=2000.0): lo, hi = 3.0, r_hi def f(r): return f_null_radial_factor(b, r) if f(hi) <= 0: raise RuntimeError("Failed to bracket turning radius") if f(lo) > 0: lo = 3.0001 for _ in range(80): mid = 0.5*(lo + hi) if f(mid) <= 0: lo = mid else: hi = mid return 0.5*(lo+hi) def compactify_uv_to_xy(u, v, k=COMPACT_K_NULL): Uc = (2.0/np.pi) * np.arctan(k * u) Vc = (2.0/np.pi) * np.arctan(k * v) xprime = 0.5*(Vc - Uc); yprime = 0.5*(Vc + Uc) return xprime, yprime def make_curved_null_mask(masks, width_px, b_list, rmax, samples, k_scale): if not b_list: return np.zeros_like(masks["diamond"], dtype=bool) xp_grid, yp_grid, half, cx, cy = masks["xp"], masks["yp"], masks["half"], masks["cx"], masks["cy"] H, W = xp_grid.shape img = PILImage.new("L", (W, H), 0) draw = ImageDraw.Draw(img) def to_px(xp, yp): x = np.rint(cx + xp * half).astype(np.int32) y = np.rint(cy - yp * half).astype(np.int32) return x, y for b in b_list: r_turn = find_turning_radius(b, r_hi=max(rmax, 100.0)) y = np.linspace(0.0, 1.0, samples, dtype=np.float64) r_half = r_turn + (rmax - r_turn) * (y**2) integrand = dt_dr(b, r_half) t_half = np.cumsum(0.5*(integrand[1:] + integrand[:-1]) * (r_half[1:] - r_half[:-1])) t_half = np.concatenate(([0.0], t_half)) rstar_half = r_star_of_r(r_half) rstar_in = rstar_half[::-1]; t_in = -t_half[::-1] rstar_out = rstar_half; t_out = t_half rstar = np.concatenate([rstar_in, rstar_out[1:]]) t = np.concatenate([t_in, t_out[1:]]) u = t - rstar; v = t + rstar xp, yp = compactify_uv_to_xy(u, v, k=k_scale) keep = (np.abs(xp) + np.abs(yp) <= 1.02) xp = xp[keep]; yp = yp[keep] if xp.size < 2: continue xpx, ypx = to_px(xp.astype(np.float32), yp.astype(np.float32)) pts = list(map(tuple, np.stack([xpx, ypx], axis=1))) draw.line(pts, fill=255, width=width_px, joint="curve") return (np.array(img, dtype=np.uint8) > 0) & masks["diamond"] # ---------------------------- # Region gradients (lightest at region centers) # ---------------------------- def region_focal_points(): return { "I": ( +0.50, 0.00), "III": ( -0.50, 0.00), "II": ( 0.00, +0.50), "IV": ( 0.00, -0.50), } def lab_L_for_rgb(cols): rgb01 = cols.astype(np.float32) / 255.0 L, _, _ = rgb01_to_lab(rgb01) return L def assign_region_gradient(flat, idxs, cols, xp, yp, focus_xy): if len(idxs) == 0: return h, w = yp.shape yy, xx = np.divmod(idxs, w) xps = xp[yy, xx]; yps = yp[yy, xx] fx, fy = focus_xy d = np.hypot(xps - fx, yps - fy) wts = 1.0 - np.clip(d, 0.0, 1.2) / 1.2 wts = wts**1.2 order_pix = np.argsort(-wts) # closest first Ls = lab_L_for_rgb(cols) order_col = np.argsort(-Ls) # brightest first flat[idxs[order_pix]] = cols[order_col] # ---------------------------- # Background (outside diamond): low-chroma ripples # ---------------------------- def assign_background_pattern(flat, idxs, cols, xp, yp): if len(idxs) == 0: return h, w = yp.shape yy, xx = np.divmod(idxs, w) xps = xp[yy, xx]; yps = yp[yy, xx] r1 = np.abs(xps) + np.abs(yps) # diamond radius ang = np.arctan2(yps, xps) key = r1 + 0.020*np.sin(18.0*r1 + 4.0*ang) # subtle ripple order_pix = np.argsort(key) rgb01 = cols.astype(np.float32) / 255.0 L, a, b = rgb01_to_lab(rgb01) C = np.sqrt(a*a + b*b) order_col = np.lexsort((np.abs(L-55.0), C)) # prefer lower chroma, mid L flat[idxs[order_pix]] = cols[order_col] # ---------------------------- # Fill helper # ---------------------------- def fill_indices(img_flat, idxs, colors): assert colors.shape[0] == idxs.shape[0] img_flat[idxs] = colors # ---------------------------- # Main # ---------------------------- def main(): t0 = time.time() masks = make_masks(W, H, MARGIN_FRAC, LINE_PX, SING_MARGIN_FRAC) xp, yp, half = masks["xp"], masks["yp"], masks["half"] # Base masks (exclusivity comes later) label_fill_mask, label_stroke_mask = place_labels_masks(W, H, masks, LABEL_FONT_SIZE, LABEL_STROKE_PIX) line_mask_base = (masks["horizons"] | masks["singular"]) # within diamond already frame_band = (np.abs(np.abs(xp) + np.abs(yp) - 1.0) <= (FRAME_PX / half)) # diamond outline band # Grid / t-lines if DRAW_GRID: r_ext_list, r_int_list = choose_even_r_lists(N_R_EXT, N_R_INT, k=KRUSKAL_K, y_ref_int=Y_REF_INT) t_list = build_even_t_list(N_T, THETA_MAX_DEG) grid_mask_raw = make_coord_grid_mask( masks, GRID_LINE_PX, GRID_SAMPLES, r_ext_list, r_int_list, t_list, k_scale=KRUSKAL_K, eps_hor_uc=1e-6, # sub-pixel near horizons eps_inf_uc=0.003 # tight near corners ) else: grid_mask_raw = np.zeros_like(masks["diamond"], dtype=bool) # Optional non-radial null geodesics if DRAW_CURVED_NULL: geo_mask_raw = make_curved_null_mask( masks, width_px=GEO_CURVE_PX, b_list=GEODESIC_IMPACT_PARAMS, rmax=GEODESIC_RMAX, samples=GEODESIC_SAMPLES, k_scale=COMPACT_K_NULL ) else: geo_mask_raw = np.zeros_like(masks["diamond"], dtype=bool) # Region base fills and outside reg_I_base, reg_II_base = masks["I"], masks["II"] reg_III_base, reg_IV_base = masks["III"], masks["IV"] bg_base = masks["outside"] # -------- Exclusive partition (TOP → DOWN) -------- occupied = np.zeros((H, W), dtype=bool) def take(mask): m = mask & (~occupied) occupied[:] = occupied | m return m # Visual precedence: labels → frame → lines → grid → geo → regions → background m_label_fill = take(label_fill_mask) m_label_stk = take(label_stroke_mask) m_frame = take(frame_band) m_line = take(line_mask_base) m_grid = take(grid_mask_raw) m_geo = take(geo_mask_raw) m_I = take(reg_I_base) m_II = take(reg_II_base) m_III = take(reg_III_base) m_IV = take(reg_IV_base) m_bg = take(bg_base) assert occupied.all(), "Partition left some pixels unassigned" # Pixel counts n_label_fill = int(np.count_nonzero(m_label_fill)) n_label_stk = int(np.count_nonzero(m_label_stk)) n_frame = int(np.count_nonzero(m_frame)) n_line = int(np.count_nonzero(m_line)) n_grid = int(np.count_nonzero(m_grid)) n_geo = int(np.count_nonzero(m_geo)) n_I = int(np.count_nonzero(m_I)) n_II = int(np.count_nonzero(m_II)) n_III = int(np.count_nonzero(m_III)) n_IV = int(np.count_nonzero(m_IV)) n_bg = int(np.count_nonzero(m_bg)) total = (n_label_fill + n_label_stk + n_frame + n_line + n_grid + n_geo + n_I + n_II + n_III + n_IV + n_bg) assert total == W*H, f"Mask coverage error: {total} vs {W*H}" print("Pixel budget (exclusive):") print(f" Label fill : {n_label_fill:,} (lightest)") print(f" Label stroke : {n_label_stk:,} (darkest)") print(f" Frame : {n_frame:,} (diamond outline)") print(f" Lines : {n_line:,} (horizons + singularities)") print(f" Grid : {n_grid:,} (even r- and t-lines)") print(f" Geo : {n_geo:,} (optional non-radial null)") print(f" Regions I-IV : {n_I:,}, {n_II:,}, {n_III:,}, {n_IV:,}") print(f" Background : {n_bg:,}") print(f" TOTAL : {total:,}") # Palette pal = BinPalette() # LIGHTEST → label fill cols_label_fill = pal.allocate_light(n_label_fill) # DARKEST → label outline, frame, horizons/singularities, grid, optional null cols_label_stk = pal.allocate_dark(n_label_stk) cols_frame = pal.allocate_dark(n_frame) cols_lines = pal.allocate_dark(n_line) cols_grid = pal.allocate_dark(n_grid) cols_geo = pal.allocate_dark(n_geo) # Low-chroma → background (patterned) cols_bg = pal.allocate_low_chroma(n_bg) # Region colors (sorted by hue preferences) bins_I = pal.bins_by_hue_preference(REGION_HUE_PREFS["I"], MIN_CHROMA_FOR_REGIONS) bins_III = pal.bins_by_hue_preference(REGION_HUE_PREFS["III"], MIN_CHROMA_FOR_REGIONS) bins_II = pal.bins_by_hue_preference(REGION_HUE_PREFS["II"], MIN_CHROMA_FOR_REGIONS) bins_IV = pal.bins_by_hue_preference(REGION_HUE_PREFS["IV"], MIN_CHROMA_FOR_REGIONS) cols_I = pal.allocate_from_bins(bins_I, n_I) cols_III = pal.allocate_from_bins(bins_III, n_III) cols_II = pal.allocate_from_bins(bins_II, n_II) cols_IV = pal.allocate_from_bins(bins_IV, n_IV) # Assemble img = np.empty((H, W, 3), dtype=np.uint8) flat = img.reshape(-1, 3) def idx(mask): return np.flatnonzero(mask.ravel()) idx_label_fill = idx(m_label_fill) idx_label_stk = idx(m_label_stk) idx_frame = idx(m_frame) idx_line = idx(m_line) idx_grid = idx(m_grid) idx_geo = idx(m_geo) idx_I = idx(m_I) idx_II = idx(m_II) idx_III = idx(m_III) idx_IV = idx(m_IV) idx_bg = idx(m_bg) # Background (outside diamond): patterned, subdued assign_background_pattern(flat, idx_bg, cols_bg, xp, yp) # Region gradients (lightest at region centers) foc = region_focal_points() assign_region_gradient(flat, idx_I, cols_I, xp, yp, foc["I"]) assign_region_gradient(flat, idx_III, cols_III, xp, yp, foc["III"]) assign_region_gradient(flat, idx_II, cols_II, xp, yp, foc["II"]) assign_region_gradient(flat, idx_IV, cols_IV, xp, yp, foc["IV"]) # Overlays (exclusive masks, order doesn't change totals) fill_indices(flat, idx_grid, cols_grid) fill_indices(flat, idx_geo, cols_geo) fill_indices(flat, idx_line, cols_lines) fill_indices(flat, idx_frame, cols_frame) fill_indices(flat, idx_label_stk, cols_label_stk) fill_indices(flat, idx_label_fill, cols_label_fill) if VERIFY_UNIQUENESS: packed = (img[...,0].astype(np.uint32) << 16) | (img[...,1].astype(np.uint32) << 8) | img[...,2].astype(np.uint32) uniq = np.unique(packed) assert uniq.size == 256*256*256, f"Uniqueness failed: {uniq.size} unique colors" print("Verified: all 16,777,216 colors used exactly once.") ts = datetime.now().strftime("%Y%m%d_%H%M%S") out_path = os.path.join(OUTPUT_DIR, f"penrose_allrgb_{W}x{H}_{ts}.png") PILImage.fromarray(img, mode="RGB").save(out_path, compress_level=0) print(f"Saved: {out_path} (elapsed {time.time() - t0:.1f}s)") if __name__ == "__main__": main()
Date | |
---|---|
Colors | 16,777,216 |
Pixels | 16,777,216 |
Dimensions | 4,096 × 4,096 |
Bytes | 50,351,853 |