Skip to content

Commit fd2b831

Browse files
authored
Merge pull request #212 from hlindset/fix/exact-affine-background-fill
Reproduce partially transparent affine backgrounds exactly
2 parents 648468f + 3f5fe2b commit fd2b831

3 files changed

Lines changed: 137 additions & 71 deletions

File tree

lib/image.ex

Lines changed: 84 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -6588,36 +6588,12 @@ defmodule Image do
65886588
65896589
See also `Image.Pixel.to_pixel/2`.
65906590
6591-
## Partially-transparent backgrounds
6592-
6593-
A fully opaque or fully transparent `:background` is reproduced
6594-
exactly. A *partially* transparent `:background` (an alpha band
6595-
value somewhere between fully opaque and fully transparent) is
6596-
not. To interpolate correctly `libvips` works in premultiplied-alpha
6597-
space, and it injects the fill color directly into that space and
6598-
then unpremultiplies the whole result on output. The fill therefore
6599-
sees only that one unpremultiply step, which scales its color bands
6600-
by `max / alpha`. For example, on an 8-bit image (where `max` is
6601-
`255`) a declared `[10, 20, 30, 40]` is filled as `[63, 127, 191,
6602-
40]`: the alpha is preserved, but each color band is multiplied by
6603-
`255 / 40`.
6604-
6605-
If you need an exact partially-transparent fill, don't pass it as
6606-
`:background`. Instead rotate over a transparent background and
6607-
composite the result onto a canvas of the desired color:
6608-
6609-
iex> image = Image.open!("./test/support/images/dice_transparent.png")
6610-
iex> {:ok, rotated} = Image.rotate(image, 45, background: [0, 0, 0, 0])
6611-
iex> canvas = Image.new!(rotated, color: [10, 20, 30, 40])
6612-
iex> {:ok, _filled} = Image.compose(canvas, rotated)
6613-
6614-
The color is applied by the composite rather than passed through the
6615-
rotate, so it is reproduced exactly. Note that this backs the
6616-
*entire* image: it fills every transparent pixel, not just the canvas
6617-
exposed by the rotate. When the source content is fully opaque the
6618-
two are the same region; for a source that carries its own
6619-
transparency (such as the dice image above) the composite also fills
6620-
those areas, which `:background` would leave untouched.
6591+
## Transparent backgrounds
6592+
6593+
A partially transparent `:background` is reproduced exactly. The one
6594+
exception is a *fully* transparent fill (`alpha: 0`) with non-zero
6595+
color bands: color cannot be recovered from under zero alpha, so it
6596+
is rendered as transparent black rather than the declared color.
66216597
66226598
## Discrete rotation
66236599
@@ -6640,13 +6616,20 @@ defmodule Image do
66406616
66416617
* `{:error, reason}`
66426618
6643-
### Example
6619+
### Examples
66446620
66456621
iex> image = Image.new!(20, 10, color: :blue)
66466622
iex> {:ok, rotated} = Image.rotate(image, 90)
66476623
iex> Image.shape(rotated)
66486624
{10, 20, 3}
66496625
6626+
A partially transparent `:background` fills the exposed canvas exactly:
6627+
6628+
iex> image = Image.new!(20, 20, color: [255, 0, 0, 255])
6629+
iex> {:ok, rotated} = Image.rotate(image, 45, background: [10, 20, 30, 40])
6630+
iex> Image.get_pixel!(rotated, 1, 1)
6631+
[10, 20, 30, 40]
6632+
66506633
"""
66516634
@doc subject: "Operation"
66526635

@@ -6661,14 +6644,26 @@ defmodule Image do
66616644
with {:ok, options} <- Options.Rotate.validate_options(image, options) do
66626645
rot_angle = rot_angle(angle, options)
66636646

6664-
if rot_angle do
6665-
Operation.rot(image, rot_angle)
6666-
else
6667-
Operation.rotate(image, angle, options)
6647+
cond do
6648+
rot_angle ->
6649+
Operation.rot(image, rot_angle)
6650+
6651+
has_alpha?(image) ->
6652+
# `vips_rotate` doesn't expose the `:premultiplied` option, so alpha images
6653+
# run the equivalent affine through the premultiplied pipeline instead
6654+
premultiplied_affine(image, rotation_matrix(angle), options)
6655+
6656+
true ->
6657+
Operation.rotate(image, angle, options)
66686658
end
66696659
end
66706660
end
66716661

6662+
defp rotation_matrix(angle) do
6663+
radians = angle * :math.pi() / 180.0
6664+
[:math.cos(radians), -:math.sin(radians), :math.sin(radians), :math.cos(radians)]
6665+
end
6666+
66726667
defp rot_angle(angle, options) do
66736668
if Options.Rotate.no_displacement?(options) do
66746669
to_rot_angle(angle)
@@ -11911,41 +11906,12 @@ defmodule Image do
1191111906
Image.height(image)]` keeps the output the same size as the
1191211907
input, anchored at the origin.
1191311908
11914-
## Partially-transparent backgrounds
11915-
11916-
A fully opaque or fully transparent `:background` is reproduced
11917-
exactly. A *partially* transparent `:background` (an alpha band
11918-
value somehwere between fully opaque and fully transparent) is
11919-
not. To interpolate correctly `libvips` works in premultiplied-alpha
11920-
space, and it injects the fill color directly into that space and
11921-
then unpremultiplies the whole result on output. The fill therefore
11922-
sees only that one unpremultiply step, which scales its color bands
11923-
by `max / alpha`. For example, on an 8-bit image (where `max` is
11924-
`255`) a declared `[10, 20, 30, 40]` is filled as `[63, 127, 191,
11925-
40]`: the alpha is preserved, but each color band is multiplied by
11926-
`255 / 40`.
11927-
11928-
If you need an exact partially-transparent fill, don't pass it as
11929-
`:background`. Instead transform over a transparent background and
11930-
composite the result onto a canvas of the desired color. Here
11931-
`jose.png` is opaque (an alpha band is added so the exposed canvas
11932-
can be transparent), so the only transparency is the canvas the
11933-
transform exposes:
11934-
11935-
iex> image = Image.add_alpha!(Image.open!("./test/support/images/jose.png"), 255)
11936-
iex> angle = :math.pi() / 4
11937-
iex> matrix = [:math.cos(angle), -:math.sin(angle), :math.sin(angle), :math.cos(angle)]
11938-
iex> {:ok, rotated} = Image.affine(image, matrix, background: [0, 0, 0, 0])
11939-
iex> canvas = Image.new!(rotated, color: [10, 20, 30, 40])
11940-
iex> {:ok, _filled} = Image.compose(canvas, rotated)
11941-
11942-
The color is applied by the composite rather than passed through the
11943-
transform, so it is reproduced exactly. Note that the composite backs
11944-
the *entire* image: it fills every transparent pixel, not just the
11945-
canvas exposed by the transform. Because the content here is opaque
11946-
the two coincide; for a source that carries its own transparency the
11947-
composite also fills those areas, which `:background` would leave
11948-
untouched.
11909+
## Transparent backgrounds
11910+
11911+
A partially transparent `:background` is reproduced exactly. The one
11912+
exception is a *fully* transparent fill (`alpha: 0`) with non-zero
11913+
color bands: color cannot be recovered from under zero alpha, so it
11914+
is rendered as transparent black rather than the declared color.
1194911915
1195011916
### Notes
1195111917
@@ -11978,6 +11944,15 @@ defmodule Image do
1197811944
iex> image = Image.open!("./test/support/images/jose.png")
1197911945
iex> {:ok, _stretched} = Image.affine(image, [2, 0, 0, 1])
1198011946
11947+
A partially transparent `:background` fills the exposed canvas exactly:
11948+
11949+
iex> image = Image.new!(20, 20, color: [255, 0, 0, 255])
11950+
iex> angle = :math.pi() / 4
11951+
iex> matrix = [:math.cos(angle), -:math.sin(angle), :math.sin(angle), :math.cos(angle)]
11952+
iex> {:ok, transformed} = Image.affine(image, matrix, background: [10, 20, 30, 40])
11953+
iex> Image.get_pixel!(transformed, 1, 1)
11954+
[10, 20, 30, 40]
11955+
1198111956
"""
1198211957
@doc subject: "Distortion"
1198311958

