Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions lib/image/background_color.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
defmodule Image.BackgroundColor do
@moduledoc """
Resolves a `Image.Pixel.t()` / `:average` value into a concrete pixel in the
image's colorspace.

A background color specification is either the atom `:average` (the average
color of the image), or any color accepted by
`Image.Pixel.to_pixel/2` (a `Color` struct, a hex string, a CSS named color,
an atom or a list of numbers).

In both cases the resolved pixel matches `image`'s number of bands.
"""

alias Image.Pixel
alias Vix.Vips.Image, as: Vimage

@typedoc "A background color specification: the image's average color, or any color."
@type spec :: Pixel.t() | :average

@doc """
Resolves a background color `spec` into a pixel matching `image`'s
interpretation and band layout.

### Arguments

* `image` is any `t:Vix.Vips.Image.t/0`.

* `spec` is `:average` (the image's average color) or any color
accepted by `Image.Pixel.to_pixel/2`.

### Returns

* `{:ok, [number()]}` - the resolved pixel, whose band count matches
`image` (an opaque alpha band is appended for `:average` when the
image has alpha), or

* `{:error, t:Image.Error.t/0}`
"""
@spec resolve(Vimage.t(), spec()) :: {:ok, [number()]} | {:error, Image.Error.t()}
def resolve(%Vimage{} = image, :average) do
case Image.average(image) do
color when is_list(color) ->
if Image.has_alpha?(image) do
# Append an opaque alpha band in the interpretation's own scale via `to_pixel`
case Pixel.to_pixel(image, :black, alpha: :opaque) do
{:ok, opaque_pixel} ->
{:ok, color ++ [List.last(opaque_pixel)]}

{:error, reason} ->
{:error, error("Could not construct alpha #{inspect(color)}", reason)}
end
else
{:ok, color}
end

{:error, reason} ->
{:error, error("Could not compute the image average", reason)}
end
end

def resolve(%Vimage{} = image, color) do
case Pixel.to_pixel(image, color) do
{:ok, pixel} ->
{:ok, pixel}

{:error, reason} ->
{:error, error("Invalid background color #{inspect(color)}", reason)}
end
end

defp error(message, reason) do
%Image.Error{message: "#{message}: #{inspect(reason)}", reason: reason}
end
end
24 changes: 3 additions & 21 deletions lib/image/options/affine.ex
Original file line number Diff line number Diff line change
Expand Up @@ -105,28 +105,10 @@ defmodule Image.Options.Affine do
end
end

# Background handling mirrors `Image.warp_perspective/4`: `:average`
# uses the image's average color and any other value is resolved by
# `Image.Pixel.to_pixel/2` (numbers, lists, CSS names, hex strings).
defp validate_option({:background, :average}, image, options) do
case Image.average(image) do
color when is_list(color) ->
{:cont, Keyword.put(options, :background, color)}

{:error, reason} ->
{:halt,
{:error,
%Image.Error{
message: "Could not get the image average: #{inspect(reason)}",
reason: "Could not get the image average: #{inspect(reason)}"
}}}
end
end

defp validate_option({:background, color} = option, image, options) do
case Pixel.to_pixel(image, color) do
defp validate_option({:background, background}, image, options) do
case Image.BackgroundColor.resolve(image, background) do
{:ok, pixel} -> {:cont, Keyword.put(options, :background, pixel)}
_other -> {:halt, {:error, invalid_option(option)}}
{:error, reason} -> {:halt, {:error, reason}}
end
end

Expand Down
23 changes: 3 additions & 20 deletions lib/image/options/rotate.ex
Original file line number Diff line number Diff line change
Expand Up @@ -81,27 +81,10 @@ defmodule Image.Options.Rotate do
end
end

# `:average` uses the image's average color and any other value is resolved
# by `Image.Pixel.to_pixel/2` (numbers, lists, CSS names, hex strings).
defp validate_option({:background, :average}, image, options) do
case Image.average(image) do
color when is_list(color) ->
{:cont, Keyword.put(options, :background, color)}

{:error, reason} ->
{:halt,
{:error,
%Image.Error{
message: "Could not get the image average: #{inspect(reason)}",
reason: "Could not get the image average: #{inspect(reason)}"
}}}
end
end

defp validate_option({:background, color} = option, image, options) do
case Pixel.to_pixel(image, color) do
defp validate_option({:background, background}, image, options) do
case Image.BackgroundColor.resolve(image, background) do
{:ok, pixel} -> {:cont, Keyword.put(options, :background, pixel)}
_other -> {:halt, {:error, invalid_option(option)}}
{:error, reason} -> {:halt, {:error, reason}}
end
end

Expand Down
22 changes: 3 additions & 19 deletions lib/image/options/warp_perspective.ex
Original file line number Diff line number Diff line change
Expand Up @@ -53,26 +53,10 @@ defmodule Image.Options.WarpPerspective do
end
end

defp validate_option({:background, :average}, image, options) do
case Image.average(image) do
color when is_list(color) ->
options = Keyword.put(options, :background, color)
{:cont, options}

{:error, reason} ->
{:halt,
{:error,
%Image.Error{
message: "Could not get the image average: #{inspect(reason)}",
reason: "Could not get the image average: #{inspect(reason)}"
}}}
end
end

defp validate_option({:background, color} = option, image, options) do
case Pixel.to_pixel(image, color) do
defp validate_option({:background, background}, image, options) do
case Image.BackgroundColor.resolve(image, background) do
{:ok, pixel} -> {:cont, Keyword.put(options, :background, pixel)}
_other -> {:halt, {:error, invalid_option(option)}}
{:error, reason} -> {:halt, {:error, reason}}
end
end

