As the owner of a Bambu Lab A1 printer with the AMS Lite, I regularly print trinkets, busts, and other figurines that I enjoy painting. Over time, my paint collection has grown to nearly 28 different colors from the Vallejo Model Color range. I also have a few Citadel paints, but for some reason, I’ve naturally gravitated toward Vallejo. Sometimes I mix colors to get a specific shade, but in many cases, it’s easier—and more consistent—to just buy the exact color I need. Mixing the same shade twice is tricky.
A few days ago, I printed a paint rack to organize and store all my colors. I wanted to arrange them in a meaningful order—either as a rainbow or from dark to light. The rack I printed has a fixed layout of 3 rows by 16 columns, so I was limited in how I could structure things. No matter how I tried, I couldn’t get the order to feel right.
So I looked online for inspiration. I remembered seeing color charts before and eventually stumbled upon an HSB pie chart. It’s essentially a 3D mapping of the RGB color model. In hindsight, maybe I should’ve looked into the CMYK color model instead—it’s more closely tied to pigments and paints, while RGB is meant for screens. My quest lead me to see that my color choices were quite poor and I definitely should look at different colors from now on.
From RGB to the HSB pie chart.
Every digital color can be represented by combining Red (R), Green (G), and Blue (B) light. Each channel ranges from 0 (no intensity) to 255 (full intensity). For example, pure red is written as (255, 0, 0), white as (255, 255, 255) and black as (0, 0, 0). Instead of writing RGB values in decimal, it’s also common to use hexadecimal notation, where 255 is written as FF
and 60 as 3C
. Using this format, yellow—being a mix of red and green at full intensity—is represented as #FFFF00
.
While RGB is widely used, a more intuitive way to describe color is through the HSV color model, which stands for Hue, Saturation, and Value. Sometimes “Brightness” is used in place of “Value”, but in this context, both refer to the same thing: the highest of the RGB components, or Cmax.

Before converting RGB to HSV, the RGB values are normalized to the range [0,1] by dividing each component by 255 (i.e., R’= R/255, and similar for G’ and B’). Based on these normalized values, we define:
\[
\begin{aligned}
C_{\text{max}} &= \max(R’, G’, B’) \\
C_{\text{min}} &= \min(R’, G’, B’) \\
\Delta &= C_{\text{max}} – C_{\text{min}}
\end{aligned}
\]
Hue
Hue describes the type of color and is computed based on which RGB component is the largest. It’s defined as:
\[
H =
\begin{cases}
0^\circ, & \Delta = 0 \\
60^\circ \times \left( \frac{G’ – B’}{\Delta} \bmod 6 \right), & C_{\text{max}} = R’ \\
60^\circ \times \left( \frac{B’ – R’}{\Delta} + 2 \right), & C_{\text{max}} = G’ \\
60^\circ \times \left( \frac{R’ – G’}{\Delta} + 4 \right), & C_{\text{max}} = B’
\end{cases}
\]
Note: If two components are tied for the maximum, the standard approach is to choose based on the order: R’ > G’ > B’.
Saturation
Saturation measures how vivid the color is:
\[
S =
\begin{cases}
0, & C_{\text{max}} = 0 \\
\frac{\Delta}{C_{\text{max}}}, & \text{otherwise}
\end{cases}
\]
Value
Value (or brightness) corresponds to the highest RGB component:
\[V=C_{max}\]
HSV model
Hue in the HSV model is measured in degrees from 0° to 360°, forming a circular spectrum—red appears at both 0° and 360°. In the 2D representation of the hue-saturation plane, hue corresponds to the angle around the circle, while saturation is the radial distance from the center: fully saturated colors lie on the edge, and desaturated colors (grays) lie near the center.
To represent value (or brightness), the model adds a third dimension. This creates a cylinder, where vertical position represents brightness: the bottom of the cylinder corresponds to black (value = 0), and the top represents full brightness (value = 1). Each horizontal slice of the cylinder at a given brightness level forms a 2D hue-saturation circle.
Figure X shows these horizontal slices taken at intervals of 0.2 in brightness. As brightness decreases, colors become darker and more muted, eventually blending into black. Thus, the full 3D HSV model is best visualized as a cylinder, where:
- Hue is the angle around the vertical axis,
- Saturation is the distance from the center axis outward,
- Value is the height along the vertical axis.
This cylindrical form helps visualize how colors shift in hue, become more or less vivid (saturation), and change in brightness—all in a single geometric structure.