@@ -11993,7 +11968,7 @@ defmodule Image do
1199311968
def affine(%Vimage{} = image, [a, b, c, d], options)
1199411969
when is_number(a) and is_number(b) and is_number(c) and is_number(d) do
1199511970
with {:ok, options} <- Options.Affine.validate_options(image, options) do
11996-
Operation.affine(image, [a, b, c, d], options)
11971+
premultiplied_affine(image, [a, b, c, d], options)
1199711972
end
1199811973
end
1199911974

@@ -12007,6 +11982,44 @@ defmodule Image do
1200711982
}}
1200811983
end
1200911984

11985+
# `libvips` resamples alpha images in premultiplied-alpha space and
11986+
# injects the `:background` fill raw into that space, so the fill's
11987+
# color bands come back scaled by `max_alpha / alpha`. Premultiplying
11988+
# the image and the background ourselves (with `premultiplied: true`)
11989+
# makes the fill round-trip exactly.
11990+
defp premultiplied_affine(%Vimage{} = image, matrix, options) do
11991+
if has_alpha?(image) do
11992+
band_format = Vix.Vips.Image.format(image)
11993+
11994+
options =
11995+
options
11996+
|> Keyword.replace_lazy(:background, &premultiply_pixel(image, &1))
11997+
|> Keyword.put(:premultiplied, true)
11998+
11999+
with {:ok, premultiplied} <- Operation.premultiply(image),
12000+
{:ok, transformed} <- Operation.affine(premultiplied, matrix, options),
12001+
{:ok, unpremultiplied} <- Operation.unpremultiply(transformed) do
12002+
Operation.cast(unpremultiplied, band_format)
12003+
end
12004+
else
12005+
Operation.affine(image, matrix, options)
12006+
end
12007+
end
12008+
12009+
defp premultiply_pixel(image, pixel) do
12010+
# The background premultiplication must use the same alpha scale as the
12011+
# operations above. `Image.Pixel` encodes that scale as the opaque alpha
12012+
# value, so read it from there.
12013+
max_alpha =
12014+
case Pixel.to_pixel(image, :black, alpha: :opaque) do
12015+
{:ok, pixel} -> List.last(pixel)
12016+
{:error, _reason} -> 255
12017+
end
12018+
12019+
{color_bands, [alpha]} = Enum.split(pixel, -1)
12020+
Enum.map(color_bands, &(&1 * alpha / max_alpha)) ++ [alpha]
12021+
end
12022+
1201012023
@doc """
1201112024
Applies an affine transformation to an image or raises
1201212025
an exception.

test/affine_test.exs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,46 @@ defmodule Image.Affine.Test do
126126
end
127127
end
128128

129+
describe "Image.affine/3 with a partially transparent :background" do
130+
# 45-degree rotation: the output corners are exposed canvas, the
131+
# center is source content.
132+
@angle :math.pi() / 4
133+
@rotation_45 [:math.cos(@angle), -:math.sin(@angle), :math.sin(@angle), :math.cos(@angle)]
134+
135+
test "the fill is reproduced exactly" do
136+
image = Image.new!(20, 20, color: [255, 0, 0, 255])
137+
138+
{:ok, result} = Image.affine(image, @rotation_45, background: [10, 20, 30, 40])
139+
140+
assert Image.get_pixel!(result, 1, 1) == [10, 20, 30, 40]
141+
end
142+
143+
test "content pixels, shape and band format are unchanged" do
144+
image = Image.new!(20, 20, color: [255, 0, 0, 255])
145+
146+
{:ok, straight} = Image.affine(image, @rotation_45, background: [10, 20, 30, 255])
147+
{:ok, result} = Image.affine(image, @rotation_45, background: [10, 20, 30, 40])
148+
149+
assert Image.shape(result) == Image.shape(straight)
150+
assert Image.band_format(result) == Image.band_format(image)
151+
152+
{width, height, 4} = Image.shape(result)
153+
assert Image.get_pixel!(result, div(width, 2), div(height, 2)) == [255, 0, 0, 255]
154+
end
155+
156+
test "the fill is exact for a :rgb16 image (alpha scale 65_535)" do
157+
image =
158+
Image.new!(20, 20, color: [255, 0, 0, 255])
159+
|> Image.to_colorspace!(:rgb16)
160+
161+
{:ok, result} =
162+
Image.affine(image, @rotation_45, background: [4_000, 8_000, 12_000, 16_000])
163+
164+
assert Image.get_pixel!(result, 1, 1) == [4_000, 8_000, 12_000, 16_000]
165+
assert Image.band_format(result) == {:u, 16}
166+
end
167+
end
168+
129169
describe "Image.translate/4" do
130170
test "shifts content right and down without resizing the canvas" do
131171
image = white_dot(20, 20, 2, 3)

test/rotate_test.exs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,5 +92,18 @@ defmodule Image.Rotate.Test do
9292
# No crash even though :interpolate/:background are resolved but unused.
9393
assert {:ok, %Vimage{}} = Image.rotate(image, 90, interpolate: :nearest, background: :red)
9494
end
95+
96+
test "a partially transparent :background fill is reproduced exactly" do
97+
image = Image.new!(20, 20, color: [255, 0, 0, 255])
98+
99+
{:ok, result} = Image.rotate(image, 45, background: [10, 20, 30, 40])
100+
101+
# Corner is exposed canvas, center is source content.
102+
assert Image.get_pixel!(result, 1, 1) == [10, 20, 30, 40]
103+
assert Image.band_format(result) == Image.band_format(image)
104+
105+
{width, height, 4} = Image.shape(result)
106+
assert Image.get_pixel!(result, div(width, 2), div(height, 2)) == [255, 0, 0, 255]
107+
end
95108
end
96109
end

0 commit comments

Comments
 (0)