import numpy as np
from PIL import Image
def generate_heart_with_transparent_gray_background(
levels=3,
output_path="fractal_heart_transparent_bg.png",
random_seed=42
):
"""
Generates a 16^levels × 16^levels fractal heart sprite, using every 24-bit RGB color exactly once,
with:
1) A dark 1-px outline (darkest colors) around each smallest sprite.
2) A light 1-px outline (lightest colors) just outside each sprite.
3) A smooth hue-gradient fill inside each sprite.
4) The remaining colors shuffled into the background.
5) The background rendered at 50% opacity (alpha=128), while the sprite stays fully opaque.
"""
# 1. Build base 16×16 mask and outlines
sprite_data = [
"0000010011000000","0000100100100000","0000100100100000","0000010101000000",
"0000000100000000","0000000100000000","0022220102222000","0221112021112200",
"0211111211111200","0211111111111200","0011111111111000","0001111111110000",
"0000111111100000","0000011111000000","0000001110000000","0000000100000000",
]
rows, cols = 16, 16
base_flat = np.array([int(c) for row in sprite_data for c in row], dtype=np.uint8)
base_mask = base_flat.reshape((rows, cols))
# identify fill, boundary, outside, interior indices
base_i = np.arange(rows*cols) // cols
base_j = np.arange(rows*cols) % cols
fill_base = np.where(base_flat > 0)[0]
boundary_base = []
for idx in fill_base:
i, j = base_i[idx], base_j[idx]
for di, dj in [(-1,0),(1,0),(0,-1),(0,1)]:
ni, nj = i+di, j+dj
if ni<0 or ni>=rows or nj<0 or nj>=cols or base_mask[ni,nj]==0:
boundary_base.append(idx)
break
boundary_base = np.unique(boundary_base)
outside_base = []
for idx in range(rows*cols):
if base_flat[idx] == 0:
i, j = base_i[idx], base_j[idx]
for di, dj in [(-1,0),(1,0),(0,-1),(0,1)]:
ni, nj = i+di, j+dj
if 0<=ni0:
outside_base.append(idx)
break
outside_base = np.unique(outside_base)
interior_base = np.setdiff1d(fill_base, boundary_base, assume_unique=True)
# 2. Build 4096×4096 fractal mask
mask1 = (base_mask>0).astype(np.uint8)
mask2 = np.kron(mask1, mask1) # 256×256
fractal = np.kron(mask2, mask1) # 4096×4096
H, W = fractal.shape
total = H * W
flat_f = fractal.ravel()
# block positions for outlines & interior
block_idxs = np.nonzero(mask2.ravel())[0]
br = block_idxs // mask2.shape[1]
bc = block_idxs % mask2.shape[1]
n_blocks = br.size
block_offset = br * 16 * W + bc * 16
# 3. Precompute color metrics
idxs = np.arange(total, dtype=np.uint32)
R = ((idxs>>16)&0xFF).astype(np.float32)/255.0
G = ((idxs>>8 )&0xFF).astype(np.float32)/255.0
B = ((idxs )&0xFF).astype(np.float32)/255.0
lum = 0.299*(R*255) + 0.587*(G*255) + 0.114*(B*255)
mx, mn = np.maximum.reduce([R,G,B]), np.minimum.reduce([R,G,B])
d = mx - mn
S = np.where(mx==0, 0, d/mx)
# compute hue
Hh = np.zeros_like(mx)
nz = d != 0
mR = (R==mx)&nz; mG = (G==mx)&nz; mB = (B==mx)&nz
Hh[mR] = ((G[mR]-B[mR])/d[mR]) % 6
Hh[mG] = ((B[mG]-R[mG])/d[mG]) + 2
Hh[mB] = ((R[mB]-G[mB])/d[mB]) + 4
Hh *= 60
# 4. Segment the palette
lum_order = np.argsort(lum) # ascending lum
# darkest for dark outline
n_dark = n_blocks * boundary_base.size
dark_colors = lum_order[:n_dark]
# lightest for light outline
n_light = n_blocks * outside_base.size
light_colors = lum_order[-n_light:]
# mid palette
mid = lum_order[n_dark:-n_light]
# interior fill = highest-sat from mid
sat_mid = S[mid]
sat_sorted = mid[np.argsort(-sat_mid)]
n_int = n_blocks * interior_base.size
int_colors = sat_sorted[:n_int]
int_colors = int_colors[np.argsort(Hh[int_colors])] # sort by hue
# background = rest
bg_colors = sat_sorted[n_int:]
rng = np.random.default_rng(random_seed)
rng.shuffle(bg_colors)
# 5. Assign colors + alpha
assign = np.empty(total, dtype=np.uint32)
alpha = np.full(total, 128, dtype=np.uint8) # default 50% for bg
# dark outline positions
bi = boundary_base//cols; bj = boundary_base%cols
pos_dark = (block_offset[:,None] + bi[None,:]*W + bj[None,:]).ravel()
assign[pos_dark] = dark_colors
alpha[pos_dark] = 255
# light outline positions
bo_i = outside_base//cols; bo_j = outside_base%cols
pos_light = (block_offset[:,None] + bo_i[None,:]*W + bo_j[None,:]).ravel()
assign[pos_light] = light_colors
alpha[pos_light] = 255
# interior fill positions
bi3 = interior_base//cols; bj3 = interior_base%cols
pos_int = (block_offset[:,None] + bi3[None,:]*W + bj3[None,:]).ravel()
assign[pos_int] = int_colors
alpha[pos_int] = 255
# background
all_bg = np.nonzero(flat_f==0)[0]
bg_pos = np.setdiff1d(all_bg, pos_light, assume_unique=True)
assign[bg_pos] = bg_colors
# alpha remains 128
# 6. Build RGBA image
Rf = ((assign>>16)&0xFF).astype(np.uint8).reshape(H,W)
Gf = ((assign>>8 )&0xFF).astype(np.uint8).reshape(H,W)
Bf = ((assign )&0xFF).astype(np.uint8).reshape(H,W)
Af = alpha.reshape(H,W)
rgba = np.dstack((Rf, Gf, Bf, Af))
img = Image.fromarray(rgba, mode='RGBA')
img.save(output_path)
print(f"Saved {H}×{W} RGBA image to '{output_path}' with 50%-transparent background.")
if __name__ == "__main__":
generate_heart_with_transparent_gray_background()