Expand Down
8 changes: 8 additions & 0 deletions test/affine_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,14 @@ defmodule Image.Affine.Test do
assert Image.get_pixel!(result, 0, 0) == [10, 20, 30]
end

test "resolves :average against an image with an alpha band" do
image = Image.new!(20, 20, color: [10, 20, 30, 255])

assert {:ok, result} = Image.translate(image, 5, 0, background: :average)
assert Image.bands(result) == 4
assert Image.get_pixel!(result, 0, 0) == [10, 20, 30, 255]
end

test "rejects an invalid :background" do
image = white_dot(20, 20, 10, 10)
assert {:error, _reason} = Image.translate(image, 5, 0, background: :not_a_colour)
Expand Down
123 changes: 123 additions & 0 deletions test/background_color_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
defmodule Image.BackgroundColorTest do
use ExUnit.Case, async: true

alias Image.BackgroundColor

defp solid(color), do: Image.new!(4, 4, color: color)

defp solid_in(color, colorspace),
do: Image.to_colorspace!(solid(color), colorspace)

describe "resolve/2 with :average" do
test "resolves to the image's average color" do
image = solid([10, 20, 30])
assert BackgroundColor.resolve(image, :average) == {:ok, [10, 20, 30]}
end

test "matches a non-alpha image's band count" do
image = solid([10, 20, 30])
assert {:ok, pixel} = BackgroundColor.resolve(image, :average)
assert length(pixel) == Image.bands(image)
end

test "appends an opaque alpha band for an image with alpha" do
image = solid([10, 20, 30, 255])
assert {:ok, pixel} = BackgroundColor.resolve(image, :average)

# The average is computed on the flattened image, so the alpha band is
# re-appended as fully opaque to match the image's band layout exactly.
assert pixel == [10, 20, 30, 255]
assert length(pixel) == Image.bands(image)
assert List.last(pixel) == 255
end
end

describe "resolve/2 with :average in a non-sRGB interpretation" do
test "preserves LAB color bands verbatim (no sRGB re-interpretation)" do
image = solid_in([120, 80, 40], :lab)
raw = Image.average!(image)

assert BackgroundColor.resolve(image, :average) == {:ok, raw}
end

test "LAB with alpha keeps the raw average and appends a float opaque alpha" do
image = solid_in([120, 80, 40, 255], :lab)
raw = Image.average!(image)

assert {:ok, pixel} = BackgroundColor.resolve(image, :average)
assert pixel == raw ++ [1.0]
assert length(pixel) == Image.bands(image)
end

test "CMYK with alpha keeps the raw average and appends a uchar opaque alpha" do
image = solid_in([120, 80, 40, 255], :cmyk)
raw = Image.average!(image)

assert {:ok, pixel} = BackgroundColor.resolve(image, :average)
assert pixel == raw ++ [255]
assert length(pixel) == Image.bands(image)
end

test "16-bit RGB with alpha appends a ushort opaque alpha" do
image = solid_in([120, 80, 40, 255], :rgb16)
raw = Image.average!(image)

assert {:ok, pixel} = BackgroundColor.resolve(image, :average)
assert pixel == raw ++ [65_535]
assert length(pixel) == Image.bands(image)
end

test "signed-short LAB (labs) with alpha uses libvips' 65_535 opaque alpha" do
# `:labs` bands are signed shorts ({:s, 16}), but libvips stores the
# alpha band in the unsigned 0..65_535 range. The opaque alpha must
# therefore follow the interpretation (65_535), not the band format's
# signed maximum (32_767), matching what `Image.Pixel.to_pixel/3` does.
image = solid_in([120, 80, 40, 255], :labs)
raw = Image.average!(image)

assert {:ok, pixel} = BackgroundColor.resolve(image, :average)
assert pixel == raw ++ [65_535]
assert length(pixel) == Image.bands(image)
end
end

describe "resolve/2 with a color spec" do
test "resolves a named color into the image's band layout" do
image = solid([0, 0, 0])
assert BackgroundColor.resolve(image, :red) == {:ok, [255, 0, 0]}
end

test "appends an opaque alpha band when the image has alpha" do
image = solid([0, 0, 0, 255])
assert {:ok, pixel} = BackgroundColor.resolve(image, :red)

assert pixel == [255, 0, 0, 255]
assert length(pixel) == Image.bands(image)
end

test "forwards other spec forms (hex string) to Image.Pixel" do
image = solid([0, 0, 0])
assert BackgroundColor.resolve(image, "#00ff00") == {:ok, [0, 255, 0]}
end
end

describe "resolve/2 error handling" do
test "wraps an invalid color in an Image.Error" do
image = solid([0, 0, 0])

assert {:error, %Image.Error{} = error} =
BackgroundColor.resolve(image, :definitely_not_a_color)

assert error.message =~ "Invalid background color :definitely_not_a_color"
end

test "preserves the underlying reason from Image.Pixel" do
image = solid([0, 0, 0])

assert {:error, %Image.Error{reason: reason}} =
BackgroundColor.resolve(image, "not-a-hex")

assert reason == %Color.UnknownColorNameError{name: "not-a-hex"}
end
end
end
8 changes: 8 additions & 0 deletions test/rotate_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ defmodule Image.Rotate.Test do
assert {:ok, %Vimage{}} = Image.rotate(image, 45, background: :average)
end

test "resolves :average against an image with an alpha band" do
image = Image.new!(20, 20, color: [10, 20, 30, 255])

assert {:ok, result} = Image.rotate(image, 45, background: :average)
assert Image.bands(result) == 4
assert Image.get_pixel!(result, 0, 0) == [10, 20, 30, 255]
end

test "rejects an invalid :background" do
image = white_dot(20, 20, 2, 3)
assert {:error, _reason} = Image.rotate(image, 45, background: :not_a_colour)
Expand Down