Switch to edge-weighted color extraction algo

This commit is contained in:
Claudio Ortolina
2025-06-07 21:53:47 +01:00
parent be8b7ead5f
commit 96ceb5c7f6
2 changed files with 140 additions and 69 deletions
+61 -69
View File
@@ -1,40 +1,47 @@
defmodule MusicLibrary.Records.DominantColors do
@moduledoc """
Extracts dominant colors from album cover images using Vix.
Simple edge-weighted color extraction using Vix.
Initially by Claude, using Sonnet 4.
This approach weights colors based on their proximity to edges,
giving more importance to colors from visually significant regions
without requiring complex algorithms.
Generated by Claude, using Sonnet 4.
"""
alias Vix.Vips.{Image, Operation}
@doc """
Extracts the n most dominant colors from image data (defaults to 5)
and returns them in hex format, e.g. ["#FF5733", "#33C3FF", "#75FF33"].
Extracts dominant colors weighted by edge proximity.
Colors near edges (important features) are given higher importance.
"""
@spec extract_dominant_colors(binary(), pos_integer()) :: {:ok, [String.t()]} | {:error, term()}
def extract_dominant_colors(image_data, num_colors \\ 5) do
with {:ok, image} <- Image.new_from_buffer(image_data),
{:ok, processed_image} <- prepare_image_for_analysis(image),
{:ok, colors} <- extract_colors_via_sampling(processed_image, num_colors) do
{:ok, resized} <- Operation.thumbnail_image(image, 200),
{:ok, rgb_image} <- ensure_rgb_channels(resized),
{:ok, edge_map} <- create_edge_map(rgb_image),
{:ok, colors} <- extract_edge_weighted_colors(rgb_image, edge_map, num_colors) do
hex_colors = Enum.map(colors, &rgb_to_hex/1)
{:ok, hex_colors}
end
end
@doc """
Same as `extract-dominant_colors/2`, but raises an error if extraction fails.
Same as `extract_dominant_colors/2`, but raises an error on failure.
"""
@spec extract_dominant_colors!(binary(), pos_integer()) :: [String.t()] | no_return
@spec extract_dominant_colors!(binary(), integer()) :: [String.t()] | no_return
def extract_dominant_colors!(image_data, num_colors \\ 5) do
case extract_dominant_colors(image_data, num_colors) do
{:ok, colors} -> colors
{:error, reason} -> raise "Failed to extract dominant colors: #{inspect(reason)}"
{:error, reason} -> raise "Failed to extract dominant colors: #{reason}"
end
end
defp prepare_image_for_analysis(image) do
with {:ok, resized} <- Operation.thumbnail_image(image, 150) do
ensure_rgb_channels(resized)
defp create_edge_map(image) do
# Convert to grayscale for edge detection
# Apply Sobel edge detection - built into Vix!
with {:ok, gray} <- Operation.colourspace(image, :VIPS_INTERPRETATION_B_W) do
Operation.sobel(gray)
end
end
@@ -42,88 +49,73 @@ defmodule MusicLibrary.Records.DominantColors do
bands = Image.bands(image)
cond do
bands >= 3 ->
# Image already has 3+ channels (RGB or RGBA), use as-is
{:ok, image}
bands == 1 ->
# Grayscale image, convert to 3-channel by copying the single channel
Operation.bandjoin([image, image, image])
true ->
{:error, "Unsupported image format with #{bands} bands"}
bands >= 3 -> {:ok, image}
bands == 1 -> Operation.bandjoin([image, image, image])
true -> {:error, "Unsupported image format"}
end
end
defp extract_colors_via_sampling(image, num_colors) do
width = Image.width(image)
height = Image.height(image)
defp extract_edge_weighted_colors(rgb_image, edge_map, num_colors) do
width = Image.width(rgb_image)
height = Image.height(rgb_image)
# Sample every nth pixel to get a good distribution
sample_step = max(1, div(min(width, height), 10))
# Sample pixels with edge-based weighting
# Sample every 3rd pixel for performance
weighted_pixels =
for y <- 0..(height - 1)//3,
x <- 0..(width - 1)//3 do
with {:ok, [r, g, b | _]} <- Operation.getpoint(rgb_image, x, y),
{:ok, [edge_strength]} <- Operation.getpoint(edge_map, x, y) do
# Weight this pixel by edge strength
# Normalize edge strength
weight = max(1, trunc(edge_strength / 50))
color = {trunc(r), trunc(g), trunc(b)}
pixels =
for y <- 0..(height - 1)//sample_step,
x <- 0..(width - 1)//sample_step do
case Operation.getpoint(image, x, y) do
{:ok, [r, g, b | _]} ->
{trunc(r), trunc(g), trunc(b)}
{:ok, [gray]} ->
gray_val = trunc(gray)
{gray_val, gray_val, gray_val}
{:ok, [r, g]} ->
# Handle 2-channel images
{trunc(r), trunc(g), 0}
_ ->
nil
# Return the color multiple times based on edge weight
List.duplicate(color, weight)
else
_ -> []
end
end
|> Enum.reject(fn
{r, g, b} ->
# Filter out very dark or very light colors
brightness = (r + g + b) / 3
brightness < 20 || brightness > 235
nil ->
true
|> List.flatten()
|> Enum.reject(fn {r, g, b} ->
# Filter out very dark/light colors
brightness = (r + g + b) / 3
brightness < 15 || brightness > 240
end)
if length(pixels) > 0 do
colors = analyze_color_histogram(pixels, num_colors)
if length(weighted_pixels) > 0 do
colors = cluster_weighted_colors(weighted_pixels, num_colors)
{:ok, colors}
else
{:error, "No valid pixels found for color extraction"}
{:error, "No valid edge-weighted pixels found"}
end
end
defp analyze_color_histogram(pixels, num_colors) do
# Simple frequency-based approach with color grouping
defp cluster_weighted_colors(pixels, num_colors) do
pixels
|> group_similar_colors()
# Slightly larger buckets for better clustering
|> group_similar_colors(40)
|> Enum.frequencies()
|> Enum.sort_by(fn {_color, count} -> count end, :desc)
|> Enum.take(num_colors)
|> Enum.map(fn {{r, g, b}, _count} -> {r, g, b} end)
end
defp group_similar_colors(pixels) do
defp group_similar_colors(pixels, bucket_size) do
Enum.map(pixels, fn {r, g, b} ->
# Group colors into buckets to reduce similar colors
bucket_size = 64
grouped_r = div(r, bucket_size) * bucket_size
grouped_g = div(g, bucket_size) * bucket_size
grouped_b = div(b, bucket_size) * bucket_size
{grouped_r, grouped_g, grouped_b}
{
div(r, bucket_size) * bucket_size,
div(g, bucket_size) * bucket_size,
div(b, bucket_size) * bucket_size
}
end)
end
defp rgb_to_hex({r, g, b}) do
"#" <>
(Integer.to_string(r, 16) |> String.pad_leading(2, "0") |> String.upcase()) <>
(Integer.to_string(g, 16) |> String.pad_leading(2, "0") |> String.upcase()) <>
(Integer.to_string(b, 16) |> String.pad_leading(2, "0") |> String.upcase())
(r |> Integer.to_string(16) |> String.pad_leading(2, "0") |> String.upcase()) <>
(g |> Integer.to_string(16) |> String.pad_leading(2, "0") |> String.upcase()) <>
(b |> Integer.to_string(16) |> String.pad_leading(2, "0") |> String.upcase())
end
end