diff --git a/guides/assets/lqip-orig-hong-kong.jpg b/guides/assets/lqip-orig-hong-kong.jpg new file mode 100644 index 0000000..b6268d0 Binary files /dev/null and b/guides/assets/lqip-orig-hong-kong.jpg differ diff --git a/guides/assets/lqip-orig-kamchatka.jpg b/guides/assets/lqip-orig-kamchatka.jpg new file mode 100644 index 0000000..5ec03d5 Binary files /dev/null and b/guides/assets/lqip-orig-kamchatka.jpg differ diff --git a/guides/assets/lqip-orig-lamborghini.jpg b/guides/assets/lqip-orig-lamborghini.jpg new file mode 100644 index 0000000..8e881a6 Binary files /dev/null and b/guides/assets/lqip-orig-lamborghini.jpg differ diff --git a/guides/assets/lqip-orig-mongolia.jpg b/guides/assets/lqip-orig-mongolia.jpg new file mode 100644 index 0000000..d6ef10f Binary files /dev/null and b/guides/assets/lqip-orig-mongolia.jpg differ diff --git a/guides/assets/lqip-orig-singapore.jpg b/guides/assets/lqip-orig-singapore.jpg new file mode 100644 index 0000000..bad3994 Binary files /dev/null and b/guides/assets/lqip-orig-singapore.jpg differ diff --git a/guides/assets/lqip-orig-sydney-bw.jpg b/guides/assets/lqip-orig-sydney-bw.jpg new file mode 100644 index 0000000..50808f2 Binary files /dev/null and b/guides/assets/lqip-orig-sydney-bw.jpg differ diff --git a/guides/lqip_css.md b/guides/lqip_css.md new file mode 100644 index 0000000..7f3b66b --- /dev/null +++ b/guides/lqip_css.md @@ -0,0 +1,220 @@ +# LQIP CSS placeholders + +`Image.Lqip.Css` encodes an LQIP image as a CSS value: a single 32-bit number, +packed into an RGBA hex code like `#a1b2c3d4`, that a static piece of CSS +expands into a blurred gradient placeholder. + +The technique was created by Freek Zijlmans (documented in [this article](https://dev.to/frzi/image-placeholders-in-pure-css-or-defying-gods-with-math-and-color-3a5d)), +and the reference implementation lives at . +Unlike `Image.Blurhash`, these placeholders are decoded entirely by the +browser's CSS engine. + +## How it works + +`Image.Lqip.Css.encode/1` resizes the image to a 3x3 thumbnail and samples three +pixels: + +| Pixel | CSS variable | Role | +| ------------ | ------------ | ---------------------- | +| top-left | `--lqip-c0` | background color | +| center | `--lqip-c1` | first radial gradient | +| bottom-right | `--lqip-c2` | second radial gradient | + +Each color is quantized ([adjusted for chroma](#chroma-aware-packing)), +and bit-packed into a single 32-bit value, which is rendered as an 8-digit +RGBA hex color, `#RRGGBBAA`: + +``` +| RR | GG | BB | AA | +|0 0 0 0 0 0 0 0|0 0 0 0 0 0 0 0|0 0 0 0 0 0 0 0|0 0 0 0 0 0 0 0| +|R R R R G G G G|B B B R R R R G|G G G B B B R R|R G G G G B B B| +|- - - - = = = = - - -|= = = = - - - - = = =|- - - = = = = - - -| +| color 0 | color 1 | color 2 | +| 11-bit | 11-bit | 10-bit | +``` + +## Generating the value + +Compute the value once, and store it alongside the image: + +```elixir +{:ok, image} = Image.open("photo.jpg") +{:ok, hex} = Image.Lqip.Css.encode(image) +#=> {:ok, "#a1b2c3d4"} +``` + +Then set a CSS variable on the element using inline styles: + +```html + +``` + +or as a data attribute, accessed by the stylesheet using the `attr()` function: + +```html + +``` + +Note that the browser support for `attr()` is still [not quite there](https://caniuse.com/css3-attr), +so for now, the CSS variable is the safest choice. + +## The CSS + +The stylesheet is identical for every image and only needs to be included once. +It unpacks the LQIP value into three colors (using +[relative color syntax](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_colors/Relative_colors)) +and paints them as two radial gradients over the background color. + +This CSS is a verbatim copy of the reference implementation's `lqip.css`: + +```css +[data-lqip] { + --lqip-c: attr(data-lqip type(), white); +} + +[style*="--lqip:"] { + --lqip-c: var(--lqip); +} + +[style*="--lqip:"], +[data-lqip] { + /* + * | RR | GG | BB | AA | + * |0 0 0 0 0 0 0 0|0 0 0 0 0 0 0 0|0 0 0 0 0 0 0 0|0 0 0 0 0 0 0 0| + * |R R R R G G G G|B B B R R R R G|G G G B B B R R|R G G G G B B B| + * |- - - - = = = = - - -|= = = = - - - - = = =|- - - = = = = - - -| + * | color 0 | color 1 | color 2 | + * | 11-bit | 11-bit | 10-bit | + */ + --lqip-c0: color( + from var(--lqip-c) srgb calc(round(down, r * 255 / pow(2, 4)) / 15) + calc(mod(round(down, r * 255), pow(2, 4)) / 15) + calc(round(down, g * 255 / pow(2, 5)) / 7) / 1 + ); + + --lqip-c1: color( + from var(--lqip-c) srgb calc(mod(round(down, g * 255 / 2), pow(2, 4)) / 15) + calc( + ( + (mod(round(down, g * 255), 2) * pow(2, 3)) + + (round(down, b * 255 / pow(2, 5))) + ) / + 15 + ) + calc(mod(round(down, b * 255 / pow(2, 2)), pow(2, 3)) / 7) / 1 + ); + + --lqip-c2: color( + from var(--lqip-c) srgb + calc( + ( + ((mod(round(down, b * 255), pow(2, 2)) * 2)) + + round(down, alpha * 255 / pow(2, 7)) + ) / + 7 + ) + calc(mod(round(down, alpha * 255 / pow(2, 3)), pow(2, 4)) / 15) + calc(mod(round(down, alpha * 255), pow(2, 3)) / 7) / 1 + ); + + background: + radial-gradient( + 150% 75% at 80% 100%, + var(--lqip-c2), + rgb(from var(--lqip-c2) r g b / 98%) 10%, + rgb(from var(--lqip-c2) r g b / 92%) 20%, + rgb(from var(--lqip-c2) r g b / 82%) 30%, + rgb(from var(--lqip-c2) r g b / 68%) 40%, + rgb(from var(--lqip-c2) r g b / 32%) 60%, + rgb(from var(--lqip-c2) r g b / 18%) 70%, + rgb(from var(--lqip-c2) r g b / 8%) 80%, + rgb(from var(--lqip-c2) r g b / 2%) 90%, + transparent + ), + radial-gradient( + 100% 75% at 40% 50%, + var(--lqip-c1), + rgb(from var(--lqip-c1) r g b / 98%) 10%, + rgb(from var(--lqip-c1) r g b / 92%) 20%, + rgb(from var(--lqip-c1) r g b / 82%) 30%, + rgb(from var(--lqip-c1) r g b / 68%) 40%, + rgb(from var(--lqip-c1) r g b / 32%) 60%, + rgb(from var(--lqip-c1) r g b / 18%) 70%, + rgb(from var(--lqip-c1) r g b / 8%) 80%, + rgb(from var(--lqip-c1) r g b / 2%) 90%, + transparent + ), + var(--lqip-c0); +} +``` + +## Chroma-aware packing + +Because each channel is packed into only 3 or 4 bits, independent per-channel +rounding can give near-grey colors a visible tint. To reduce that, the encoder +chooses each color's packed value to be the one closest to the source in the +CIELAB space, measured with CIEDE2000 (via `Color.Distance.delta_e_2000/3`). + +This keeps near-greys closer to neutral while still preserving chroma for +saturated colors. + +## Examples + +For each image, the original (left) and the LQIP placeholder it produces +(right). + +
+
+ Kamchatka +
Kamchatka
+
+
+
+
Placeholder #bcceca32
+
+ +
+ Mongolia +
Mongolia
+
+
+
+
Placeholder #9bd35643
+
+ +
+ Lamborghini +
Lamborghini
+
+
+
+
Placeholder #fff0cb6e
+
+ +
+ Singapore +
Singapore
+
+
+
+
Placeholder #3688881a
+
+ +
+ Hong Kong +
Hong Kong
+
+
+
+
Placeholder #578acc9a
+
+ +
+ Sydney Opera House (B&W) +
Sydney Opera House (B&W)
+
+
+
+
Placeholder #ddccce4c
+
+
diff --git a/lib/image/lqip/css.ex b/lib/image/lqip/css.ex new file mode 100644 index 0000000..d3ccd9b --- /dev/null +++ b/lib/image/lqip/css.ex @@ -0,0 +1,142 @@ +defmodule Image.Lqip.Css do + @moduledoc """ + Encodes an image as an [LQIP CSS](https://dev.to/frzi/image-placeholders-in-pure-css-or-defying-gods-with-math-and-color-3a5d) + (Low Quality Image Placeholder) value. + + LQIP CSS represents a placeholder as a single 32-bit value packed into an RGBA + hex code such as `#a1b2c3d4`. A static CSS rule then unpacks that value into + three colors and paints two radial gradients over a background color, producing + a blurred gradient placeholder using only the browser's CSS engine. + + This module is the encoder only: it extracts the three colors and returns + the packed hex string. The CSS is static and identical for every image. + + See the [LQIP CSS guide](lqip_css.md) for the copy-and-paste stylesheet. + + ### How it works + + The image is resized to a 3x3 thumbnail and three pixels are sampled: + + * the top-left pixel becomes the background color (`--lqip-c0`) + * the center pixel becomes the first radial gradient (`--lqip-c1`) + * the bottom-right pixel becomes the second radial gradient (`--lqip-c2`) + + Each color is quantized ([adjusted for chroma](#module-chroma-aware-packing)) + and bit-packed: the first two into 11 bits (`RRRR GGGG BBB`) and the third + into 10 bits (`RRR GGGG BBB`). They are then combined into a single 32-bit + value rendered as an 8-digit `#RRGGBBAA` hex string. + + ### Usage + + Compute the value once and store it. Then set a CSS variable using inline + styles or use a data attribute, and include the LQIP CSS stylesheet: + + hex = Image.Lqip.Css.encode!(image) + # => "#a1b2c3d4" + + # In your template: + # + # or + # + + See the [LQIP CSS guide](lqip_css.md) for more usage information. + + ### Chroma-aware packing + + Because each channel is packed into only 3 or 4 bits, independent per-channel + rounding can give near-grey colors a visible tint. To reduce that, the encoder + chooses each color's packed value to be the one closest to the source in the + CIELAB space, measured with CIEDE2000 (via `Color.Distance.delta_e_2000/3`). + + This keeps near-greys closer to neutral while still preserving chroma for + saturated colors. + + ### Example + + iex> image = Image.open!("./test/support/images/Kip_small.jpg") + iex> Image.Lqip.Css.encode(image) + {:ok, "#22333091"} + + """ + + alias Vix.Vips.Image, as: Vimage + + # Every packable value mapped to the CIELAB color it reconstructs to (channel + # level n of m -> n / m), used by the chroma-aware search in `nearest/2`. + # Built once, at compile time. + @table_11 (for r4 <- 0..15, g4 <- 0..15, b3 <- 0..7 do + {:ok, lab} = Color.convert([r4 / 15, g4 / 15, b3 / 7], Color.Lab) + <> = <> + {packed, lab} + end) + + @table_10 (for r3 <- 0..7, g4 <- 0..15, b3 <- 0..7 do + {:ok, lab} = Color.convert([r3 / 7, g4 / 15, b3 / 7], Color.Lab) + <> = <> + {packed, lab} + end) + + @doc """ + Encodes an image as a packed LQIP hex value. + + ### Arguments + + * `image` is any `t:Vix.Vips.Image.t/0`. It is converted to the `:srgb` + colorspace before sampling, and any alpha channel is ignored. + + ### Returns + + * `{:ok, hex}` where `hex` is an 8-digit `#RRGGBBAA` string, or + + * `{:error, reason}` + + ### Example + + iex> image = Image.open!("./test/support/images/Kip_small.jpg") + iex> Image.Lqip.Css.encode(image) + {:ok, "#22333091"} + + """ + @doc subject: "Operation" + + @spec encode(image :: Vimage.t()) :: {:ok, String.t()} | {:error, Image.error()} + + def encode(%Vimage{} = image) do + with {:ok, image} <- Image.to_colorspace(image, :srgb), + {:ok, image} <- Image.thumbnail(image, "3x3", resize: :force), + {:ok, [r0, g0, b0 | _]} <- Image.get_pixel(image, 0, 0), + {:ok, [r1, g1, b1 | _]} <- Image.get_pixel(image, 1, 1), + {:ok, [r2, g2, b2 | _]} <- Image.get_pixel(image, 2, 2) do + c0 = nearest({r0, g0, b0}, @table_11) + c1 = nearest({r1, g1, b1}, @table_11) + c2 = nearest({r2, g2, b2}, @table_10) + + packed = <> + {:ok, "#" <> Base.encode16(packed, case: :lower)} + end + end + + @doc """ + Encodes an image as a packed LQIP hex value, or raises on error. + + See `encode/1`. + """ + @doc subject: "Operation" + + @spec encode!(image :: Vimage.t()) :: String.t() | no_return() + + def encode!(%Vimage{} = image) do + case encode(image) do + {:ok, hex} -> hex + {:error, reason} -> raise Image.Error, reason + end + end + + # Returns the packed value perceptually nearest to `{r, g, b}` in CIELAB using + # the precompiled table + defp nearest({r, g, b}, table) do + {:ok, lab} = Color.convert([r / 255, g / 255, b / 255], Color.Lab) + {packed, _lab} = Enum.min_by(table, fn {_packed, candidate} -> Color.Distance.delta_e_2000(lab, candidate) end) + packed + end +end diff --git a/mix.exs b/mix.exs index 38a130e..544304f 100644 --- a/mix.exs +++ b/mix.exs @@ -173,11 +173,13 @@ defmodule Image.MixProject do source_ref: "v#{@version}", main: "readme", logo: "logo.jpg", + assets: %{"guides/assets" => "assets"}, extra_section: "Guides", extras: [ "README.md", "guides/thumbnailing.md", "guides/performance.md", + "guides/lqip_css.md", "livebook/image_edge_masking.livemd", "livebook/color_clustering.livemd", "LICENSE.md", diff --git a/test/lqip_css_test.exs b/test/lqip_css_test.exs new file mode 100644 index 0000000..7323cfd --- /dev/null +++ b/test/lqip_css_test.exs @@ -0,0 +1,91 @@ +defmodule Image.Lqip.Css.Test do + use ExUnit.Case, async: true + + import Image.TestSupport + + doctest Image.Lqip.Css + + # Builds an image from `bands`-per-pixel tuples in row-major order. + defp image_3x3(pixels, bands) do + binary = for tuple <- pixels, into: <<>>, do: for(v <- Tuple.to_list(tuple), into: <<>>, do: <>) + Vix.Vips.Image.new_from_binary(binary, 3, 3, bands, :VIPS_FORMAT_UCHAR) + end + + @filler {0, 0, 0} + + # Reconstructs color c0 from a packed hex the way the LQIP CSS does, and + # returns its chroma (max channel − min channel). Zero == perfectly neutral. + defp c0_chroma("#" <> hex) do + <> = Base.decode16!(hex, case: :lower) + red = div(r, 16) * 255 / 15 + green = rem(r, 16) * 255 / 15 + blue = div(g, 32) * 255 / 7 + Enum.max([red, green, blue]) - Enum.min([red, green, blue]) + end + + test "packs top-left, center and bottom-right into an 8-digit hex (reference vector)" do + {:ok, image} = + image_3x3( + [ + {255, 0, 0}, @filler, @filler, + @filler, {0, 255, 0}, @filler, + @filler, @filler, {0, 0, 255} + ], + 3 + ) + + assert Image.Lqip.Css.encode(image) == {:ok, "#f001e007"} + end + + test "ignores the alpha channel, matching the reference which samples only RGB" do + {:ok, image} = + image_3x3( + [ + {255, 0, 0, 17}, {0, 0, 0, 255}, {0, 0, 0, 255}, + {0, 0, 0, 255}, {0, 255, 0, 3}, {0, 0, 0, 255}, + {0, 0, 0, 255}, {0, 0, 0, 255}, {0, 0, 255, 200} + ], + 4 + ) + + assert Image.Lqip.Css.encode(image) == {:ok, "#f001e007"} + end + + test "encodes a single-band greyscale image by converting to sRGB" do + {:ok, gray} = image_3x3([{40}, {80}, {120}, {160}, {200}, {240}, {10}, {50}, {90}], 1) + + assert {:ok, "#" <> rest} = Image.Lqip.Css.encode(gray) + assert String.length(rest) == 8 + end + + test "encodes a real image to a well-formed hex value" do + image = image_path("Kip_small.jpg") |> Image.open!() + + assert {:ok, "#" <> rest} = Image.Lqip.Css.encode(image) + assert rest =~ ~r/^[0-9a-f]{8}$/ + end + + test "encode!/1 returns the bare hex string" do + {:ok, image} = + image_3x3( + [ + {255, 0, 0}, @filler, @filler, + @filler, {0, 255, 0}, @filler, + @filler, @filler, {0, 0, 255} + ], + 3 + ) + + assert Image.Lqip.Css.encode!(image) == "#f001e007" + end + + test "packs near-grey colors to a near-neutral value (chroma-aware)" do + {:ok, grey} = image_3x3(List.duplicate({200, 200, 200}, 9), 3) + + {:ok, hex} = Image.Lqip.Css.encode(grey) + + # Naive per-channel rounding would give this grey a chroma of ~22 (a visible + # tint); the perceptually-nearest packing keeps it near-neutral. + assert c0_chroma(hex) <= 8 + end +end