


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()
| Date | |
|---|---|
| Colors | 16,777,216 |
| Pixels | 16,777,216 |
| Dimensions | 4,096 × 4,096 |
| Bytes | 50,331,713 |
