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
282 changes: 152 additions & 130 deletions lib/image.ex

Large diffs are not rendered by default.

91 changes: 74 additions & 17 deletions lib/image/background_color.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,19 @@ defmodule Image.BackgroundColor do
`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.
Either form may also be given as `{spec, alpha: transparency}` to attach an
explicit alpha (an integer `0..255`, a float `0.0..1.0`, or `:opaque` /
`:transparent`). The alpha is applied only when `image` has an alpha band,
otherwise it is dropped, since there is no band to carry it.

In all 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
@typedoc "A background color specification: the image's average color, or any color, optionally with an explicit alpha."
@type spec :: Pixel.t() | :average | {Pixel.t() | :average, [alpha: Pixel.transparency()]}

@doc """
Resolves a background color `spec` into a pixel matching `image`'s
Expand All @@ -25,8 +30,9 @@ defmodule Image.BackgroundColor do

* `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`.
* `spec` is `:average` (the image's average color), any color
accepted by `Image.Pixel.to_pixel/2`, or either of those wrapped
as `{spec, alpha: transparency}` to attach an explicit alpha.

### Returns

Expand All @@ -50,25 +56,25 @@ defmodule Image.BackgroundColor do
@spec resolve(Vimage.t(), spec()) :: {:ok, [number()]} | {:error, Image.Error.t()}
def resolve(%Vimage{} = image, :average) 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) ->
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
put_alpha_band(image, color, :opaque)

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

# The wrapped `{spec, alpha: alpha}` form: resolve the color part like any
# other spec, then set the alpha band directly.
def resolve(%Vimage{} = image, {spec, opts}) when is_list(opts) do
with {:ok, alpha} <- fetch_alpha(spec, opts),
{:ok, pixel} <- resolve(image, spec) do
apply_alpha(image, pixel, alpha)
end
end

def resolve(%Vimage{} = image, color) do
case Pixel.to_pixel(image, color) do
{:ok, pixel} ->
Expand All @@ -79,6 +85,57 @@ defmodule Image.BackgroundColor do
end
end

# `:alpha` is the only supported key in the wrapped form. A missing or
# misspelled key is reported as an error rather than raised.
defp fetch_alpha(_spec, [alpha: alpha]), do: {:ok, alpha}

defp fetch_alpha(spec, opts) do
{:error,
%Image.Error{
reason: :invalid_background,
value: {spec, opts},
message:
"Invalid background color #{inspect({spec, opts})}: " <>
"expected {color, alpha: transparency}"
}}
end

# Set the alpha band of an already-resolved pixel from an alpha spec. The
# alpha is validated up front so an invalid value errors even on an image
# without an alpha band.
defp apply_alpha(image, pixel, alpha) do
with {:ok, _byte} <- validate_alpha(alpha) do
put_alpha_band(image, pixel, alpha)
end
end

defp validate_alpha(alpha) do
case Pixel.transparency(alpha) do
{:ok, byte} -> {:ok, byte}
{:error, reason} -> {:error, error("Invalid alpha #{inspect(alpha)}", reason)}
end
end

# Make the pixel's alpha band the given alpha, scaled to the image's
# interpretation via `to_pixel/3`. The pixel may arrive with or without an
# alpha band (taking the color bands normalizes both). On an image without
# an alpha band the alpha is unrepresentable and the pixel is returned
# unchanged.
defp put_alpha_band(image, pixel, alpha) do
if Image.has_alpha?(image) do
case Pixel.to_pixel(image, :black, alpha: alpha) do
{:ok, scaled} ->
color_bands = Image.bands(image) - 1
{:ok, Enum.take(pixel, color_bands) ++ [List.last(scaled)]}

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

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

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

@typedoc """
The interpolators that may be selected with the `:interpolate`
Expand All @@ -32,6 +32,20 @@ defmodule Image.Options.Affine do
| :nohalo
| :vsqbs

@typedoc """
How the interpolator synthesizes the one-pixel fringe just beyond
the content edge when resampling boundary pixels.

* `:background` (the default) blends the fringe toward the
`:background` color. This is correct whenever the transform
exposes canvas.
* `:copy` clamps to the nearest content pixel. Use it when the
content fills the whole canvas, where the default would leave a
faint border along the outermost row and column.

"""
@type extend_mode :: :background | :copy

@typedoc """
The options applicable to an affine transformation.

Expand All @@ -42,9 +56,9 @@ defmodule Image.Options.Affine do
| {:odx, number()}
| {:ody, number()}
| {:interpolate, interpolate()}
| {:background, Pixel.t() | :average}
| {:background, BackgroundColor.spec() | nil}
| {:extend_mode, extend_mode()}
| {:output_area, [integer()]}
| {:extend_mode, Image.ExtendMode.t()}
]

# The libvips nickname for each interpolator is identical to the
Expand All @@ -53,6 +67,12 @@ defmodule Image.Options.Affine do

@displacement_options [:idx, :idy, :odx, :ody]

# The other libvips extend modes are deliberately not exposed: :repeat
# samples the opposite edge of the image into the fringe, :mirror is
# indistinguishable from :copy at one pixel deep, and :black/:white are
# colors, which belong to :background.
@extend_modes [background: :VIPS_EXTEND_BACKGROUND, copy: :VIPS_EXTEND_COPY]

@doc """
Validate the options for `Image.affine/3`.

Expand All @@ -65,6 +85,8 @@ defmodule Image.Options.Affine do
@spec validate_options(Vimage.t(), Keyword.t()) ::
{:ok, Keyword.t()} | {:error, Image.error()}
def validate_options(image, options) do
# A nil `:background` means "unset", i.e. it falls back to the default.
options = Enum.reject(options, &match?({:background, nil}, &1))
options = Keyword.merge(default_options(), options)

case Enum.reduce_while(options, options, &validate_option(&1, image, &2)) do
Expand All @@ -87,31 +109,24 @@ defmodule Image.Options.Affine do
end
end

# The public option is `:extend_mode`, renamed internally to `:extend` for `libvips`
defp validate_option({:extend_mode, extend}, _image, options)
when is_atom(extend) or is_binary(extend) do
case Image.ExtendMode.validate_extend(extend) do
{:ok, extend} ->
options =
options
|> Keyword.delete(:extend_mode)
|> Keyword.put(:extend, extend)

{:cont, options}

{:error, reason} ->
{:halt,
{:error, %Image.Error{reason: :invalid_extend_mode, value: extend, message: reason}}}
end
end

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

# The public option is `:extend_mode`, renamed to `:extend` for `libvips`.
defp validate_option({:extend_mode, extend_mode}, _image, options)
when extend_mode in [:background, :copy] do
options =
options
|> Keyword.delete(:extend_mode)
|> Keyword.put(:extend, Keyword.fetch!(@extend_modes, extend_mode))

{:cont, options}
end

# The public option is `:output_area`, libvips names it `oarea`.
defp validate_option({:output_area, [left, top, width, height] = area}, _image, options)
when is_integer(left) and is_integer(top) and is_integer(width) and is_integer(height) do
Expand Down Expand Up @@ -140,11 +155,11 @@ defmodule Image.Options.Affine do
}
end

# `:extend_mode` defaults to `:background` rather than `:black`: since
# extend only governs the antialiased edge fringe (not the canvas fill),
# `:background` blends the fringe toward the fill color, whereas `:black`
# would leave a dark fringe on a non-black background.
# No default `:background` is injected. When omitted, libvips uses its
# native all-zeros fill. `:interpolate` defaults to `:bilinear`, also
# libvips' own default. `:extend_mode` defaults to `:background` so the
# edge fringe blends into the canvas fill.
defp default_options do
[extend_mode: :background, interpolate: :bilinear]
[interpolate: :bilinear, extend_mode: :background]
end
end
Loading