Skip to content

Instantly share code, notes, and snippets.

@ozgurgulsuna
Last active July 1, 2025 12:45
Show Gist options
  • Select an option

  • Save ozgurgulsuna/7c94c79dbf022e642f1bc4a7e309af96 to your computer and use it in GitHub Desktop.

Select an option

Save ozgurgulsuna/7c94c79dbf022e642f1bc4a7e309af96 to your computer and use it in GitHub Desktop.
A script to help with ordering phone app icons based on 2D HSL color space, which optimized reducing the distance to a specific HSL gradient.
from PIL import Image
import os
import matplotlib.pyplot as plt
import matplotlib.patches as patches
import cv2
import numpy as np
import colorsys
import math
import scipy.optimize
from scipy.optimize import linear_sum_assignment # For Hungarian algorithm
import numpy as np
from PIL import Image
import colorsys
from matplotlib.colors import rgb_to_hsv
def rgb_to_hsv_np(image_np):
"""Convert an RGB NumPy image array to HSV using colorsys (pixel-wise)."""
hsv_image = np.zeros_like(image_np, dtype=np.float32)
for y in range(image_np.shape[0]):
for x in range(image_np.shape[1]):
r, g, b = image_np[y, x][:3] / 255.0 # Normalize to [0,1]
hsv_image[y, x] = colorsys.rgb_to_hsv(r, g, b)
return hsv_image
def rgb_to_hsl_vectorized(rgb):
"""
Convert RGB [0,1] image array to HSL.
Input: (..., 3) RGB array normalized to [0, 1]
Output: (..., 3) HSL array: Hue [0-1], Saturation [0-1], Lightness [0-1]
"""
r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
maxc = np.maximum(np.maximum(r, g), b)
minc = np.minimum(np.minimum(r, g), b)
L = (minc + maxc) / 2.0
S = np.zeros_like(L)
mask = maxc != minc
delta = maxc - minc
S[mask] = delta[mask] / (1.0 - np.abs(2.0 * L[mask] - 1.0))
H = np.zeros_like(L)
rc = (((maxc - r) / 6) + (delta / 2)) / delta
gc = (((maxc - g) / 6) + (delta / 2)) / delta
bc = (((maxc - b) / 6) + (delta / 2)) / delta
cond = (maxc == r) & mask
H[cond] = (bc - gc)[cond]
cond = (maxc == g) & mask
H[cond] = (1.0 / 3.0) + (rc - bc)[cond]
cond = (maxc == b) & mask
H[cond] = (2.0 / 3.0) + (gc - rc)[cond]
H = (H + 1.0) % 1.0
return np.stack([H, S, L], axis=-1)
def compute_distance_matrix_hsv(app_icons, icon_gradient):
"""
Fast computation of HSV distance matrix between app icons and gradient blocks.
Uses vectorized operations and circular hue correction.
"""
N = len(app_icons)
M = len(icon_gradient)
distance_matrix = np.zeros((N, M))
for i, app_icon in enumerate(app_icons):
icon_rgb = np.array(app_icon.convert("RGB")).astype(np.float32) / 255.0
icon_hsv = rgb_to_hsv(icon_rgb)
for j, grad_icon in enumerate(icon_gradient):
grad_rgb = grad_icon.astype(np.float32)
grad_hsv = rgb_to_hsv(grad_rgb)
# Circular hue distance
dh = np.minimum(np.abs(icon_hsv[..., 0] - grad_hsv[..., 0]), 1 - np.abs(icon_hsv[..., 0] - grad_hsv[..., 0]))
ds = icon_hsv[..., 1] - grad_hsv[..., 1]
dv = icon_hsv[..., 2] - grad_hsv[..., 2]
dist = np.sqrt(dh**2 + ds**2 + dv**2)
distance_matrix[i, j] = np.mean(dist)
return distance_matrix
def compute_distance_matrix_hsl(app_icons, icon_gradient):
"""
Compute distance matrix between app icons and gradients in HSL color space.
"""
N = len(app_icons)
M = len(icon_gradient)
distance_matrix = np.zeros((N, M))
for i, app_icon in enumerate(app_icons):
icon_rgb = np.array(app_icon.convert("RGB")).astype(np.float32) / 255.0
icon_hsl = rgb_to_hsl_vectorized(icon_rgb)
for j, grad_icon in enumerate(icon_gradient):
grad_rgb = grad_icon.astype(np.float32) # Already normalized
grad_hsl = rgb_to_hsl_vectorized(grad_rgb)
# Circular hue distance
dh = np.minimum(np.abs(icon_hsl[..., 0] - grad_hsl[..., 0]), 1 - np.abs(icon_hsl[..., 0] - grad_hsl[..., 0]))
ds = icon_hsl[..., 1] - grad_hsl[..., 1]
dl = icon_hsl[..., 2] - grad_hsl[..., 2]
dist = np.sqrt(dh**2 + ds**2 + dl**2)
distance_matrix[i, j] = np.mean(dist)
return distance_matrix
# --- CONFIGURATION ---
# margins = {'top': 57, 'left': 48}
distances = {'x': 270, 'y': 294}
icon_size = {'x': 180, 'y': 180} # Size of each icon in pixels
margins = {'top': 240+ distances['y']/2, 'left': 93 + distances['x']/2}
grid_cols, grid_rows = 4, 6
HSL_WEIGHTS = np.array([1, 1, 20])
# --- FUNCTIONS ---
def manual_box_blur(image, kernel_size=3):
image_np = np.array(image)
kernel = np.ones((kernel_size, kernel_size), np.float32) / (kernel_size ** 2)
blurred_image = cv2.filter2D(image_np, -1, kernel)
return Image.fromarray(blurred_image)
def closest_color(color, app_colors):
print(f"Color: {color}")
print(f"App colors: {app_colors}")
min_dist = float('inf')
closest_app = None
for app_id, app_color in app_colors.items():
color_hsl = colorsys.rgb_to_hls(*color[:3])
app_hsl = colorsys.rgb_to_hls(*app_color[:3])
dist = np.linalg.norm((np.array(color_hsl) - np.array(app_hsl))* HSL_WEIGHTS)
# multiply by a vector to give more weight to hue
if dist < min_dist:
min_dist = dist
closest_app = app_id
print(f"Closest app: {closest_app}, app color: {app_colors[closest_app] if closest_app is not None else None}, color: {color}, distance: {min_dist}")
return closest_app
# --- BLUR IMAGES ---
files = [file for file in os.listdir('.') if file.endswith('.png')]
for file in files:
if file.startswith('b') or file.startswith('db'):
continue
img = Image.open(file)
img_blurred = manual_box_blur(img, 80)
img_blurred.save(f'b_{file}')
for file in [f for f in os.listdir('.') if f.startswith('b_') and not f.startswith('db_')]:
img = Image.open(file)
img_double_blurred = manual_box_blur(img, 220)
img_double_blurred.save(f'db_{file[2:]}')
# --- LOAD IMAGES ---
mask_icon = Image.open('mask_icon.png').convert("RGBA")
if not mask_icon:
print("Mask icon not found.")
exit()
db_images = [Image.open(f).convert("RGBA") for f in os.listdir('.') if f.startswith('db_') and f.endswith('.png')]
if not db_images:
print("No double-blurred images found.")
exit()
b_images = [Image.open(f).convert("RGBA") for f in os.listdir('.') if f.startswith('b_') and not f.startswith('db_') and f.endswith('.png')]
if not b_images:
print("No blurred images found.")
exit()
org_images = [Image.open(f).convert("RGBA") for f in os.listdir('.') if not f.startswith('db_') and not f.startswith('mask_') and not f.startswith('hue_') and not f.startswith('b_') and f.endswith('.png')]
# --- COMBINE ORG IMAGES IN A SINGLE IMAGE ---
# Crop 2 pixels from the left and 95 pixels from the right of each image (x axis only) before combining
num_images = len(org_images)
if num_images == 0:
print("No original images found.")
exit()
cropped_org_images = [img.crop((2, 0, img.width - 93, img.height)) for img in org_images]
org_image_width = sum(img.width for img in cropped_org_images)
org_image_height = max(img.height for img in cropped_org_images)
org_image = Image.new('RGBA', (org_image_width, org_image_height))
x_offset = 0
for img in cropped_org_images:
org_image.paste(img, (x_offset, 0))
x_offset += img.width
# do the same with blurred images
b_image_width = sum(img.width for img in b_images)
b_image_height = max(img.height for img in b_images)
b_image = Image.new('RGBA', (b_image_width, b_image_height))
x_offset = 0
for img in b_images:
img_cropped = img.crop((45, 0, img.width - 46, img.height))
b_image.paste(img_cropped, (x_offset, 0))
x_offset += img_cropped.width
# do the same with double blurred images
db_image_width = sum(img.width for img in db_images)
db_image_height = max(img.height for img in db_images)
db_image = Image.new('RGBA', (db_image_width, db_image_height))
x_offset = 0
for img in db_images:
img_cropped = img.crop((45, 0, img.width - 46, img.height))
db_image.paste(img_cropped, (x_offset, 0))
x_offset += img_cropped.width
# --- DISPLAY ORIGINAL IMAGES ---
fig, ax = plt.subplots(figsize=(org_image_width / 100, org_image_height / 100), dpi=100)
ax.imshow(org_image)
ax.axis('off')
plt.show()
# # --- DISPLAY BLURRED IMAGES ---
# fig, ax = plt.subplots(figsize=(b_image_width / 100, b_image_height / 100), dpi=100)
# ax.imshow(b_image)
# ax.axis('off')
# plt.show()
# # --- DISPLAY DOUBLE-BLURRED IMAGES ---
# fig, ax = plt.subplots(figsize=(db_image_width / 100, db_image_height / 100), dpi=100)
# ax.imshow(db_image)
# ax.axis('off')
# plt.show()
# --- CROP THE IMAGES ---
app_icons = []
grid_cols = grid_cols* len(org_images) # Adjust grid_cols to account for multiple images
grid_rows = grid_rows
for i in range(grid_cols):
for j in range(grid_rows):
x = 91 + i * (91+180)
y = 240 + j * (114 +180)
# if x < org_image.width and y < org_image.height:
cropped_icon = org_image.crop((x, y, x + icon_size['x'], y + icon_size['y']))
app_icons.append(cropped_icon)
# --- DISPLAY CROP ICONS ---
fig, ax = plt.subplots(figsize=(grid_cols, grid_rows))
for idx, icon in enumerate(app_icons):
i = idx % grid_cols
j = idx // grid_cols
x = i * 2
y = grid_rows * 2 - (j + 1) * 2
# Display the cropped icon
ax.imshow(icon, extent=[x, x + 2, y, y + 2])
# Add a rectangle around the icon
# ax.add_patch(patches.Rectangle((x, y), 2, 2, edgecolor='black', fill=False))
ax.set_xlim(0, grid_cols*2)
ax.set_ylim(0, grid_rows*2)
ax.axis('off')
plt.show()
# --- COUNT THE APPS IN THE GRID ---
# also remove black icons
# Remove black icons from app_icons and count non-black icons
non_black_app_icons = []
for icon in app_icons:
blurred_icon = manual_box_blur(icon, 80)
# Check if the center pixel is not black
if blurred_icon.getpixel((icon_size['x'] // 2, icon_size['y'] // 2))[:3] != (0, 0, 0):
non_black_app_icons.append(icon)
app_count = len(non_black_app_icons)
app_icons = non_black_app_icons
print(f"Total non-black apps in the grid: {app_count}")
print(f"Total apps in the grid: {len(app_icons)}")
grid_rows = grid_rows
# grid_cols = math.ceil(app_count / grid_rows) # Adjust grid_cols based on app count
# Adjust grid to match app count exactly
grid_cols = math.ceil(app_count / grid_rows)
total_grid_slots = grid_cols * grid_rows
# Recalculate rows if slots still exceed app count
if total_grid_slots > app_count:
grid_rows = math.ceil(app_count / grid_cols)
total_grid_slots = grid_cols * grid_rows
print(f"Adjusted Grid rows: {grid_rows}, Grid cols: {grid_cols}, Total slots: {total_grid_slots}, App count: {app_count}")
# --- CREATE HUE GRADIENT ---
# check if its already created
if os.path.exists('hue_gradient.png'):
hue_gradient_image = Image.open('hue_gradient.png')
hue_gradient = np.array(hue_gradient_image) / 255.0 # Normalize to [0, 1]
print("Hue gradient loaded from file.")
else:
print("Creating hue gradient...")
width = icon_size['x'] * grid_cols
height = icon_size['y'] * grid_rows
width_large, height_large = width , height # Increase size for better visualization
hue_gradient = np.zeros((height_large, width_large, 3), dtype=np.float32)
starting_hue = 0.91 # Change this value to set the starting hue
for y in range(height_large):
for x in range(width_large):
hue = (starting_hue + x / width_large) % 1.0
val_ratio = y / height_large
saturation, value = (1 - (val_ratio - 0.5) * 2, 1) if val_ratio >= 0.5 else (1, val_ratio * 2)
r, g, b = colorsys.hsv_to_rgb(hue, saturation, value)
hue_gradient[height_large - 1 - y, x] = [r, g, b]
# export the hue gradient to an image
hue_gradient_image = Image.fromarray((hue_gradient * 255).astype(np.uint8))
# Save the hue gradient image
hue_gradient_image.save('hue_gradient.png')
print("Hue gradient created and saved to 'hue_gradient.png'.")
# --- DISPLAY HUE GRADIENT ---
plt.imshow(hue_gradient)
plt.axis('off')
plt.show()
# --- SPLIT THE HUE GRADIENT INTO ICON SIZED PIXEL ARRAYS ---
def split_hue_gradient(hue_gradient, icon_size, grid_cols, grid_rows):
icon_gradient = []
gradient_height, gradient_width = hue_gradient.shape[:2]
for j in range(grid_rows):
for i in range(grid_cols):
x_start = i * icon_size['x']
y_start = j * icon_size['y']
x_end = x_start + icon_size['x']
y_end = y_start + icon_size['y']
# Check if this block is within bounds
if y_end <= gradient_height and x_end <= gradient_width:
block = hue_gradient[y_start:y_end, x_start:x_end]
else:
# Create a black block (same shape as normal icon)
block = np.zeros((icon_size['y'], icon_size['x'], 3), dtype=np.float32)
icon_gradient.append(block)
return icon_gradient
icon_gradient = split_hue_gradient(hue_gradient, icon_size, grid_cols, grid_rows)
if os.path.exists('hue_gradient.png'):
hue_gradient_image = Image.open('hue_gradient.png')
hue_gradient = np.array(hue_gradient_image) / 255.0 # Normalize to [0, 1]
# --- DISPLAY ICON GRADIENTS ---
fig, ax = plt.subplots(figsize=(grid_cols, grid_rows))
for idx, icon_grad in enumerate(icon_gradient):
i = idx % grid_cols
j = idx // grid_cols
x = i * 2
y = grid_rows * 2 - (j + 1) * 2
# Display the icon gradient
ax.imshow(icon_grad, extent=[x, x + 2, y, y + 2])
# Add a rectangle around the icon gradient
ax.add_patch(patches.Rectangle((x, y), 2, 2, edgecolor='black', fill=False))
ax.set_xlim(0, grid_cols*2)
ax.set_ylim(0, grid_rows*2)
ax.axis('off')
plt.show()
# --- COMPUTE DISTANCE MATRIX ---
# for each pixel of an icon compute the distance to an icon sized pixel array of the hue gradient
# and return the average distance for each icon
def compute_distance_matrix(app_icons, icon_gradient, lightness_weight=2.0):
distance_matrix = np.zeros((len(app_icons), len(icon_gradient)))
for i, app_icon in enumerate(app_icons):
icon_rgb = np.array(app_icon.convert("RGB")).astype(np.float32) / 255.0
icon_hsl = rgb_to_hsl_vectorized(icon_rgb)
for j, grad_icon in enumerate(icon_gradient):
grad_rgb = grad_icon.astype(np.float32) # already normalized if your split_hue_gradient outputs normalized data
grad_hsl = rgb_to_hsl_vectorized(grad_rgb)
# Calculate H, S, L differences
dh = np.minimum(np.abs(icon_hsl[..., 0] - grad_hsl[..., 0]), 1 - np.abs(icon_hsl[..., 0] - grad_hsl[..., 0]))
ds = icon_hsl[..., 1] - grad_hsl[..., 1]
dl = icon_hsl[..., 2] - grad_hsl[..., 2]
# Apply weighting to lightness
dl *= lightness_weight
# Combine differences
dist = np.sqrt(dh**2 + ds**2 + dl**2)
distance_matrix[i, j] = np.mean(dist)
return distance_matrix
distance_matrix = compute_distance_matrix(app_icons, icon_gradient, lightness_weight=2.0)
print("Distance matrix shape:", distance_matrix.shape)
print("Distance matrix:", distance_matrix)
# --- PLOT THE DISTANCE MATRIX ---
fig, ax = plt.subplots(figsize=(10, 10))
cax = ax.matshow(distance_matrix, cmap='viridis')
plt.colorbar(cax)
ax.set_title('Distance Matrix')
plt.xlabel('Icon Gradient Index')
plt.ylabel('App Icon Index')
plt.show()
# --- OPTIMIZE ICON PLACEMENT USING HUNGARIAN ALGORITHM ---
row_ind, col_ind = linear_sum_assignment(distance_matrix)
# Build an empty list the same size as icon_gradient
reordered_icons = [None] * len(icon_gradient)
# Place each app_icon in the optimal grid position
for app_idx, grid_idx in zip(row_ind, col_ind):
reordered_icons[grid_idx] = app_icons[app_idx]
# Now `reordered_icons[i]` matches `icon_gradient[i]` in color similarity
fig, ax = plt.subplots(figsize=(grid_cols, grid_rows))
for idx, icon in enumerate(reordered_icons):
if icon is not None and isinstance(icon, Image.Image):
i = idx % grid_cols
j = idx // grid_cols
x = i * 2
y = grid_rows * 2 - (j + 1) * 2
ax.imshow(np.array(icon), extent=[x, x + 2, y, y + 2])
else:
print(f"Warning: icon at index {idx} is invalid ({type(icon)})")
ax.set_xlim(0, grid_cols * 2)
ax.set_ylim(0, grid_rows * 2)
ax.axis('off')
plt.title("Optimized Icon Grid")
plt.show()
# # --- OPTIMIZE ICON PLACEMENT ---
# def optimize_icon_placement(distance_matrix):
# def objective_function(permutation):
# # Calculate the total distance for the given permutation
# return np.sum(distance_matrix[np.arange(len(permutation)), permutation])
# # Initial guess: identity permutation
# initial_guess = np.arange(len(distance_matrix))
# # Use a solver to find the optimal permutation
# result = scipy.optimize.minimize(objective_function, initial_guess, method='Nelder-Mead')
# if not result.success:
# print("Optimization failed:", result.message)
# return None
# return result.x.astype(int)
# # Perform the optimization
# optimal_permutation = optimize_icon_placement(distance_matrix)
# if optimal_permutation is None:
# print("No optimal permutation found.")
# else:
# print("Optimal permutation found:", optimal_permutation)
# # --- REORDER ICONS ---
# reordered_icons = [app_icons[i] for i in optimal_permutation]
# # --- DISPLAY REORDERED ICONS ---
# fig, ax = plt.subplots(figsize=(grid_cols, grid_rows))
# for idx, icon in enumerate(reordered_icons):
# i = idx % grid_cols
# j = idx // grid_cols
# x = i * 2
# y = grid_rows * 2 - (j + 1) * 2
# # Display the reordered icon
# ax.imshow(icon, extent=[x, x + 2, y, y + 2])
# # Add a rectangle around the icon
# ax.add_patch(patches.Rectangle((x, y), 2, 2, edgecolor='black', fill=False))
# ax.set_xlim(0, grid_cols*2)
# ax.set_ylim(0, grid_rows*2)
# ax.axis('off')
# plt.show()
@ozgurgulsuna
Copy link
Author

step-1
step-2
step-3
step-4
step-5

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment