Last active
July 1, 2025 12:45
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment





