Thumbnail.

Testbeeld v1

Description

I used Chatgpt 5 Thinking to write Python code that generates an image inspired by output of the PM 5544 circle pattern generator that was used for test cards for all Dutch tv channels during my childhood.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
AllRGB Philips PM5544 generator (SVG + pooled color mapping)
------------------------------------------------------------

This script:

1. Loads a reference PM5544 image, which may be:
   - An SVG file (vector, via CairoSVG), or
   - A raster file (PNG/JPEG/etc., via Pillow).
2. Rasterizes (if SVG) and resizes it to fit inside a 4096×4096 square while
   preserving aspect ratio. The pattern is centered on a mid-grey background.
3. Treats the resulting 4096×4096 pixels as "target" colors.
4. Enumerates all 24-bit colors (0x000000 .. 0xFFFFFF).
5. Builds color "pools" for both targets and palette, based on luma and
   saturation (dark/mid/light greys, moderate colors, vivid colors).
6. Within each pool, assigns colors so that:
   - grey pools are mapped along luma gradients (good ramps and gray planes),
   - color pools are mapped by hue sector + luma (bars, patches and colored
     details get vivid colors of similar hue).
7. Writes out the resulting 4096×4096 image and verifies that every 24-bit
   color is used exactly once (permutation + XOR + channel sums).
