Color.Palette.Cluster
(Color v0.13.0)
Copy Markdown
Low-level perceptual clustering primitives in Oklab.
This module is the seam between Color.Palette.Summarize
(which clusters arbitrary colour lists) and the
:image library's pixel
extraction pipeline (which clusters image pixels via Scholar
K-means and then collapses near-duplicates). Both call the
same primitives so the algorithm doesn't drift between the
two callers.
Two operations are exposed:
merge_until/3— agglomerative bottom-up merging. Takes a list of clusters and merges the closest pair (mass-weighted Oklab distance) repeatedly until the target count is reached.representative/2— given a cluster, pick one of its members as the cluster's swatch using a centroid-aware rule. Highest-chroma member when the centroid is chromatic; closest to centroid when achromatic.
A small helper, from_colors/2, builds initial singleton
clusters from a list of colour inputs (with optional weights),
which is what Color.Palette.Summarize uses to bootstrap.
Cluster shape
A cluster is a plain map with three keys:
%{
centroid: {l, a, b}, # Oklab, mass-weighted mean of members
mass: number, # sum of member masses
members: [member] # the originals that fell into this cluster
}A member is also a plain map:
%{
output: term, # what to return when this member is picked
oklab: %Color.Oklab{},
oklch: %Color.Oklch{}, # null L/C/H normalised to 0.0
mass: number
}Members carry both Oklab (rectangular, used for distance) and Oklch (cylindrical, used for chroma checks) so callers don't re-convert during inner loops.
Distance and the chromatic-axis weight
Distance between two clusters is mass-aware Euclidean in Oklab
with the chromatic axes scaled by ab_weight:
d² = ΔL² + ab_weight · (Δa² + Δb²)Default ab_weight = 2.0. The intuition: hue mismatch is more
perceptually salient than lightness mismatch, so weighting
(a, b) higher than L keeps clusters from accidentally
merging across colour-category lines.
Use from the :image library
:image's K-means pass produces a centroid array and a
per-pixel cluster-assignment array. To collapse near-duplicate
centroids and pick swatches, build clusters of the shape
above (one member per pixel, or per uniqued pixel) and call
merge_until/3 followed by representative/2.
Summary
Functions
Mass-weighted Euclidean distance between two Oklab points,
with the chromatic axes (a, b) scaled by ab_weight
relative to lightness L.
Builds initial singleton clusters from a list of colour inputs. Each input becomes its own cluster; the cluster centroid is the input's Oklab coordinate and the cluster's sole member is a fully-prepared member map.
Merges two clusters into one. The merged centroid is the
mass-weighted mean of the inputs; the merged member list is
the concatenation of the inputs'. Public so the
:image library can re-use it after Scholar's K-means
produces clusters with explicit assignment masses.
Merges the closest cluster pair (by mass-weighted Oklab
distance) until at most target_count clusters remain.
Picks one of a cluster's members as the cluster's swatch and
returns that member's :output field.
Types
@type member() :: %{ output: term(), oklab: Color.Oklab.t(), oklch: Color.Oklch.t(), mass: number() }
Functions
Mass-weighted Euclidean distance between two Oklab points,
with the chromatic axes (a, b) scaled by ab_weight
relative to lightness L.
Exposed so the :image library can use the same metric for
its own merge / dedupe passes without redefining the formula.
Arguments
ais the first Oklab point as{l, a, b}.bis the second Oklab point as{l, a, b}.ab_weightis the multiplier on(a, b)relative toL. Pass2.0to match the default merging metric.
Returns
- A non-negative number.
Examples
iex> Color.Palette.Cluster.distance({0.5, 0.0, 0.0}, {0.5, 0.0, 0.0}, 2.0)
0.0
iex> Color.Palette.Cluster.distance({0.0, 0.0, 0.0}, {1.0, 0.0, 0.0}, 2.0)
1.0
@spec from_colors( [Color.input()], keyword() ) :: [cluster()]
Builds initial singleton clusters from a list of colour inputs. Each input becomes its own cluster; the cluster centroid is the input's Oklab coordinate and the cluster's sole member is a fully-prepared member map.
Arguments
colorsis a list of values accepted byColor.new/1.
Options
:weightsis an optional list of non-negative numbers, the same length ascolors. Default: every colour weighted1.0.
Returns
- A list of
cluster/0maps, one per input.
Examples
iex> [c] = Color.Palette.Cluster.from_colors(["#ff0000"])
iex> c.mass
1.0
iex> length(c.members)
1
iex> [_, _] = Color.Palette.Cluster.from_colors(["#ff0000", "#0000ff"], weights: [3.0, 1.0])
Merges two clusters into one. The merged centroid is the
mass-weighted mean of the inputs; the merged member list is
the concatenation of the inputs'. Public so the
:image library can re-use it after Scholar's K-means
produces clusters with explicit assignment masses.
Arguments
Returns
- A single
cluster/0map with the combined mass and members.
Examples
iex> [a, b] = Color.Palette.Cluster.from_colors(["#ff0000", "#0000ff"])
iex> merged = Color.Palette.Cluster.merge_pair(a, b)
iex> merged.mass
2.0
iex> length(merged.members)
2
@spec merge_until([cluster()], non_neg_integer(), keyword()) :: [cluster()]
Merges the closest cluster pair (by mass-weighted Oklab
distance) until at most target_count clusters remain.
Centroids are updated as the mass-weighted mean of the two
merged clusters; member lists are concatenated. If the input
already has ≤ target_count clusters the input is returned
unchanged.
Arguments
clustersis a list ofcluster/0maps.target_countis the maximum number of clusters in the output.
Options
:ab_weightis the multiplier on the chromatic axes(a, b)in the Oklab distance metric, relative to lightnessL. Default2.0.
Returns
- A list of
cluster/0maps, length≤ target_count.
Examples
iex> [c] =
...> Color.Palette.Cluster.from_colors(["#ff0000", "#fe0202"])
...> |> Color.Palette.Cluster.merge_until(1)
iex> length(c.members)
2
iex> Color.Palette.Cluster.merge_until([], 5)
[]Complexity
Pairwise distances are recomputed each merge, giving an
O(n³) worst case in the number of input clusters. Suitable
for the typical palette sizes (tens of clusters); for
thousands of inputs, run an upstream K-means pass and call
this only on the resulting centroids.
Picks one of a cluster's members as the cluster's swatch and
returns that member's :output field.
The rule is centroid-aware:
If the centroid is chromatic (Oklch C above
:rep_chroma_threshold), prefer the member with the largestmass × Oklch chroma. This favours the most vivid representative while still honouring weight, so under heavy mass weighting the rep tracks the centroid's hue rather than being hijacked by a high-chroma minority.If the centroid is achromatic, prefer the member nearest the centroid in mass-weighted Oklab distance, with member mass as a tiebreaker. This keeps the rep on the cluster's tonal axis instead of leaning warm or cool.
Arguments
clusteris acluster/0map.
Options
:ab_weight— seemerge_until/3. Default2.0.:rep_chroma_threshold— Oklch chroma above which the chromatic branch is taken. Default0.03.
Returns
- The
:outputfield of the chosen member (typically a%Color.SRGB{}struct, but anything the caller stored when building the member).
Examples
iex> [cluster] =
...> Color.Palette.Cluster.from_colors(["#ff0000", "#cc4040"])
...> |> Color.Palette.Cluster.merge_until(1)
iex> Color.Palette.Cluster.representative(cluster) |> Color.to_hex()
"#ff0000"