Switch to edge-weighted color extraction algo
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user