Vallejo Game Colors
My dataset consists of the 28 colors from the Vallejo Game Color set. These colors were converted into hexadecimal codes using the website encycolorpedia.com, which matches Vallejo’s color codes with their closest digital equivalents, to see which colors I have, I refer you to Figure X. One thing I noticed is that the brightness on the digital results appears slightly lower for nearly all the colors—this could be due to my display settings, so please take the data with a grain of salt. Nevertheless, the dataset provides a solid basis for analysis.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut elit tellus, luctus nec ullamcorper mattis, pulvinar dapibus leo.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut elit tellus, luctus nec ullamcorper mattis, pulvinar dapibus leo.
import pyvista as pv
import numpy as np
# Cylinder parameters
radius = 1.0
height = 3.0
# Create a basic cylinder
cylinder = pv.Cylinder(center=(0, 0, 0), direction=(0, 0, 1), radius=radius, height=height, resolution=100)
# Function to generate a random point inside the cylinder
def random_point_in_cylinder(radius, height):
while True:
x, y = np.random.uniform(-radius, radius, 2)
if x**2 + y**2 <= radius**2:
break
z = np.random.uniform(-height / 2, height / 2)
return (x, y, z)
# Generate 4 random points
points = np.array([random_point_in_cylinder(radius, height) for _ in range(4)])
# Create small spheres at each point
spheres = [pv.Sphere(radius=0.05, center=pt) for pt in points]
# Start plotting
plotter = pv.Plotter()
plotter.add_mesh(cylinder, color="lightgray", opacity=0.5)
# Add spheres (dots)
for s in spheres:
plotter.add_mesh(s, color="red")
plotter.show()
import pyvista as pv
import numpy as np
import matplotlib.colors as mcolors
num_x = 360
num_y = 100
plane = pv.Plane(i_resolution=num_x-1, j_resolution=num_y-1, i_size=1, j_size=1)
points = plane.points
X = points[:, 0]
Y = points[:, 1]
X_norm = (X - X.min()) / (X.max() - X.min()) # Hue: 0-1
Y_norm = (Y - Y.min()) / (Y.max() - Y.min()) # Saturation: 0-1
hsv = np.stack([X_norm, Y_norm, np.ones_like(X_norm)], axis=1)
rgb = mcolors.hsv_to_rgb(hsv)
colors = (rgb * 255).astype(np.uint8)
plane.point_data['Colors'] = colors
plotter = pv.Plotter()
plotter.add_mesh(plane, scalars='Colors', rgb=True)
plotter.set_background("black")
plotter.show()
import pyvista as pv
import numpy as np
import matplotlib.colors as mcolors
num_x = 360
num_y = 100
plane = pv.Plane(i_resolution=num_x-1, j_resolution=num_y-1, i_size=1, j_size=1)
points = plane.points
X = points[:, 0]
Y = points[:, 1]
X_norm = (X - X.min()) / (X.max() - X.min()) # Hue: 0-1
Y_norm = (Y - Y.min()) / (Y.max() - Y.min()) # Saturation: 0-1
value = np.full_like(X_norm, 0.5) # Value = 0.5 (half brightness)
hsv = np.stack([X_norm, Y_norm, value], axis=1)
rgb = mcolors.hsv_to_rgb(hsv)
colors = (rgb * 255).astype(np.uint8)
plane.point_data['Colors'] = colors
plotter = pv.Plotter()
plotter.add_mesh(plane, scalars='Colors', rgb=True)
plotter.set_background("white")
plotter.show()
import pyvista as pv
import numpy as np
import matplotlib.colors as mcolors
# Grid size
num_x = 360
num_y = 100
# Create HSV plane
plane = pv.Plane(i_resolution=num_x - 1, j_resolution=num_y - 1, i_size=1, j_size=1)
points = plane.points
X = points[:, 0]
Y = points[:, 1]
# Normalize for HSV
hue = (X + 0.5) # from [-0.5, 0.5] → [0, 1]
sat = (Y + 0.5) # from [-0.5, 0.5] → [0, 1]
val = np.ones_like(hue) # constant brightness = 1
# HSV to RGB
hsv = np.stack([hue, sat, val], axis=1)
rgb = mcolors.hsv_to_rgb(hsv)
colors = (rgb * 255).astype(np.uint8)
plane.point_data['Colors'] = colors
# Function to convert hex to dot on plane
def create_dot(hex_color):
rgb = mcolors.to_rgb(f"#{hex_color}")
h, s, v = mcolors.rgb_to_hsv(rgb)
x = h - 0.5
y = s - 0.5
return pv.Sphere(radius=0.01, center=(x, y, 0)), f"#{hex_color}"
# 🎯 Dots for given hex codes
dots = [create_dot("FF00FF"), create_dot("80FFA4")]
# Plot
plotter = pv.Plotter()
plotter.add_mesh(plane, scalars='Colors', rgb=True)
for dot, color in dots:
plotter.add_mesh(dot, color=color)
plotter.set_background("white")
plotter.show()
Circle hsv wheel below with brightness .5, this cna be changed.
import pyvista as pv
import numpy as np
import matplotlib.colors as mcolors
# Grid resolution
n_radius = 100
n_theta = 360
# Create polar grid: r (0 to 1), theta (0 to 2π)
r = np.linspace(0, 1, n_radius)
theta = np.linspace(0, 2 * np.pi, n_theta)
r_grid, theta_grid = np.meshgrid(r, theta)
# Convert to Cartesian coordinates
x = (r_grid * np.cos(theta_grid)).flatten()
y = (r_grid * np.sin(theta_grid)).flatten()
z = np.zeros_like(x)
# Create PolyData with points
points = np.column_stack((x, y, z))
hs_disc = pv.PolyData(points)
# Build cells for surface (quads)
cells = []
for i in range(n_theta - 1):
for j in range(n_radius - 1):
p0 = i * n_radius + j
p1 = p0 + 1
p2 = p0 + n_radius + 1
p3 = p0 + n_radius
cells.append([4, p0, p1, p2, p3])
hs_disc = hs_disc.cast_to_unstructured_grid()
hs_disc.cells = np.array(cells, dtype=np.int64).flatten()
# HSV values per point
hue = theta_grid.flatten() / (2 * np.pi) # from 0 to 1
sat = r_grid.flatten() # from 0 to 1
val = np.full_like(hue, 0.5) # brightness = 0.5
hsv = np.stack([hue, sat, val], axis=1)
rgb = mcolors.hsv_to_rgb(hsv)
colors = (rgb * 255).astype(np.uint8)
# Assign RGB to mesh
hs_disc.point_data["Colors"] = colors
# Plot
plotter = pv.Plotter()
plotter.add_mesh(hs_disc, scalars="Colors", rgb=True)
plotter.set_background("white")
plotter.show()
6 disks with random hex color
import pyvista as pv
import numpy as np
import matplotlib.colors as mcolors
# Brightness levels (disks)
brightness_levels = [1.0, 0.8, 0.6, 0.4, 0.2, 0.0]
spacing = 3 # vertical spacing between disks
n_radius = 100
n_theta = 360
def create_disk(brightness):
r = np.linspace(0, 1, n_radius)
theta = np.linspace(0, 2 * np.pi, n_theta)
r_grid, theta_grid = np.meshgrid(r, theta)
x = (r_grid * np.cos(theta_grid)).flatten()
y = (r_grid * np.sin(theta_grid)).flatten()
z = np.full_like(x, brightness * spacing)
points = np.column_stack((x, y, z))
disk = pv.PolyData(points)
cells = []
for i in range(n_theta - 1):
for j in range(n_radius - 1):
p0 = i * n_radius + j
p1 = p0 + 1
p2 = p0 + n_radius + 1
p3 = p0 + n_radius
cells.append([4, p0, p1, p2, p3])
disk = disk.cast_to_unstructured_grid()
disk.cells = np.array(cells, dtype=np.int64).flatten()
hue = theta_grid.flatten() / (2 * np.pi)
sat = r_grid.flatten()
val = np.full_like(hue, brightness)
hsv = np.stack([hue, sat, val], axis=1)
rgb = mcolors.hsv_to_rgb(hsv)
colors = (rgb * 255).astype(np.uint8)
disk.point_data["Colors"] = colors
return disk
disks = [create_disk(b) for b in brightness_levels]
plotter = pv.Plotter()
for disk in disks:
plotter.add_mesh(disk, scalars="Colors", rgb=True, opacity=0.8)
# Your input hex color
input_hex = "#F83D9E"
# Convert hex to RGB (0 to 1)
input_rgb = np.array(mcolors.to_rgb(input_hex))
# Convert RGB to HSV
input_hsv = mcolors.rgb_to_hsv(input_rgb.reshape(1, -1))[0]
input_hue = input_hsv[0] # 0-1
input_sat = input_hsv[1] # 0-1
input_val = input_hsv[2] # 0-1
# Find closest brightness ring
closest_brightness = min(brightness_levels, key=lambda b: abs(b - input_val))
# Compute point coords
theta = input_hue * 2 * np.pi
radius = input_sat
x = radius * np.cos(theta)
y = radius * np.sin(theta)
z = closest_brightness * spacing
point_sphere = pv.Sphere(radius=0.03, center=(x, y, z))
plotter.add_mesh(point_sphere, color=input_rgb, specular=1.0, smooth_shading=True)
plotter.show()
wheel with several of my vallejo colros:
import pyvista as pv
import numpy as np
import matplotlib.colors as mcolors
# Brightness levels (disks)
brightness_levels = [1.0, 0.8, 0.6, 0.4, 0.2, 0.0]
spacing = 5 # vertical spacing between disks
n_radius = 100
n_theta = 360
def create_disk(brightness):
r = np.linspace(0, 1, n_radius)
theta = np.linspace(0, 2 * np.pi, n_theta)
r_grid, theta_grid = np.meshgrid(r, theta)
x = (r_grid * np.cos(theta_grid)).flatten()
y = (r_grid * np.sin(theta_grid)).flatten()
z = np.full_like(x, brightness * spacing)
points = np.column_stack((x, y, z))
disk = pv.PolyData(points)
cells = []
for i in range(n_theta - 1):
for j in range(n_radius - 1):
p0 = i * n_radius + j
p1 = p0 + 1
p2 = p0 + n_radius + 1
p3 = p0 + n_radius
cells.append([4, p0, p1, p2, p3])
disk = disk.cast_to_unstructured_grid()
disk.cells = np.array(cells, dtype=np.int64).flatten()
hue = theta_grid.flatten() / (2 * np.pi)
sat = r_grid.flatten()
val = np.full_like(hue, brightness)
hsv = np.stack([hue, sat, val], axis=1)
rgb = mcolors.hsv_to_rgb(hsv)
colors = (rgb * 255).astype(np.uint8)
disk.point_data["Colors"] = colors
return disk
# Create and add disks
plotter = pv.Plotter()
for b in brightness_levels:
disk = create_disk(b)
plotter.add_mesh(disk, scalars="Colors", rgb=True, opacity=0.8)
# List of hex colors
hex_colors = [
"#A54336", # warm red
"#DC5E35", # orange-red
"#F8C85E", # warm yellow
"#F8DB5D", # pale yellow
"#DDD1A4", # tan
"#BBB992", # olive beige
"#9A835F", # muted tan
"#99624B", # burnt sienna
"#805C3F", # brown
"#7F6355", # dusty brown
"#544231", # earthy brown
"#3B2C29", # dark brown
"#232A2C", # almost black
"#3C3431", # charcoal
"#402C33", # dark plum-brown
"#372743", # violet-black
"#5A8B78", # muted teal-green
"#32665A", # cool teal
"#324A32", # forest green
"#7C7B66", # army green/grey
"#757C70", # moss grey
"#9AA0A0", # pale grey-blue
"#DDDED7", # off-white grey
"#FDF8F3", # ivory/cream
"#3B4D77", # deep denim blue
"#3A4372", # twilight blue
"#3A2F4B", # midnight violet
"#252527" # black
]
for hex_color in hex_colors:
# Convert hex to RGB
rgb = np.array(mcolors.to_rgb(hex_color))
# Convert to HSV
hsv = mcolors.rgb_to_hsv(rgb.reshape(1, -1))[0]
hue, sat, val = hsv
# Find closest brightness ring
closest_brightness = min(brightness_levels, key=lambda b: abs(b - val))
# Polar to Cartesian
theta = hue * 2 * np.pi
radius = sat
x = radius * np.cos(theta)
y = radius * np.sin(theta)
z = closest_brightness * spacing
# Add sphere at location
sphere = pv.Sphere(radius=0.05, center=(x, y, z))
plotter.add_mesh(sphere, color=rgb, specular=1.0, smooth_shading=True)
plotter.set_background("white")
plotter.show()
3D xyz graph wwith all colors
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
import colorsys
from mpl_toolkits.mplot3d import Axes3D
hex_colors = [
"#A54336", "#DC5E35", "#F8C85E", "#F8DB5D", "#DDD1A4", "#BBB992", "#9A835F",
"#99624B", "#805C3F", "#7F6355", "#544231", "#3B2C29", "#232A2C", "#3C3431",
"#402C33", "#372743", "#5A8B78", "#32665A", "#324A32", "#7C7B66", "#757C70",
"#9AA0A0", "#DDDED7", "#FDF8F3", "#3B4D77", "#3A4372", "#3A2F4B", "#252527"
]
hsl_data = [colorsys.rgb_to_hls(*mcolors.hex2color(c)) for c in hex_colors]
hue = [h for h, l, s in hsl_data]
saturation = [s for h, l, s in hsl_data]
lightness = [l for h, l, s in hsl_data]
fig = plt.figure(figsize=(10, 7))
ax = fig.add_subplot(111, projection='3d')
for h, s, l, color in zip(hue, saturation, lightness, hex_colors):
ax.scatter(h, s, l, color=color, s=150)
ax.plot([h, h], [s, s], [0, l], color=color, linewidth=1)
# Larger fonts for title and labels
ax.set_title("3D Color Distribution with Lightness Lines", fontsize=18)
ax.set_xlabel("Hue", fontsize=14)
ax.set_ylabel("Saturation", fontsize=14)
ax.set_zlabel("Brightness", fontsize=14, labelpad=-30)
ax.set_xlim(0, 1)
ax.set_ylim(0, 1)
ax.set_zlim(0, 1)
# Increase tick label font size on all 3 axes
for axis in [ax.xaxis, ax.yaxis, ax.zaxis]:
for tick in axis.get_ticklabels():
tick.set_fontsize(14)
plt.tight_layout()
plt.show()
under here is the 2D map square
# -*- coding: utf-8 -*-
"""
Created on Wed Jun 11 23:12:23 2025
@author: flori
"""
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
import colorsys
# Provided hex color list
hex_colors = [
"#A54336", "#DC5E35", "#F8C85E", "#F8DB5D", "#DDD1A4", "#BBB992", "#9A835F",
"#99624B", "#805C3F", "#7F6355", "#544231", "#3B2C29", "#232A2C", "#3C3431",
"#402C33", "#372743", "#5A8B78", "#32665A", "#324A32", "#7C7B66", "#757C70",
"#9AA0A0", "#DDDED7", "#FDF8F3", "#3B4D77", "#3A4372", "#3A2F4B", "#252527"
]
# Convert hex colors to HLS and collect hue and saturation
points = []
for hex_color in hex_colors:
rgb = mcolors.hex2color(hex_color)
h, l, s = colorsys.rgb_to_hls(*rgb)
points.append((h, s, hex_color))
# Create scatter plot
plt.figure(figsize=(8, 6))
for h, s, color in points:
plt.scatter(h, s, color=color, edgecolor='black', s=100)
plt.title("Color Distribution by Hue (X) and Saturation (Y)")
plt.xlabel("Hue")
plt.ylabel("Saturation")
plt.grid(True)
plt.xlim(0, 1)
plt.ylim(0, 1)
plt.tight_layout()
plt.show()
Analysis done here:
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
import colorsys
from mpl_toolkits.mplot3d import Axes3D
import numpy as np
from sklearn.cluster import KMeans
# Step 0: Your list of hex colors
hex_colors = [
"#A54336", "#DC5E35", "#F8C85E", "#F8DB5D", "#DDD1A4", "#BBB992", "#9A835F",
"#99624B", "#805C3F", "#7F6355", "#544231", "#3B2C29", "#232A2C", "#3C3431",
"#402C33", "#372743", "#5A8B78", "#32665A", "#324A32", "#7C7B66", "#757C70",
"#9AA0A0", "#DDDED7", "#FDF8F3", "#3B4D77", "#3A4372", "#3A2F4B", "#252527"
]
# Step 1: Convert hex colors to HSL
hsl_data = [colorsys.rgb_to_hls(*mcolors.hex2color(c)) for c in hex_colors]
hsl_array = np.array(hsl_data) # shape: (N_colors, 3)
hue = hsl_array[:, 0]
lightness = hsl_array[:, 1]
saturation = hsl_array[:, 2]
# Step 2: Basic statistics
mean_hsl = np.mean(hsl_array, axis=0)
std_hsl = np.std(hsl_array, axis=0)
print("=== Basic Statistics ===")
print("Mean HSL:", mean_hsl)
print("Std HSL: ", std_hsl)
print()
# Step 3: Histograms of Hue, Saturation, Lightness
fig, axs = plt.subplots(1, 3, figsize=(12, 4))
labels = ["Hue", "Lightness", "Saturation"]
data = [hue, lightness, saturation]
for i in range(3):
axs[i].hist(data[i], bins=10, color='gray', edgecolor='black')
axs[i].set_title(f"{labels[i]} Distribution")
axs[i].set_xlim(0, 1)
plt.suptitle("Histograms of HSL Components")
plt.tight_layout()
plt.show()
from sklearn.cluster import KMeans
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
# Step 4: KMeans clustering with visualization
hsl_array = np.array(hsl_data)
# Number of clusters
n_clusters = 4
kmeans = KMeans(n_clusters=n_clusters, n_init="auto", random_state=0)
labels = kmeans.fit_predict(hsl_array)
centers = kmeans.cluster_centers_
# Convert HSL centers to RGB and then to HEX
import colorsys
import matplotlib.colors as mcolors
center_hex_colors = []
for h, l, s in centers:
r, g, b = colorsys.hls_to_rgb(h, l, s)
center_hex_colors.append(mcolors.to_hex((r, g, b)))
# Create a colormap
cluster_colors = plt.cm.get_cmap("tab10", n_clusters)
# 3D plot
fig = plt.figure(figsize=(10, 7))
ax = fig.add_subplot(111, projection='3d')
for i in range(n_clusters):
cluster_points = hsl_array[labels == i]
ax.scatter(
cluster_points[:, 0], # Hue
cluster_points[:, 2], # Saturation
cluster_points[:, 1], # Lightness
color=cluster_colors(i),
label=f'Cluster {i}',
s=80
)
# Plot the cluster centers using their actual colors
for (h, l, s), hex_color in zip(centers, center_hex_colors):
ax.scatter(h, s, l, color=hex_color, edgecolor='black', marker='o', s=200, linewidth=1.5)
# Axes and labels
ax.set_xlabel("Hue")
ax.set_ylabel("Saturation")
ax.set_zlabel("Lightness")
ax.set_title("KMeans Clustering in HSL Space")
ax.legend()
plt.tight_layout()
plt.show()
# Step 5: Find underrepresented regions using binning
bins = (5, 5, 5) # (H, S, L) grid resolution
hist, edges = np.histogramdd(hsl_array, bins=bins, range=[[0, 1], [0, 1], [0, 1]])
print("=== Underrepresented Regions ===")
underrepresented = np.argwhere(hist == 0)
for idx in underrepresented:
h_bin = f"{edges[0][idx[0]]:.2f}-{edges[0][idx[0]+1]:.2f}"
s_bin = f"{edges[1][idx[1]]:.2f}-{edges[1][idx[1]+1]:.2f}"
l_bin = f"{edges[2][idx[2]]:.2f}-{edges[2][idx[2]+1]:.2f}"
print(f"No colors in bin: Hue={h_bin}, Saturation={s_bin}, Lightness={l_bin}")
interactive map under here
import plotly.graph_objects as go
import matplotlib.colors as mcolors
import colorsys
import numpy as np
# Your list of hex colors
hex_colors = [
"#A54336", "#DC5E35", "#F8C85E", "#F8DB5D", "#DDD1A4", "#BBB992", "#9A835F",
"#99624B", "#805C3F", "#7F6355", "#544231", "#3B2C29", "#232A2C", "#3C3431",
"#402C33", "#372743", "#5A8B78", "#32665A", "#324A32", "#7C7B66", "#757C70",
"#9AA0A0", "#DDDED7", "#FDF8F3", "#3B4D77", "#3A4372", "#3A2F4B", "#252527"
]
# Convert hex to HSL
hsl_data = [colorsys.rgb_to_hls(*mcolors.hex2color(c)) for c in hex_colors]
hue = np.array([h for h, l, s in hsl_data])
lightness = np.array([l for h, l, s in hsl_data])
saturation = np.array([s for h, l, s in hsl_data])
# Format tooltips
hover_texts = [
f"Hex: {hex_colors[i]}
Hue: {hue[i]:.2f}
Saturation: {saturation[i]:.2f}
Lightness: {lightness[i]:.2f}"
for i in range(len(hex_colors))
]
# Create 3D scatter plot
fig = go.Figure(data=[go.Scatter3d(
x=hue,
y=saturation,
z=lightness,
mode='markers',
marker=dict(
size=6,
color=hex_colors, # Actual color
opacity=0.9,
line=dict(width=0.5, color='black')
),
text=hover_texts,
hoverinfo='text'
)])
# Update layout
fig.update_layout(
title="Interactive 3D Color Distribution (HSL)",
scene=dict(
xaxis=dict(title='Hue'),
yaxis=dict(title='Saturation'),
zaxis=dict(title='Lightness'),
),
margin=dict(l=0, r=0, b=0, t=40),
)
# Show
fig.show(renderer="browser")
fig.write_html("color_plot.html", include_plotlyjs='cdn')
You may also like







