Experiment: show dominant colors in collected record details page

This commit is contained in:
Claudio Ortolina
2025-06-07 14:36:29 +01:00
parent 0aa0a19a00
commit ccb3089ddf
3 changed files with 132 additions and 4 deletions
@@ -0,0 +1,118 @@
defmodule MusicLibrary.Records.DominantColors do
@moduledoc """
Extracts dominant colors from album cover images using Vix.
Initially 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"].
"""
@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
hex_colors = Enum.map(colors, &rgb_to_hex/1)
{:ok, hex_colors}
end
end
defp prepare_image_for_analysis(image) do
with {:ok, resized} <- Operation.thumbnail_image(image, 150) do
ensure_rgb_channels(resized)
end
end
defp ensure_rgb_channels(image) 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"}
end
end
defp extract_colors_via_sampling(image, num_colors) do
width = Image.width(image)
height = Image.height(image)
# Sample every nth pixel to get a good distribution
sample_step = max(1, div(min(width, height), 20))
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
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
end)
if length(pixels) > 0 do
colors = analyze_color_histogram(pixels, num_colors)
{:ok, colors}
else
{:error, "No valid pixels found for color extraction"}
end
end
defp analyze_color_histogram(pixels, num_colors) do
# Simple frequency-based approach with color grouping
pixels
|> group_similar_colors()
|> 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
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}
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())
end
end
@@ -31,6 +31,7 @@ defmodule MusicLibraryWeb.CollectionLive.Show do
def handle_params(%{"id" => id}, _, socket) do
record = Records.get_record!(id)
last_listened_track = Records.get_last_listened_track(record)
{:ok, dominant_colors} = Records.DominantColors.extract_dominant_colors(record.cover_data)
socket =
if record.selected_release_id do
@@ -44,6 +45,7 @@ defmodule MusicLibraryWeb.CollectionLive.Show do
|> assign(:current_section, :collection)
|> assign(:page_title, page_title(socket.assigns.live_action, record))
|> assign(:record, record)
|> assign(:dominant_colors, dominant_colors)
|> assign(:last_listened_track, last_listened_track)}
end
@@ -16,10 +16,18 @@
<h2 class="mt-1 flex font-semibold text-base sm:text-lg leading-5 text-zinc-700 dark:text-zinc-300 text-wrap">
{@record.title}
</h2>
<p class="mt-2 text-sm leading-5 text-zinc-500 dark:text-zinc-400">
{Records.Record.format_release_date(@record.release_date)} · {format_label(
@record.format
)} · {type_label(@record.type)}
<p class="mt-2 flex items-center text-sm leading-5 text-zinc-500 dark:text-zinc-400">
<span
:for={color <- @dominant_colors}
class="inline-block w-4 h-4 mr-1"
style={"background-color: #{color}"}
>
</span>
<span class="ml-1">
{Records.Record.format_release_date(@record.release_date)} · {format_label(
@record.format
)} · {type_label(@record.type)}
</span>
</p>
</div>
<.actions_menu id={@record.id} background_container_target="#records > li">