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

centroid()

@type centroid() :: {number(), number(), number()}

cluster()

@type cluster() :: %{centroid: centroid(), mass: number(), members: [member()]}

member()

@type member() :: %{
  output: term(),
  oklab: Color.Oklab.t(),
  oklch: Color.Oklch.t(),
  mass: number()
}

Functions

distance(arg1, arg2, ab_weight)

@spec distance(centroid(), centroid(), number()) :: float()

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

  • a is the first Oklab point as {l, a, b}.

  • b is the second Oklab point as {l, a, b}.

  • ab_weight is the multiplier on (a, b) relative to L. Pass 2.0 to 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

from_colors(colors, options \\ [])

@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

Options

  • :weights is an optional list of non-negative numbers, the same length as colors. Default: every colour weighted 1.0.

Returns

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])

merge_pair(map1, map2)

@spec merge_pair(cluster(), cluster()) :: cluster()

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/0 map 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

merge_until(clusters, target_count, options \\ [])

@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

  • clusters is a list of cluster/0 maps.

  • target_count is the maximum number of clusters in the output.

Options

  • :ab_weight is the multiplier on the chromatic axes (a, b) in the Oklab distance metric, relative to lightness L. Default 2.0.

Returns

  • A list of cluster/0 maps, 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.

representative(map, options \\ [])

@spec representative(
  cluster(),
  keyword()
) :: term()

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 largest mass × 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

Options

  • :ab_weight — see merge_until/3. Default 2.0.

  • :rep_chroma_threshold — Oklch chroma above which the chromatic branch is taken. Default 0.03.

Returns

  • The :output field 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"