Thumbnail.

Point Spread Function v1

Description

Made with Chatgpt 5, Python 3, and Google Colab notebook. Source:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Physics-accurate telescope diffraction image (4096×4096) using all 24-bit RGB colors exactly once.

Features
- Pupil model: annulus (central obstruction) + spider vanes → Fraunhofer PSF via FFT
- Aberrations: fringe Zernike terms (defocus, astigmatism 0/45°, coma x/y, spherical)
- Intensity shaping: monotone gamma (does not change rank order)
- Exact-once color usage: rank-match intensity to Rec.709 luma; checksum + XOR proof
- Reserved overlays: center crosshair (darkest colors), neat legend box & ticks (lightest colors)

Requires: numpy, pillow
"""

import time
import numpy as np
from PIL import Image

# -------------------------- Zernike (fringe polynomials) --------------------------
# Fringe (unnormalized) variants often used in engineering optics. Coefficients are in waves.
# theta in radians, rho∈[0,1]
def z_defocus(rho, th):      # Z4 fringe ~ 2ρ^2 - 1
    return 2.0 * rho**2 - 1.0

def z_astig0(rho, th):       # Z2 fringe ~ ρ^2 cos 2θ
    return rho**2 * np.cos(2.0 * th, dtype=np.float32)

def z_astig45(rho, th):      # Z3 fringe ~ ρ^2 sin 2θ
    return rho**2 * np.sin(2.0 * th, dtype=np.float32)

def z_coma_x(rho, th):       # Z7 fringe ~ (3ρ^3 - 2ρ) cos θ
    return (3.0 * rho**3 - 2.0 * rho) * np.cos(th, dtype=np.float32)

def z_coma_y(rho, th):       # Z8 fringe ~ (3ρ^3 - 2ρ) sin θ
    return (3.0 * rho**3 - 2.0 * rho) * np.sin(th, dtype=np.float32)

def z_spherical(rho, th):    # Z11 fringe ~ 6ρ^4 - 6ρ^2 + 1
    return 6.0 * rho**4 - 6.0 * rho**2 + 1.0

# -------------------------- Pupil → PSF (FFT) --------------------------
def aperture_fft_intensity(h, w,
                           epsilon=0.33,        # central obstruction ratio D_inner / D_outer
                           vanes=4,             # number of spider vanes
                           vane_thickness=0.008,# fraction of pupil diameter
                           rotate_deg=15.0,     # orientation of vanes [deg]
                           gamma=0.80,          # intensity^gamma for contrast (monotone)
                           # Aberration coefficients in waves (peak-to-valley-ish)
                           c_defocus = 0.00,
                           c_astig0  = 0.00,
                           c_astig45 = 0.00,
                           c_coma_x  = 0.00,
                           c_coma_y  = 0.00,
                           c_spherical=0.00):
    """
    Build Fraunhofer diffraction intensity from a physical pupil.
    Steps:
      1) Pupil amplitude: annulus minus spider vanes
      2) Pupil phase: exp(i*2π*sum c_k * Z_k(rho, theta))
      3) PSF ∝ |FFT(pupil)|^2  (fftshifted)
    """
    H, W = int(h), int(w)

    # Normalized coordinates in [-1,1]; inscribed pupil radius ≈ 1.0
    yy = np.linspace(-1.0, 1.0, H, dtype=np.float32)
    xx = np.linspace(-1.0, 1.0, W, dtype=np.float32)
    X, Y = np.meshgrid(xx, yy, indexing="xy")
    R = np.hypot(X, Y, dtype=np.float32)
    TH = np.arctan2(Y, X, dtype=np.float32)

    # Annulus mask (slightly underfilled to avoid hard edge touching frame)
    outer_R = 0.98
    inner_R = outer_R * np.float32(epsilon)
    outer = (R <= outer_R).astype(np.float32)
    inner = (R <= inner_R).astype(np.float32)
    annulus = outer - inner

    # Rotate vane coordinates
    th = np.deg2rad(np.float32(rotate_deg))
    c, s = np.cos(th), np.sin(th)
    Xr =  c * X + s * Y
    Yr = -s * X + c * Y

    pupil = annulus.copy()
    if vanes > 0 and vane_thickness > 0.0:
        t = np.float32(vane_thickness)
        for k in range(vanes):
            phi = np.float32(k) * (np.pi / np.float32(vanes))
            cp, sp = np.cos(phi), np.sin(phi)
            u =  cp * Xr + sp * Yr
            v = -sp * Xr + cp * Yr
            vane = (np.abs(v) <= (t / 2.0)).astype(np.float32)
            # remove vane only where within outer pupil
            pupil *= (1.0 - vane) + (1.0 - outer)

    # Phase from Zernike aberrations (fringe set). Coeffs are in waves → 2π factor to radians.
    if any(abs(x) > 1e-12 for x in [c_defocus, c_astig0, c_astig45, c_coma_x, c_coma_y, c_spherical]):
        phase_waves = (c_defocus   * z_defocus(R/outer_R, TH) +
                       c_astig0    * z_astig0 (R/outer_R, TH) +
                       c_astig45   * z_astig45(R/outer_R, TH) +
                       c_coma_x    * z_coma_x (R/outer_R, TH) +
                       c_coma_y    * z_coma_y (R/outer_R, TH) +
                       c_spherical * z_spherical(R/outer_R, TH)).astype(np.float32)
        phase = (2.0 * np.pi * phase_waves).astype(np.float32)
        pupil = pupil * np.exp(1j * phase, dtype=np.complex64)
    else:
        pupil = pupil.astype(np.complex64)

    # FFT → PSF
    F = np.fft.fft2(pupil)
    I = np.fft.fftshift(np.abs(F)**2).astype(np.float32)

    # Normalize + gamma (monotone)
    I -= I.min()
    m = I.max()
    if m > 0:
        I /= m
    if gamma != 1.0:
        I = np.power(I, np.float32(gamma), dtype=np.float32)
    return I

# -------------------------- Exact-once mapping with reserved overlays --------------------------
def stable_argsort(a):
    try:
        return np.argsort(a, kind='stable')
    except TypeError:
        return np.argsort(a, kind='mergesort')

def map_allrgb_with_overlays(intensity,
                             use_crosshair=True,
                             use_legend=True,
                             invert=False,
                             legend_box=(32, 4096-32-120, 480, 120),  # (x,y,w,h) bottom-left by default
                             legend_thick=2,
                             ticks=9, tick_len=16, tick_thick=2):
    """
    Map all 24-bit colors exactly once with optional reserved overlays:
      - Crosshair (center horizontal+vertical lines) uses the darkest colors.
      - Legend box (corner rectangle with ticks) uses the lightest colors.
    The remaining pixels are filled by rank-matching intensity with color luma.
    """
    H, W = intensity.shape
    N = H * W

    # 1) Build overlay mask: 0=free, 1=dark-reserved (crosshair), 2=light-reserved (legend)
    mask = np.zeros((H, W), dtype=np.uint8)

    # Crosshair pixels (avoid double-count at center)
    cross_idx = []
    if use_crosshair:
        cy, cx = H // 2, W // 2
        # horizontal
        mask[cy, :] = 1
        # vertical
        mask[:, cx] = np.maximum(mask[:, cx], 1)
        # collect linear indices
        y = np.full(W, cy, dtype=np.int64)
        x = np.arange(W, dtype=np.int64)
        cross_idx.extend((y * W + x).tolist())
        y = np.arange(H, dtype=np.int64)
        x = np.full(H, cx, dtype=np.int64)
        cross_idx.extend((y * W + x).tolist())
        # remove the center double-count
        cross_idx = sorted(set(cross_idx))

    # Legend rectangle (border only) with ticks
    legend_idx = []
    if use_legend:
        lx, ly, lw, lh = legend_box
        lx = int(np.clip(lx, 0, W-1)); ly = int(np.clip(ly, 0, H-1))
        lw = int(np.clip(lw, 1, W - lx)); lh = int(np.clip(lh, 1, H - ly))
        t  = int(max(1, legend_thick))
        # borders
        # top/bottom
        mask[ly:ly+t, lx:lx+lw] = 2
        mask[ly+lh-t:ly+lh, lx:lx+lw] = 2
        # left/right
        mask[ly:ly+lh, lx:lx+t] = 2
        mask[ly:ly+lh, lx+lw-t:lx+lw] = 2

        # ticks along bottom border inside the box (evenly spaced)
        if ticks > 0:
            for i in range(ticks+1):
                tx = lx + int(round(i * (lw-1) / ticks))
                ty0 = ly + lh - t - tick_len
                ty1 = ly + lh - t
                ty0 = max(ly + t, ty0)
                ty1 = min(ly + lh - 1, ty1)
                mask[ty0:ty1, tx:tx+tick_thick] = 2

        legend_idx = np.flatnonzero(mask == 2).tolist()

    # 2) Compute pixel order among FREE pixels only
    flat_I = intensity.ravel()
    free_lin = np.flatnonzero(mask.ravel() == 0)
    free_order = stable_argsort(flat_I[free_lin])
    if invert:
        free_order = free_order[::-1]
    free_lin_sorted = free_lin[free_order]
    del free_order, free_lin

    # 3) Build global color ordering by perceived brightness (Rec.709 luma, fixed-point)
    c = np.arange(N, dtype=np.uint32)
    R = (c >> 16) & 0xFF
    G = (c >> 8)  & 0xFF
    B = (c      ) & 0xFF
    Y256 = (54 * R + 183 * G + 19 * B).astype(np.uint32)
    color_order = stable_argsort(Y256)  # dark→bright
    del Y256, R, G, B

    # Reserve darkest for crosshair, lightest for legend
    D_dark  = len(cross_idx)
    D_light = len(legend_idx)
    if D_dark + D_light > N:
        raise RuntimeError("Overlay area exceeds total pixels.")

    darkest_ids  = color_order[:D_dark].astype(np.uint32, copy=False)
    lightest_ids = color_order[-D_light:].astype(np.uint32, copy=False) if D_light > 0 else np.array([], dtype=np.uint32)
    remain_ids   = color_order[D_dark: N - D_light].astype(np.uint32, copy=False)
    del color_order

    # 4) Assemble output
    img_flat = np.empty((N, 3), dtype=np.uint8)

    # Fill FREE pixels with remaining colors by intensity rank
    co = remain_ids
    img_flat[free_lin_sorted, 0] = (co >> 16).astype(np.uint8)
    img_flat[free_lin_sorted, 1] = ((co >> 8) & 0xFF).astype(np.uint8)
    img_flat[free_lin_sorted, 2] = (co & 0xFF).astype(np.uint8)
    del co

    # Paint crosshair with darkest colors (center-out radial gradient along both axes)
    if D_dark > 0:
        # Sort crosshair indices by distance from center for a nice gradient
        cross_idx_arr = np.array(cross_idx, dtype=np.int64)
        ys = (cross_idx_arr // W).astype(np.int32)
        xs = (cross_idx_arr %  W).astype(np.int32)
        cy, cx = H // 2, W // 2
        d = np.hypot(xs - cx, ys - cy).astype(np.float32)
        order = np.argsort(d, kind='mergesort')  # stable
        ci = cross_idx_arr[order]
        cd = darkest_ids  # dark→less dark
        img_flat[ci, 0] = ((cd >> 16) & 0xFF).astype(np.uint8)
        img_flat[ci, 1] = ((cd >> 8)  & 0xFF).astype(np.uint8)
        img_flat[ci, 2] = ( cd        & 0xFF).astype(np.uint8)
        del cross_idx_arr, ys, xs, d, order, ci, cd

    # Paint legend with lightest colors (simple sequential assignment)
    if D_light > 0:
        li = np.array(legend_idx, dtype=np.int64)
        ld = lightest_ids
        img_flat[li, 0] = ((ld >> 16) & 0xFF).astype(np.uint8)
        img_flat[li, 1] = ((ld >> 8 ) & 0xFF).astype(np.uint8)
        img_flat[li, 2] = ( ld        & 0xFF).astype(np.uint8)
        del li, ld

    img = img_flat.reshape(H, W, 3)
    del img_flat

    # 5) Integrity checks — prove every color used exactly once
    packed = (img[...,0].astype(np.uint32) << 16) | \
             (img[...,1].astype(np.uint32) << 8)  | \
             (img[...,2].astype(np.uint32))
    sum1 = int(packed.sum(dtype=np.uint64))
    xors = np.bitwise_xor.reduce(packed.ravel().astype(np.uint64)).item()
    del packed

    exp_sum1 = N * (N - 1) // 2
    assert sum1 == exp_sum1 and xors == 0, "Color coverage mismatch."

    return img

# -------------------------- Driver --------------------------
def build_image(size=4096,
                invert=False,
                # Pupil + FFT parameters
                epsilon=0.33, vanes=4, vane_thickness=0.008, rotate_deg=15.0, gamma=0.80,
                # Aberration waves (try small values like 0.05–0.20 for visible but tasteful effects)
                c_defocus=0.00, c_astig0=0.00, c_astig45=0.00, c_coma_x=0.00, c_coma_y=0.00, c_spherical=0.00,
                # Overlays
                use_crosshair=True, use_legend=True,
                legend_box=(32, 4096-32-120, 480, 120), legend_thick=2, ticks=9, tick_len=16, tick_thick=2,
                out_path=None):
    H = W = int(size)
    print(f"Image: {W}×{H}  (pixels: {H*W:,})")

    print("Computing aperture FFT intensity (annulus + spider vanes + optional aberrations)...")
    I = aperture_fft_intensity(H, W,
                               epsilon=epsilon, vanes=vanes, vane_thickness=vane_thickness,
                               rotate_deg=rotate_deg, gamma=gamma,
                               c_defocus=c_defocus, c_astig0=c_astig0, c_astig45=c_astig45,
                               c_coma_x=c_coma_x, c_coma_y=c_coma_y, c_spherical=c_spherical)

    print("Mapping all 24-bit colors with reserved overlays (crosshair & legend)...")
    img = map_allrgb_with_overlays(I,
                                   use_crosshair=use_crosshair,
                                   use_legend=use_legend,
                                   invert=invert,
                                   legend_box=legend_box,
                                   legend_thick=legend_thick,
                                   ticks=ticks, tick_len=tick_len, tick_thick=tick_thick)

    ts = time.strftime("%Y%m%d-%H%M%S")
    if out_path is None:
        suffix = f"eps{epsilon:.2f}_v{vanes}_t{vane_thickness:.3f}_rot{rotate_deg:.0f}_g{gamma:.2f}"
        ab = f"d{c_defocus:.2f}_a0{c_astig0:.2f}_a45{c_astig45:.2f}_cx{c_coma_x:.2f}_cy{c_coma_y:.2f}_s{c_spherical:.2f}"
        out_path = f"telescope_psf_allRGB_{W}x{H}_{suffix}_{ab}_{'inv' if invert else 'norm'}_{ts}.png"

    Image.fromarray(img, mode="RGB").save(out_path, compress_level=0, optimize=False)
    print(f"Done. Saved: {out_path}")
    return img

if __name__ == "__main__":
    # Tasteful defaults; tweak freely (all monotone/gamma and optical changes preserve exact-once mapping).
    build_image(
        size=4096,
        invert=False,          # True puts vivid colors onto the dark spikes/rings
        # Pupil
        epsilon=0.33,          # 0.10..0.40 typical; larger → bigger central hole → stronger rings
        vanes=4,               # 3,4,6 common; controls number of spikes
        vane_thickness=0.008,  # 0.006..0.012 looks good
        rotate_deg=25.0,
        gamma=0.80,            # 0.75..0.95: lower → more visible rings/spikes

        # Aberrations (in waves). Try small nonzero for personality:
        c_defocus=0.08,
        c_astig0=0.05,
        c_astig45=0.00,
        c_coma_x=0.06,
        c_coma_y=-0.04,
        c_spherical=0.03,

        # Overlays
        use_crosshair=True,
        use_legend=True,
        legend_box=(32, 4096-32-120, 480, 120),  # x,y,w,h (place near bottom-left)
        legend_thick=2,
        ticks=9, tick_len=16, tick_thick=2,
    )

Author

ACJ
68 entries

Stats

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