defmodule Image.Histogram do
@moduledoc """
A histogram is a graphical representation of the tonal
distribution in a digital image. It plots the number
of pixels for each tonal value. By looking at the histogram
for a specific image a viewer will be able to judge the
entire tonal distribution at a glance.
The horizontal axis of the graph represents the tonal variations,
while the vertical axis represents the total number of pixels in
that particular tone.
The left side of the horizontal axis represents the dark areas,
the middle represents mid-tone values and the right hand side
represents light areas. The vertical axis represents the size
of the area (total number of pixels) that is captured in each
one of these zones.
Thus, the histogram for a very dark image will have most of
its data points on the left side and center of the graph.
Conversely, the histogram for a very bright image with few
dark areas and/or shadows will have most of its data points
on the right side and center of the graph.
The histograms generated in this module have red, green,
blue and luminance layers and can be returned as either an
[svg](https://en.wikipedia.org/wiki/SVG) string or as an
`t:Vimage.t/0`.
The current implementation does not applying any scale
compression or expansion and therefore where the image
has very wide tonality differences the differences may
be difficult to distinguish if `:height` is small.
"""
alias Vix.Vips.Image, as: Vimage
alias Vix.Vips.Operation
alias Image.Options
# Both the number of histogram buckets
# and the calculated max value of each
# bucket.
@max_value 255
# In lch colorspace, the l band is
# in the range 1..100 so we need to
# extract the first 100 values and
# expand the scale to cover 0..@max_value
@number_of_luminance_values 100
@doc """
Returns an [svg](https://en.wikipedia.org/wiki/SVG) string
representing a red, green, blue and luminance histogram
for an image.
### Arguments
* `image` is any `t:Vix.Vips.Image.t/0`.
* `options` is a keyword list of options.
### Options
* `:width` is the integer width of the SVG image.
The default is `:auto` which means the image is
sized to its parent container.
* `:height` is the integer height of the SVG image.
The default is `:auto` which means the image is
sized to its parent container.
### Returns
* `{:ok, svg_string}` or
* `{:error, reason}`.
### Histogram image sizing
SVG will, by default, resize to fit its parent container
in an HTMl page. `Image.Histogram.as_svg/2` will
generate SVG with a default width and height of `auto`
the reflects this intent.
In some cases, such as generating images with
`Image.Histogram.as_image/2`, `:width` and `:height` options
should be provided (in pixels) to ensure the image is generated
at the desired size.
### Attribution
Thanks to [Alex Plescan](https://alexplescan.com/posts/2023/07/08/easy-svg-sparklines/)
for the inspiration for the SVG design.
"""
@doc since: "0.36.0"
@doc subject: "Histogram"
@spec as_svg(Vimage.t(), Options.Histogram.histogram_options()) ::
{:ok, String.t()} | {:error, Image.error_message()}
def as_svg(%Vimage{} = image, options \\ []) do
with {:ok, options} <- Options.Histogram.validate_options(options),
{:ok, srgb} <- Image.to_colorspace(image, :srgb),
{:ok, lch} <- Image.to_colorspace(image, :lch),
{:ok, srgb_histogram} <- Operation.hist_find(srgb),
{:ok, lch_histogram} <- Operation.hist_find(lch),
{:ok, {srgb_max, _}} <- Operation.max(srgb_histogram),
{:ok, {lch_max, _}} <- Operation.max(lch_histogram) do
svg =
"""
<svg height="#{options.height}" width="#{options.width}" viewBox="0 0 #{@max_value} #{@max_value}" preserveAspectRatio="none">
#{generate_histogram(srgb_histogram[2], srgb_max, :blue)}
#{generate_histogram(srgb_histogram[1], srgb_max, :green)}
#{generate_histogram(srgb_histogram[0], srgb_max, :red)}
#{generate_histogram(lch_histogram[0], lch_max, :white)}
</svg>
"""
{:ok, svg}
end
end
@doc """
Returns an [svg](https://en.wikipedia.org/wiki/SVG) string
representing a red, green, blue and luminance histogram for
an image or raises an exception.
### Arguments
* `image` is any `t:Vix.Vips.Image.t/0`.
* `options` is a keyword list of options.
### Options
* `:width` is the integer width of the SVG image.
The default is `:auto` which means the image is
sized to its parent container.
* `:height` is the integer height of the SVG image.
The default is `:auto` which means the image is
sized to its parent container.
### Returns
* `svg_string` or
* raises an exception.
### Histogram image sizing
SVG will, by default, resize to fit its parent container
in an HTMl page. `Image.Histogram.as_svg/2` will
generate SVG with a default width and height of `auto`
the reflects this intent.
In some cases, such as generating images with
`Image.Histogram.as_image/2`, `:width` and `:height` options
should be provided (in pixels) to ensure the image is generated
at the desired size.
### Attribution
Thanks to [Alex Plescan](https://alexplescan.com/posts/2023/07/08/easy-svg-sparklines/)
for the inspiration for the SVG design.
"""
@doc since: "0.36.0"
@doc subject: "Histogram"
@spec as_svg!(Vimage.t(), Options.Histogram.histogram_options()) ::
String.t() | no_return()
def as_svg!(%Vimage{} = image, options \\ []) do
case as_svg(image, options) do
{:ok, histogram} -> histogram
{:error, reason} -> raise Image.Error, reason
end
end
@doc """
Returns an image representing a red, green, blue and
luminance histogram for an image.
### Arguments
* `image` is any `t:Vix.Vips.Image.t/0`.
* `options` is a keyword list of options.
### Options
* `:width` is the integer width of the SVG image.
The default is `:auto` which means the image is
sized to its parent container.
* `:height` is the integer height of the SVG image.
The default is `:auto` which means the image is
sized to its parent container.
### Returns
* `{:ok, histogram_image}` or
* `{:error, reason}`.
### Histogram image sizing
With `Image.Histogram.as_image/2` it is recommended that
the `:width` and/or `:height` options be provided (in pixels)
to ensure the image is generated at the desired size.
The default of `:auto` will generate an image the size
of the underlying SVG viewbox which is `#{@max_value}` pixels
wide and `#{@max_value}` pixels high.
"""
@doc since: "0.36.0"
@doc subject: "Histogram"
@spec as_image(Vimage.t(), Options.Histogram.histogram_options()) ::
{:ok, Vimage.t()} | {:error, Image.error_message()}
def as_image(%Vimage{} = image, options \\ []) do
case as_svg(image, options) do
{:ok, svg} -> Image.from_svg(svg)
{:error, reason} -> {:error, reason}
end
end
@doc """
Returns an image representing a red, green, blue and
luminance histogram for an image or raises an exception.
### Arguments
* `image` is any `t:Vix.Vips.Image.t/0`.
* `options` is a keyword list of options.
### Options
* `:width` is the integer width of the SVG image.
The default is `:auto` which means the image is
sized to its parent container.
* `:height` is the integer height of the SVG image.
The default is `:auto` which means the image is
sized to its parent container.
### Returns
* `histogram_image` or
* raises an exception.
### Histogram image sizing
With `Image.Histogram.as_image/2` it is recommended that
the `:width` and/or `:height` options be provided (in pixels)
to ensure the image is generated at the desired size.
The default of `:auto` will generate an image the size
of the underlying SVG viewbox which is `#{@max_value}` pixels
wide and `#{@max_value}` pixels high.
"""
@doc since: "0.36.0"
@doc subject: "Histogram"
@spec as_image!(Vimage.t(), Options.Histogram.histogram_options()) ::
Vimage.t() | no_return()
def as_image!(%Vimage{} = image, options \\ []) do
case as_image(image, options) do
{:ok, image} -> image
{:error, reason} -> {:error, reason}
end
end
defp generate_histogram(image, max, stroke_color, fill_color \\ nil) do
{:ok, tensor} = Vix.Vips.Image.write_to_tensor(image)
values = decode_binary(tensor, max)
fill_color = fill_color || stroke_color
"""
<path
d="#{generate_fill(values)}"
stroke="transparent"
fill="#{fill_color}"
fill-opacity="0.2"
/>
<path
d="#{generate_path(values)}"
stroke-width="2"
stroke="#{stroke_color}"
fill="transparent"
vector-effect="non-scaling-stroke"
/>
"""
end
defp decode_binary(tensor, image_max) do
values =
for <<value::native-integer-32 <- tensor.data>>,
do: @max_value - fit(value, image_max)
if rgb_histogram?(values) do
values
else
resample_luminance(values)
end
end
# This is a straight linear fit into
# a range of 0..255. When there is a
# spike in one tonal area we end up with
# scale compression for the wider range.
# Lightroom compresses the ranges in this
# situation - something to look at for
# the future.
defp fit(value, image_max) do
value * @max_value / image_max
end
# RGB histograms have 256 values,
# lCH histograms have 360 values.
defp rgb_histogram?(values) do
length(values) == @max_value + 1
end
defp generate_path(values) do
Enum.with_index(values, fn
value, 0 ->
[?M, ?\s, ?0, ?\s, to_string(value), ?\s]
value, index ->
[?L, ?\s, to_string(index), ?\s, to_string(value), ?\s]
end)
|> :erlang.iolist_to_binary()
end
@close_the_area "L #{@max_value} #{@max_value} L 0 #{@max_value} Z"
defp generate_fill(values) do
Enum.with_index(values, fn
value, 0 ->
[?M, ?\s, ?0, ?\s, to_string(value), ?\s]
value, @max_value = index ->
[?L, ?\s, to_string(index), ?\s, to_string(value), ?\s, @close_the_area]
value, index ->
[?L, ?\s, to_string(index), ?\s, to_string(value), ?\s]
end)
|> :erlang.iolist_to_binary()
end
# Here we are expanding the list of 100
# luminanace values into a list of 256
# luminance values. Its a big ad-hoc but
# reasonably efficient.
defp resample_luminance(values) do
values =
values
|> Enum.take(@number_of_luminance_values)
|> Enum.with_index(fn
v, i when rem(i, 3) == 0 -> [v, v, v]
v, i when rem(i, 5) == 0 -> [v, v, v]
v, i when rem(i, 7) == 0 -> [v, v, v]
v, i when rem(i, 9) == 0 -> [v, v, v]
v, _i -> [v, v]
end)
|> List.flatten()
[hd(values) | values]
end
end