diff --git a/lib/image.ex b/lib/image.ex index 8aebddff..345981b7 100644 --- a/lib/image.ex +++ b/lib/image.ex @@ -2214,14 +2214,16 @@ defmodule Image do ### Returns - * An RGB color as a three-element list of - integers. + * `{:ok, color}` where `color` is an RGB color as a + three-element list of integers, or + + * `{:error, reason}` ### Example iex> image = Image.new!(50, 50, color: [0, 255, 0]) iex> Image.chroma_color(image) - [0, 255, 0] + {:ok, [0, 255, 0]} """ @@ -2231,7 +2233,7 @@ defmodule Image do @doc subject: "Operation", since: "0.13.0" - @spec chroma_color(image :: Vimage.t()) :: Pixel.t() + @spec chroma_color(image :: Vimage.t()) :: {:ok, [number()]} | {:error, error()} def chroma_color(%Vimage{} = image) do with {:ok, flattened} <- flatten(image), {:ok, cropped} <- Image.crop(flattened, 0, 0, 10, 10) do @@ -2239,6 +2241,40 @@ defmodule Image do end end + @doc """ + Automatically determine the chroma key + color of an image or raise an exception. + + The top left 10x10 pixels of the flattened + image are averaged to produce a color sample + that can then be used by `Image.chroma_mask/2`, + `Image.chroma_key/2` and `Image.trim/2`. + + ### Argument + + * `image` is any `t:Vix.Vips.Image.t/0`. + + ### Returns + + * An RGB color as a three-element list of integers. + + ### Example + + iex> image = Image.new!(50, 50, color: [0, 255, 0]) + iex> Image.chroma_color!(image) + [0, 255, 0] + + """ + @doc subject: "Operation", since: "0.73.0" + + @spec chroma_color!(image :: Vimage.t()) :: [number()] | no_return() + def chroma_color!(%Vimage{} = image) do + case chroma_color(image) do + {:ok, color} -> color + {:error, reason} -> raise Image.Error, reason + end + end + defp max_band_index(image) do Image.bands(image) - 1 end @@ -2330,15 +2366,17 @@ defmodule Image do # The mask is computed from the color bands only so any alpha # band is split off and the color is truncated to match. {color_image, _alpha} = split_alpha(image) - color = maybe_calculate_color(image, color) - color = color |> List.wrap() |> Enum.take(bands(color_image)) - color_image - |> Math.subtract!(color) - |> Math.pow!(2) - |> Operation.bandmean!() - |> Math.greater_than!(3 * threshold ** 2) - |> wrap(:ok) + with {:ok, color} <- maybe_calculate_color(image, color) do + color = color |> List.wrap() |> Enum.take(bands(color_image)) + + color_image + |> Math.subtract!(color) + |> Math.pow!(2) + |> Operation.bandmean!() + |> Math.greater_than!(3 * threshold ** 2) + |> wrap(:ok) + end end def chroma_mask(%Vimage{} = image, %{greater_than: greater_than, less_than: less_than}) do @@ -2353,7 +2391,7 @@ defmodule Image do end defp maybe_calculate_color(image, :auto), do: chroma_color(image) - defp maybe_calculate_color(_image, color), do: color + defp maybe_calculate_color(_image, color), do: {:ok, color} @doc """ Return a chroma-based masked image or raises @@ -3053,28 +3091,34 @@ defmodule Image do ### Returns - * A list of average pixel values which can - be interpreted as the average color of the - image. + * `{:ok, averages}` where `averages` is a list of average + pixel values which can be interpreted as the average + color of the image, or + + * `{:error, reason}` ### Example iex> Image.open!("./test/support/images/Hong-Kong-2015-07-1998.jpg") ...> |> Image.average() - [66, 86, 106] + {:ok, [66, 86, 106]} """ @doc since: "0.27.0" - @spec average(Vimage.t()) :: Pixel.t() | {:error, error()} + @spec average(Vimage.t()) :: {:ok, [number()]} | {:error, error()} def average(%Vimage{} = image) do {color, alpha} = split_alpha(image) - with {:ok, averages} <- band_averages(color, alpha) do - case band_format(color) do - {:f, _bits} -> averages - _integer_format -> Enum.map(averages, &round/1) - end + case band_averages(color, alpha) do + {:ok, averages} -> + case band_format(color) do + {:f, _bits} -> {:ok, averages} + _integer_format -> {:ok, Enum.map(averages, &round/1)} + end + + {:error, raw} -> + {:error, Image.Error.wrap(raw, operation: :average)} end end @@ -3141,11 +3185,11 @@ defmodule Image do """ @doc since: "0.27.0" - @spec average!(Vimage.t()) :: Pixel.t() | no_return() + @spec average!(Vimage.t()) :: [number()] | no_return() def average!(%Vimage{} = image) do case average(image) do + {:ok, color} -> color {:error, reason} -> raise Image.Error, reason - color -> color end end @@ -6131,8 +6175,8 @@ defmodule Image do end defp find_trim_to_color(image, options) do - with {:ok, options} <- Options.Trim.validate_options(image, options) do - background = maybe_calculate_color(image, options.background) + with {:ok, options} <- Options.Trim.validate_options(image, options), + {:ok, background} <- maybe_calculate_color(image, options.background) do threshold = options.threshold case Operation.find_trim(image, background: background, threshold: threshold) do diff --git a/lib/image/background_color.ex b/lib/image/background_color.ex index 830974c0..05c38d7a 100644 --- a/lib/image/background_color.ex +++ b/lib/image/background_color.ex @@ -58,7 +58,7 @@ defmodule Image.BackgroundColor do case Image.average(image) do # The average has no alpha band, so an opaque one is appended when the # image has alpha. - color when is_list(color) -> + {:ok, color} -> put_alpha_band(image, color, :opaque) {:error, reason} -> diff --git a/test/average_test.exs b/test/average_test.exs index 1422e511..fa0888cb 100644 --- a/test/average_test.exs +++ b/test/average_test.exs @@ -1,10 +1,12 @@ defmodule Image.AverageTest do use ExUnit.Case, async: true + import Image.TestSupport + describe "average/1" do test "averages integer images to rounded integers" do image = Image.new!(4, 4, color: [10, 20, 30]) - assert Image.average(image) == [10, 20, 30] + assert Image.average(image) == {:ok, [10, 20, 30]} end test "weights the average by the alpha band" do @@ -15,13 +17,13 @@ defmodule Image.AverageTest do white = Image.new!(10, 10, color: [255, 255, 255, 255]) {:ok, composed} = Image.compose(transparent_red, white, x: 0, y: 0) - assert Image.average(composed) == [255, 255, 255] + assert Image.average(composed) == {:ok, [255, 255, 255]} end test "falls back to the unweighted color-band average for a fully transparent image" do transparent_red = Image.new!(20, 20, color: [255, 0, 0, 0]) - assert Image.average(transparent_red) == [255, 0, 0] + assert Image.average(transparent_red) == {:ok, [255, 0, 0]} end test "preserves float precision for float interpretations" do @@ -30,7 +32,7 @@ defmodule Image.AverageTest do Image.new!(4, 4, color: [10, 20, 30]) |> Image.to_colorspace!(:scrgb) - assert [r, g, b] = Image.average(scrgb) + assert {:ok, [r, g, b]} = Image.average(scrgb) assert is_float(r) and is_float(g) and is_float(b) assert r > 0.0 and r < 1.0 assert g > 0.0 and g < 1.0 @@ -42,8 +44,18 @@ defmodule Image.AverageTest do Image.new!(4, 4, color: [120, 80, 40]) |> Image.to_colorspace!(:lab) - assert [l, a, b] = Image.average(lab) + assert {:ok, [l, a, b]} = Image.average(lab) assert is_float(l) and is_float(a) and is_float(b) end + + test "wraps a libvips failure as an Image.Error tagged with the operation" do + # JPEG with a readable header, but truncated so opening it + # succeeds, but reading pixels fails + whole = File.read!(image_path("Hong-Kong-2015-07-1998.jpg")) + image = Image.from_binary!(binary_part(whole, 0, div(byte_size(whole), 2))) + + assert {:error, %Image.Error{operation: :average, reason: reason}} = Image.average(image) + assert is_binary(reason) + end end end diff --git a/test/image_composition_coverage_test.exs b/test/image_composition_coverage_test.exs index 879f9a04..8249db83 100644 --- a/test/image_composition_coverage_test.exs +++ b/test/image_composition_coverage_test.exs @@ -152,7 +152,20 @@ defmodule Image.CompositionCoverageTest do describe "Image.chroma_color/1, chroma_mask/2 and chroma_key/2" do test "chroma_color/1 samples the top left of the image" do - assert Image.chroma_color(scene()) == [0, 128, 0] + assert Image.chroma_color(scene()) == {:ok, [0, 128, 0]} + end + + test "chroma_color!/1 returns the color unwrapped" do + assert Image.chroma_color!(scene()) == [0, 128, 0] + end + + test "chroma_color!/1 raises when chroma_color/1 errors" do + # JPEG with a readable header, but truncated so opening it + # succeeds, but reading pixels fails + whole = File.read!(image_path("Hong-Kong-2015-07-1998.jpg")) + image = Image.from_binary!(binary_part(whole, 0, div(byte_size(whole), 2))) + + assert_raise Image.Error, fn -> Image.chroma_color!(image) end end test "chroma_mask/2 with the default :auto color masks the background" do