Skip to content
Merged
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
98 changes: 71 additions & 27 deletions lib/image.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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]}

"""

Expand All @@ -2231,14 +2233,48 @@ 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
average(cropped)
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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/image/background_color.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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} ->
Expand Down
22 changes: 17 additions & 5 deletions test/average_test.exs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
15 changes: 14 additions & 1 deletion test/image_composition_coverage_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading