#!/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()