"""

import io
import os
import time

import numpy as np
from PIL import Image


# ---------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------
W = H = 4096
TOTAL_PIXELS = W * H
TOTAL_COLORS = 1 << 24  # 16,777,216

assert TOTAL_PIXELS == TOTAL_COLORS, "Canvas must have exactly 2^24 pixels."

# Path to your reference PM5544 image (SVG recommended).
REF_PATH = "pm5544.svg"   # <-- change this to your SVG/PNG path


# ---------------------------------------------------------------------
# Basic helpers
# ---------------------------------------------------------------------
def xor_0_to_n_minus_1(n: int) -> int:
    """XOR of integers 0..n-1 (closed form)."""
    m = n - 1
    r = m % 4
    if r == 0:
        return m
    elif r == 1:
        return 1
    elif r == 2:
        return m + 1
    else:
        return 0


def luma709_u8(r: np.ndarray, g: np.ndarray, b: np.ndarray) -> np.ndarray:
    """
    Approximate Rec.709 luma in 0..255 using integer math:

        Y ≈ (54*R + 183*G + 19*B + 128) >> 8
    """
    y = (54 * r.astype(np.uint32) +
         183 * g.astype(np.uint32) +
         19 * b.astype(np.uint32) + 128) >> 8
    return y.astype(np.uint16)


def hsv_sector(r: np.ndarray, g: np.ndarray, b: np.ndarray,
               s: np.ndarray) -> np.ndarray:
    """
    Rough HSV hue sector: 0=R,1=Y,2=G,3=C,4=B,5=M.

    Only meaningful when saturation s>0, but fine for global array.
    """
    r8 = r.astype(np.float32)
    g8 = g.astype(np.float32)
    b8 = b.astype(np.float32)

    maxi = np.maximum(np.maximum(r8, g8), b8)
    mini = np.minimum(np.minimum(r8, g8), b8)
    d = maxi - mini

    hue = np.zeros_like(maxi, dtype=np.float32)
    mr = (r8 >= g8) & (r8 >= b8)
    mg = (g8 > r8) & (g8 >= b8)
    mb = ~(mr | mg)

    idx = mr & (d > 0)
    hue[idx] = (60.0 * ((g8[idx] - b8[idx]) / d[idx]) + 360.0) % 360.0

    idx = mg & (d > 0)
    hue[idx] = 60.0 * ((b8[idx] - r8[idx]) / d[idx] + 2.0)

    idx = mb & (d > 0)
    hue[idx] = 60.0 * ((r8[idx] - g8[idx]) / d[idx] + 4.0)

    sec = np.floor(hue / 60.0).astype(np.int16) % 6
    return sec.astype(np.int8)


# ---------------------------------------------------------------------
# Reference image loading (SVG or raster)
# ---------------------------------------------------------------------
def load_reference_image(ref_path: str) -> Image.Image:
    """
    Load the reference image as a Pillow RGB image.

    - If ref_path ends with ".svg" (case-insensitive), use CairoSVG to
      rasterize the SVG to PNG bytes at high resolution, then load via Pillow.
    - Otherwise, open directly with Pillow and convert to RGB.
    """
    ext = os.path.splitext(ref_path)[1].lower()

    if ext == ".svg":
        import cairosvg

        with open(ref_path, "rb") as f:
            svg_bytes = f.read()

        # Render at least W wide; height will follow aspect.
        png_bytes = cairosvg.svg2png(bytestring=svg_bytes, output_width=W)
        img = Image.open(io.BytesIO(png_bytes)).convert("RGB")
    else:
        img = Image.open(ref_path).convert("RGB")

    return img


def load_reference_target(ref_path: str) -> np.ndarray:
    """
    Load the reference PM5544 image (SVG or raster), resize to fit inside
    4096×4096 while preserving aspect ratio, center on mid-grey, and return
    a flattened (N,3) uint8 array of target colors.
    """
    img = load_reference_image(ref_path)
    rw, rh = img.size

    # Scale to fit inside 4096×4096 preserving aspect ratio
    scale = min(W / rw, H / rh)
    new_w = int(round(rw * scale))
    new_h = int(round(rh * scale))

    # High-quality resample
    try:
        resample = Image.Resampling.LANCZOS
    except AttributeError:
        resample = Image.LANCZOS

    img_resized = img.resize((new_w, new_h), resample)

    # Start with mid-grey background
    canvas = np.full((H, W, 3), 128, dtype=np.uint8)

    # Center the resized pattern on the canvas
    ox = (W - new_w) // 2
    oy = (H - new_h) // 2
    arr_resized = np.asarray(img_resized, dtype=np.uint8)
    canvas[oy:oy + new_h, ox:ox + new_w, :] = arr_resized

    # Flatten to (N,3)
    target = canvas.reshape(-1, 3)
    assert target.shape == (TOTAL_PIXELS, 3)
    return target


# ---------------------------------------------------------------------
# Palette features and pooled assignment
# ---------------------------------------------------------------------
def build_palette_features() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """
    Enumerate all 24-bit RGB colors and compute features:

        c    : 24-bit color as uint32 (0xRRGGBB)
        Y    : luma 0..255 (uint16)
        S    : saturation 0..255 (uint8)
        SEC  : hue sector 0..5 (int8)
    """
    N = TOTAL_COLORS
    c = np.arange(N, dtype=np.uint32)

    R = ((c >> 16) & 0xFF).astype(np.uint8)
    G = ((c >> 8) & 0xFF).astype(np.uint8)
    B = (c & 0xFF).astype(np.uint8)

    Y = luma709_u8(R, G, B)

    maxc = np.maximum(np.maximum(R, G), B)
    minc = np.minimum(np.minimum(R, G), B)
    S = (maxc.astype(np.int16) - minc.astype(np.int16)).astype(np.uint8)

    SEC = hsv_sector(R, G, B, S)

    # We don't keep R,G,B to save memory; we only need c, Y, S, SEC.
    return c, Y, S, SEC


def assign_colors_pooled(target: np.ndarray,
                         palette_features: tuple[np.ndarray, np.ndarray,
                                                 np.ndarray, np.ndarray]
                         ) -> np.ndarray:
    """
    Core "pooling" assignment:

    - Compute Y (luma), S (saturation), SEC (hue sector) for target pixels.
    - Classify both target and palette colors into 5 bands:
        0 = dark grey       (low S, low Y)
        1 = mid grey        (low S, mid Y)
        2 = light grey      (low S, high Y)
        3 = moderate colors (medium S)
        4 = vivid colors    (high S)
    - For each band b in [0,1,2,4,3]:
        * gather all target pixels in band b,
        * gather all unused palette colors in band b (or borrow from others),
        * inside band:
            - for grey bands 0..2:  sort target & palette by Y and match
            - for color bands 3,4:  sort by (SEC, Y) and match
    - Returns assigned_color: array of 24-bit color integers (0xRRGGBB),
      length N, such that each palette color is used exactly once.
    """
    N = target.shape[0]
    assert N == TOTAL_COLORS

    c, Yp, Sp, SECp = palette_features

    # Features for target
    Rt = target[:, 0].astype(np.uint8)
    Gt = target[:, 1].astype(np.uint8)
    Bt = target[:, 2].astype(np.uint8)

    Yt = luma709_u8(Rt, Gt, Bt)
    maxc_t = np.maximum(np.maximum(Rt, Gt), Bt)
    minc_t = np.minimum(np.minimum(Rt, Gt), Bt)
    St = (maxc_t.astype(np.int16) - minc_t.astype(np.int16)).astype(np.uint8)
    SECt = hsv_sector(Rt, Gt, Bt, St)

    # Band thresholds
    S_GRAY = 25   # how "grey" is "grey"
    Y_MID1 = 85   # dark -> mid
    Y_MID2 = 170  # mid -> light

    # Classify target into 5 bands
    band_t = np.empty(N, dtype=np.int8)
    grey_mask = St <= S_GRAY
    band_t[grey_mask & (Yt < Y_MID1)] = 0
    band_t[grey_mask & (Yt >= Y_MID1) & (Yt < Y_MID2)] = 1
    band_t[grey_mask & (Yt >= Y_MID2)] = 2
    color_mask = ~grey_mask
    band_t[color_mask & (St < 128)] = 3
    band_t[color_mask & (St >= 128)] = 4

    # Same classification for palette
    band_p = np.empty(N, dtype=np.int8)
    grey_mask_p = Sp <= S_GRAY
    band_p[grey_mask_p & (Yp < Y_MID1)] = 0
    band_p[grey_mask_p & (Yp >= Y_MID1) & (Yp < Y_MID2)] = 1
    band_p[grey_mask_p & (Yp >= Y_MID2)] = 2
    color_mask_p = ~grey_mask_p
    band_p[color_mask_p & (Sp < 128)] = 3
    band_p[color_mask_p & (Sp >= 128)] = 4

    used = np.zeros(N, dtype=bool)
    assigned_color = np.empty(N, dtype=np.uint32)

    # We'll process bands in this order:
    #  - greys first (they're the most visually sensitive),
    #  - then vivid colors,
    #  - then moderate colors.
    band_order = [0, 1, 2, 4, 3]

    # Precompute keys for palette (once)
    key_p_gray = Yp.astype(np.int32)
    key_p_color = (SECp.astype(np.int16) * 256 + Yp.astype(np.int16)).astype(np.int32)

    for b in band_order:
        tidx = np.flatnonzero(band_t == b)
        n_t = tidx.size
        if n_t == 0:
            continue

        cand = np.flatnonzero((~used) & (band_p == b))
        n_c = cand.size

        if b in (0, 1, 2):
            key_t = Yt.astype(np.int32)
            key_p = key_p_gray
        else:
            key_t = (SECt.astype(np.int16) * 256 + Yt.astype(np.int16)).astype(np.int32)
            key_p = key_p_color

        if n_c >= n_t:
            # Enough palette colors of this band.
            pal_all = cand
            t_sorted = tidx[np.argsort(key_t[tidx], kind="stable")]
            p_sorted = pal_all[np.argsort(key_p[pal_all], kind="stable")][:n_t]
        else:
            # Not enough colors in this band; borrow from others by key-proximity.
            pal_list = []
            if n_c > 0:
                pal_list.append(cand)
            remaining = n_t - n_c

            all_unused_other = np.flatnonzero(~used & (band_p != b))
            if remaining > all_unused_other.size:
                raise RuntimeError("Not enough palette colors remaining (should not happen).")

            # Pick additional colors whose key is closest to the band’s mean key.
            mean_key = int(key_t[tidx].mean())
            dist = np.abs(key_p[all_unused_other] - mean_key)
            extra = all_unused_other[np.argpartition(dist, remaining - 1)[:remaining]]
            pal_list.append(extra)

            pal_all = np.concatenate(pal_list)

            # Order within band
            t_sorted = tidx[np.argsort(key_t[tidx], kind="stable")]
            p_sorted = pal_all[np.argsort(key_p[pal_all], kind="stable")]

        assert p_sorted.size == n_t
        used[p_sorted] = True
        assigned_color[t_sorted] = c[p_sorted]

    assert used.all(), "Some palette colors were not used."
    return assigned_color


def build_allrgb_from_target(target: np.ndarray) -> Image.Image:
    """
    High-level: build AllRGB image from target colors using pooled mapping.
    """
    print("  Building palette features...")
    palette_features = build_palette_features()

    print("  Assigning 24-bit colors with pooled mapping...")
    assigned = assign_colors_pooled(target, palette_features)

    # Unpack 24-bit colors 0xRRGGBB into RGB image
    R = ((assigned >> 16) & 0xFF).astype(np.uint8)
    G = ((assigned >> 8) & 0xFF).astype(np.uint8)
    B = (assigned & 0xFF).astype(np.uint8)
    img_arr = np.stack([R, G, B], axis=1).reshape(H, W, 3)
    img = Image.fromarray(img_arr, mode="RGB")
    return img


# ---------------------------------------------------------------------
# Verification
# ---------------------------------------------------------------------
def verify_allrgb(img: Image.Image):
    """
    Verify:
      - XOR of all pixel values equals XOR of 0..(2^24-1).
      - Per-channel sums match the theoretical sums for all 24-bit colors.
    """
    arr = np.asarray(img, dtype=np.uint8).reshape(-1, 3)
    packed = (arr[:, 0].astype(np.uint32) << 16) | \
             (arr[:, 1].astype(np.uint32) << 8) | \
             arr[:, 2].astype(np.uint32)

    print("  Verifying XOR coverage...")
    xor_img = np.bitwise_xor.reduce(packed)
    xor_ref = xor_0_to_n_minus_1(TOTAL_COLORS)
    assert xor_img == xor_ref, "XOR coverage check failed."

    print("  Verifying per-channel sums...")
    # Each channel: every value 0..255 appears exactly 256^2 times.
    REPEAT = 256 * 256
    sum_0_255 = (255 * 256) // 2  # sum(range(256))
    exp_sum = REPEAT * sum_0_255
    sums = arr.sum(axis=0)
    assert int(sums[0]) == exp_sum and int(sums[1]) == exp_sum and int(sums[2]) == exp_sum, \
        "Channel sum check failed."

    print("  Verification passed: perfect 24-bit coverage.")


# ---------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------
def main(ref_path: str = REF_PATH, out_path: str = None):
    t0 = time.time()
    print(f"Target resolution: {W}×{H} pixels ({TOTAL_PIXELS:,} total)")
    print(f"Reference file   : {ref_path}")

    print("Loading and preparing reference PM5544 image (SVG/PNG)...")
    target = load_reference_target(ref_path)

    print("Building AllRGB image from reference with pooled color mapping...")
    img = build_allrgb_from_target(target)

    print("Running coverage verification...")
    verify_allrgb(img)

    if out_path is None:
        from datetime import datetime
        ts = datetime.now().strftime("%Y%m%d-%H%M%S")
        ext = os.path.splitext(ref_path)[1].lower().lstrip(".")
        out_path = f"allrgb_pm5544_pooled_from_{ext}_{W}x{H}_{ts}.png"

    print(f"Saving: {out_path}")
    img.save(out_path, optimize=True)

    dt = time.time() - t0
    print(f"Done in {dt:.1f} seconds.")


if __name__ == "__main__":
    np.set_printoptions(suppress=True)
    main()

Author

ACJ
70 entries

Stats

Date
Colors16,777,216
Pixels16,777,216
Dimensions4,096 × 4,096
Bytes50,331,713