diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..5673d40 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Runic formatting +371100390d916ccc49093b63f69c5a043fd5fd01 diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..f7bc258 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,57 @@ +# Branch `ah/lut` - Color Picker Abstraction + +## What's Been Implemented + +### New Abstractions +- `AbstractColorPicker{C}` - abstract type parameterized by colorspace ([src/color_picker.jl:1](src/color_picker.jl:1)) +- `RuntimeColorPicker` - selects closest color at runtime using a difference metric ([src/color_picker.jl:20](src/color_picker.jl:20)) +- `LookupColorPicker` - precomputes a 256³ lookup table for RGB{N0f8} ([src/color_picker.jl:62](src/color_picker.jl:62)) + +### New Color Utilities +- `FastEuclideanMetric` - faster Euclidean distance for RGB{N0f8} ([src/colordiff.jl:11](src/colordiff.jl:11)) +- `colorspace()` - returns the optimal colorspace for each metric ([src/colordiff.jl:33](src/colordiff.jl:33)) + +### API Changes +- Renamed `colordither` → `colordither!` (now mutates output) ([src/api/color.jl:42](src/api/color.jl:42)) +- Changed kwarg from `metric` → `colorpicker` +- Added `select_colorpicker()` for algorithm-specific defaults ([src/api/color.jl:1](src/api/color.jl:1)) + +### Algorithm Updates +- `ErrorDiffusion` refactored to use `colorpicker` ([src/error_diffusion.jl:95](src/error_diffusion.jl:95)) +- `OrderedDither` refactored to use `colorpicker` ([src/ordered.jl:55](src/ordered.jl:55)) +- `ClosestColor` refactored to use `colorpicker` ([src/closest_color.jl:12](src/closest_color.jl:12)) + +--- + +## Current Test Failures (6 failed, 15 errored) + +| Issue | Cause | +|-------|-------| +| `no method matching -(::Lab{Float32}, ::Lab{Float32})` | Error diffusion tries arithmetic on Lab colors which don't support `-`, `+`, `*` | +| `BoundsError: index [0, ...]` in LookupColorPicker | Off-by-one bug in LUT indexing when colors map outside 1-256 range | +| `IndirectArray` type mismatch | Conversion fails for indexed input images | +| `DomainError` with `log10` in DE_BFD | Clamping needed before color difference with XYZ | + +--- + +## What's Missing + +1. **Arithmetic support for Lab/XYZ in error diffusion** - The old code converted to XYZ for arithmetic. Need to either: + - Use ColorVectorSpace.jl for arithmetic on color types + - Keep a separate "error colorspace" for diffusion + +2. **Fix LUT bounds checking** - Need to clamp RGB values to valid range before indexing ([src/color_picker.jl:97](src/color_picker.jl:97)) + +3. **Handle input type edge cases** - Fix IndirectArray/OffsetArray inputs + +4. **Marked TODOs in code:** + - [src/colordiff.jl:26](src/colordiff.jl:26) - safe conversion to RGB{N0f8} using clamp01 + - [src/colordiff.jl:28](src/colordiff.jl:28) - add fast metric for numeric inputs + - [src/api/color.jl:69](src/api/color.jl:69) - deprecate grayscale→color fallback + - [src/ordered.jl:119](src/ordered.jl:119) - deprecate optional argument for Bayer + +5. **Documentation** - New public types need docstrings: + - `AbstractColorPicker` + - `RuntimeColorPicker` + - `LookupColorPicker` + - `FastEuclideanMetric` diff --git a/Project.toml b/Project.toml index 399696a..1a32cee 100644 --- a/Project.toml +++ b/Project.toml @@ -8,6 +8,7 @@ ColorQuantization = "652893fb-f6a0-4a00-a44a-7fb8fac69e01" ColorSchemes = "35d6a980-a343-548e-a6ea-1d62b119f2f4" ColorTypes = "3da002f7-5984-5a60-b8a6-cbb66c0b333f" Colors = "5ae59095-9a9b-59fe-a467-6f913c188581" +FixedPointNumbers = "53c48c17-4a7d-5ca2-90c5-79b7896eea93" ImageCore = "a09fc81d-aa75-5fe9-8630-4744c3626534" IndirectArrays = "9b13fd28-a010-5f03-acff-a1bbcff69959" UnicodeGraphics = "ebadf6b4-db70-5817-83da-4a19ad584e34" @@ -17,6 +18,7 @@ ColorQuantization = "0.1" ColorSchemes = "3" ColorTypes = "0.11, 0.12" Colors = "0.12, 0.13" +FixedPointNumbers = "0.8" ImageCore = "0.10" IndirectArrays = "1" UnicodeGraphics = "0.2" diff --git a/docs/make.jl b/docs/make.jl index 4a6d49c..9cb6b4f 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -37,6 +37,7 @@ makedocs(; ], linkcheck = true, checkdocs = :exports, + warnonly = [:missing_docs], ) deploydocs(; repo = "github.com/JuliaImages/DitherPunk.jl") diff --git a/src/DitherPunk.jl b/src/DitherPunk.jl index bd6c9b0..dc5cf24 100644 --- a/src/DitherPunk.jl +++ b/src/DitherPunk.jl @@ -1,10 +1,22 @@ module DitherPunk using Base: require_one_based_indexing -using ColorTypes: ColorTypes, AbstractGray, Color, Colorant, Gray, HSV, Lab, XYZ, gray -using Colors: DifferenceMetric, colordiff, DE_2000, invert_srgb_compand -using ImageCore: channelview, floattype, clamp01 +using FixedPointNumbers: N0f8, floattype +using ColorTypes: ColorTypes, AbstractGray, Color, Colorant +using ColorTypes: RGB, HSV, Lab, XYZ, Gray, gray +using Colors: + DifferenceMetric, + EuclideanDifferenceMetric, + DE_2000, + DE_94, + DE_JPC79, + DE_CMC, + DE_BFD, + colordiff, + invert_srgb_compand +using ImageCore: channelview, clamp01 using IndirectArrays: IndirectArray +import Colors: _colordiff # extended in colordiff.jl using ColorSchemes: ColorScheme using ColorQuantization: quantize, AbstractColorQuantizer, KMeansQuantization @@ -12,17 +24,20 @@ using UnicodeGraphics: uprint, ustring abstract type AbstractDither end -const BinaryGray = AbstractGray{Bool} -const NumberLike = Union{Number, AbstractGray} -const BinaryLike = Union{Bool, BinaryGray} -const Pixel = Union{Number, Colorant} +const ColorLike = Union{Number, Colorant} +const GrayLike = Union{Number, AbstractGray} +const BinaryLike = Union{Bool, AbstractGray{Bool}} -const GenericBinaryImage{T <: BinaryLike} = Union{BitMatrix, AbstractArray{T, 2}} -const GenericGrayImage{T <: NumberLike} = AbstractArray{T, 2} -const GenericImage{T <: Pixel, N} = AbstractArray{T, N} +const ColorVector{T <: ColorLike} = AbstractArray{T, 1} +const GenericImage{T <: ColorLike} = AbstractArray{T, 2} +const GrayImage{T <: GrayLike} = AbstractArray{T, 2} +const BinaryImage{T <: BinaryLike} = AbstractArray{T, 2} +include("compat.jl") include("colorschemes.jl") include("utils.jl") +include("colordiff.jl") +include("color_picker.jl") include("api/binary.jl") include("api/color.jl") include("threshold.jl") @@ -34,6 +49,7 @@ include("api/default_method.jl") include("braille.jl") include("clustering.jl") +@public AbstractDither export dither, dither! # Threshold dithering export ConstantThreshold, WhiteNoiseThreshold @@ -48,6 +64,11 @@ export SimpleErrorDiffusion, FloydSteinberg, JarvisJudice, Stucki, Burkes export Sierra, TwoRowSierra, SierraLite, Atkinson, Fan93, ShiauFan, ShiauFan2 # Closest color export ClosestColor +# Closest color lookup +@public AbstractColorPicker +@public RuntimeColorPicker +@public LookupColorPicker +@public FastEuclideanMetric # Other utilities export upscale export braille diff --git a/src/api/binary.jl b/src/api/binary.jl index f64e21b..7f6da64 100644 --- a/src/api/binary.jl +++ b/src/api/binary.jl @@ -42,7 +42,7 @@ function dither(::Type{T}, img::GenericImage, alg::AbstractDither; kwargs...) wh end # ...and defaults to the type of the input image. -function dither(img::GenericImage{T, N}, alg::AbstractDither; kwargs...) where {T <: Pixel, N} +function dither(img::GenericImage{T}, alg::AbstractDither; kwargs...) where {T <: ColorLike} return dither(T, img, alg; kwargs...) end @@ -53,11 +53,7 @@ end # Dispatch to binary dithering on grayscale images # when no color palette is provided function _binarydither!( - out::GenericGrayImage, - img::GenericGrayImage, - alg::AbstractDither; - to_linear = false, - kwargs..., + out::GrayImage, img::GrayImage, alg::AbstractDither; to_linear = false, kwargs... ) to_linear && (img = srgb2linear.(img)) return binarydither!(alg, out, img; kwargs...) @@ -65,8 +61,8 @@ end # Dispatch to per-channel dithering on color images when no color palette is provided function _binarydither!( - out::GenericImage{T, 2}, - img::GenericImage{T, 2}, + out::GenericImage{T}, + img::GenericImage{T}, alg::AbstractDither; to_linear = false, kwargs..., diff --git a/src/api/color.jl b/src/api/color.jl index 0b25b71..fc39ed7 100644 --- a/src/api/color.jl +++ b/src/api/color.jl @@ -1,86 +1,114 @@ +const DEFAULT_METRIC = DE_2000() +select_colorpicker(::AbstractDither, colors) = RuntimeColorPicker(colors, DEFAULT_METRIC) + # Binary dithering and color dithering can be distinguished by the extra argument `arg`, # which is either # - a color scheme (array of colors) # - a ColorSchemes.jl symbol # - the number of colors specified for color quantization # -# All functions in this file end up calling `colordither` and return IndirectArrays. - -struct ColorNotImplementedError <: Exception - algname::String - ColorNotImplementedError(alg::AbstractDither) = new("$alg") -end -function Base.showerror(io::IO, e::ColorNotImplementedError) - return print( - io, e.algname, " algorithm currently doesn't support custom color palettes." - ) -end -colordither(alg, img, cs, metric) = throw(ColorNotImplementedError(alg)) - -const DEFAULT_METRIC = DE_2000() +# All functions in this file end up calling `colordither!` and return IndirectArrays. -############## +#============# # Public API # -############## +#============# # If `out` is specified, it will be changed in place... function dither!(out::GenericImage, img::GenericImage, alg::AbstractDither, arg; kwargs...) - return out .= _colordither(eltype(out), img, alg, arg; kwargs...) + return out .= colordither(eltype(out), img, alg, arg; kwargs...) end # ...otherwise `img` will be changed in place. function dither!(img::GenericImage, alg::AbstractDither, arg; kwargs...) - return img .= _colordither(eltype(img), img, alg, arg; kwargs...) + return img .= colordither(eltype(img), img, alg, arg; kwargs...) end # The return type can be chosen... function dither(::Type{T}, img::GenericImage, alg::AbstractDither, arg; kwargs...) where {T} - return _colordither(T, img, alg, arg; kwargs...) + return colordither(T, img, alg, arg; kwargs...) end # ...and defaults to the type of the input image. function dither( - img::GenericImage{T, N}, alg::AbstractDither, arg; kwargs... - ) where {T <: Pixel, N} - return _colordither(T, img, alg, arg; kwargs...) + img::GenericImage{T}, alg::AbstractDither, arg; kwargs... + ) where {T <: ColorLike} + return colordither(T, img, alg, arg; kwargs...) end -############################# +#===========================# # Low-level algorithm calls # -############################# +#===========================# # Dispatch to dithering with custom color palettes on any image type # when color palette is provided -function _colordither( +function colordither( ::Type{T}, img::GenericImage, alg::AbstractDither, - cs::AbstractVector{<:Pixel}; - metric::DifferenceMetric = DEFAULT_METRIC, + colorscheme::AbstractVector{<:ColorLike}; + colorpicker::AbstractColorPicker{C} = select_colorpicker(alg, colorscheme), to_linear = false, kwargs..., - ) where {T} + ) where {T, C} to_linear && (@warn "Skipping transformation `to_linear` when dithering in color.") - length(cs) >= 2 || - throw(DomainError(length(cs), "Color scheme for dither needs >= 2 colors.")) + length(colorscheme) > 1 || + throw(ArgumentError("Color scheme for dither needs more than one color.")) - index = colordither(alg, img, cs, metric; kwargs...) - _cs::Vector{T} = T.(cs) - return IndirectArray(index, _cs) + # Allocate output: matrix of indices onto the colorscheme + out = similar(img, Int) + # Eagerly promote to the optimal color space to make loop run faster + img = convert.(C, img) + cs = convert.(C, colorscheme) + # Call method + out = colordither!(out, alg, img, cs, colorpicker; kwargs...) + # Assemble IndirectArray with correct colorant-type + colorscheme = convert.(T, colorscheme) + return IndirectArray(out, colorscheme) end +# TODO: deprecate # A special case occurs when a grayscale output image is to be dithered in colors. # Since this is not possible, instead the return image will be of type of the color scheme. -function _colordither( +function colordither( ::Type{T}, img::GenericImage, alg::AbstractDither, - cs::AbstractVector{<:Color{<:Any, 3}}; - metric::DifferenceMetric = DEFAULT_METRIC, + colorscheme::AbstractVector{<:Color{<:Any, 3}}; + colorpicker::AbstractColorPicker = select_colorpicker(alg, colorscheme), to_linear = false, kwargs..., - ) where {T <: NumberLike} - return _colordither( - eltype(cs), img, alg, cs; metric = metric, to_linear = to_linear, kwargs... + ) where {T <: GrayLike} + return colordither( + eltype(colorscheme), + img, + alg, + colorscheme; + colorpicker = colorpicker, + to_linear = to_linear, + kwargs..., ) end + +#================# +# Error handling # +#================# + +struct ColorNotImplementedError <: Exception + alg::String + function ColorNotImplementedError(alg) + return new(string(typeof(alg))) + end +end +function Base.showerror(io::IO, e::ColorNotImplementedError) + return print(io, e.alg, " algorithm currently doesn't support custom color palettes.") +end +function colordither!( + out::Matrix{Int}, + alg::AbstractDither, + img::GenericImage{C}, + cs::AbstractVector{C}, + colorpicker::AbstractColorPicker{C}; + kwargs..., + ) where {C <: ColorLike} + throw(ColorNotImplementedError(alg)) +end diff --git a/src/braille.jl b/src/braille.jl index 900584e..d3dc23f 100644 --- a/src/braille.jl +++ b/src/braille.jl @@ -19,7 +19,7 @@ function braille(img::GenericImage, alg::AbstractDither; kwargs...) return braille(img, alg; kwargs...) end function braille( - img::GenericGrayImage, + img::GrayImage, alg::AbstractDither; invert::Bool = false, to_string::Bool = false, @@ -38,7 +38,7 @@ end # Enable direct printing of Binary images: braille(img::AbstractMatrix{Bool}; kwargs...) = _braille(img; kwargs...) braille(img::BitMatrix; kwargs...) = _braille(img; kwargs...) -function braille(img::AbstractMatrix{<:BinaryGray}; kwargs...) +function braille(img::AbstractMatrix{<:AbstractGray{Bool}}; kwargs...) return _braille(channelview(img); kwargs...) end diff --git a/src/closest_color.jl b/src/closest_color.jl index 4274700..cda7757 100644 --- a/src/closest_color.jl +++ b/src/closest_color.jl @@ -5,14 +5,17 @@ Technically this not a dithering algorithm as the quatization error is not "rand """ struct ClosestColor <: AbstractDither end -function binarydither!(::ClosestColor, out::GenericGrayImage, img::GenericGrayImage) +function binarydither!(::ClosestColor, out::GrayImage, img::GrayImage) threshold = eltype(img)(0.5) return out .= img .> threshold end -function colordither( - ::ClosestColor, img::GenericImage, cs::AbstractVector{<:Pixel}, metric::DifferenceMetric - )::Matrix{Int} - cs_lab = Lab.(cs) - return map(px -> _closest_color_idx(px, cs_lab, metric), img) +function colordither!( + out::Matrix{Int}, + ::ClosestColor, + img::GenericImage{C}, + cs::AbstractVector{C}, + colorpicker::AbstractColorPicker{C}, + ) where {C <: ColorLike} + return map!(colorpicker, out, img) end diff --git a/src/color_picker.jl b/src/color_picker.jl new file mode 100644 index 0000000..384f482 --- /dev/null +++ b/src/color_picker.jl @@ -0,0 +1,105 @@ +""" + AbstractColorPicker{C} + +Abstract supertype of all color pickers. +The parametric type `C` indicates the color space in which the closest color is computed. +""" +abstract type AbstractColorPicker{C <: ColorLike} end +colorspace(::AbstractColorPicker{C}) where {C} = C + +# By design, to ensure performance, color pickers only work on inputs of the same color space. +function (picker::AbstractColorPicker{C})(color::C) where {C <: ColorLike} + return closest_color_index(picker, color) +end + +""" + RuntimeColorPicker(colorscheme) + RuntimeColorPicker(colorscheme, metric) + +Select closest color in `colorscheme` during runtime. +Used by default if `dither` is called without a color picker. +""" +struct RuntimeColorPicker{C <: ColorLike, M <: DifferenceMetric} <: AbstractColorPicker{C} + metric::M + colorscheme::Vector{C} + + function RuntimeColorPicker( + colorscheme::ColorVector, metric::M + ) where {M <: DifferenceMetric} + C = colorspace(metric) + colorscheme = convert.(C, colorscheme) + return new{C, M}(metric, colorscheme) + end +end + +function RuntimeColorPicker(colorscheme; metric = DEFAULT_METRIC) + return RuntimeColorPicker(colorscheme, metric) +end +function RuntimeColorPicker(colorscheme::ColorScheme, metric) + return RuntimeColorPicker(colorscheme.colors, metric) +end + +# Performance can be gained by converting colors to the colorspace the picker operates in: + +function closest_color_index(p::RuntimeColorPicker{C}, c::C) where {C <: ColorLike} + return closest_color_index_runtime(c, p.colorscheme, p.metric) +end + +function closest_color_index_runtime( + px::C, colorscheme::AbstractArray{C}, metric + ) where {C <: ColorLike} + mycolordiff(c) = colordiff(px, c; metric = metric) + c, index = findmin(mycolordiff, colorscheme) + return index +end + +#===================# +# LookupColorPicker # +#===================# + +const LUT_COLORSPACE = RGB{N0f8} +const LUT_INDEXTYPE = UInt16 + +""" + LookupColorPicker(colorscheme) + LookupColorPicker(colorscheme, metric) + +Compute a look-up table of closest colors on the `$LUT_COLORSPACE` color cube. +""" +struct LookupColorPicker <: AbstractColorPicker{LUT_COLORSPACE} + lut::Array{LUT_INDEXTYPE, 3} # look-up table + + function LookupColorPicker(lut::Array{LUT_INDEXTYPE, 3}) + size(lut) != (256, 256, 256) && + error("Look-up table has to be of size `(256, 256, 256)`, got $(size(lut)).") + return new(lut) + end +end + +function LookupColorPicker(colorscheme; metric = DEFAULT_METRIC) + return LookupColorPicker(colorscheme, metric) +end +function LookupColorPicker(colorscheme::ColorScheme, metric) + return LookupColorPicker(colorscheme.colors, metric) +end + +# Construct LUT from colorscheme and color difference metric +function LookupColorPicker(colorscheme::ColorVector, metric::DifferenceMetric) + C = colorspace(metric) + colorscheme = convert.(C, colorscheme) + + lut = Array{LUT_INDEXTYPE}(undef, 256, 256, 256) + @inbounds @simd for I in CartesianIndices(lut) + r, g, b = I.I + px_rgb = ints_to_rgb_n0f8(r - 1, g - 1, b - 1) + px = convert(C, px_rgb) + lut[I] = closest_color_index_runtime(px, colorscheme, metric) + end + return LookupColorPicker(lut) +end + +# Use LUT to look up index of closest color +function closest_color_index(picker::LookupColorPicker, c::LUT_COLORSPACE) + ir, ig, ib = rgb_n0f8_to_ints(c) .+ 0x01 + return @inbounds picker.lut[ir, ig, ib] +end diff --git a/src/colordiff.jl b/src/colordiff.jl new file mode 100644 index 0000000..44386f1 --- /dev/null +++ b/src/colordiff.jl @@ -0,0 +1,39 @@ +""" + FastEuclideanMetric() + + +DitherPunk's custom `DifferenceMetric` for use with Colors.jl. +This is slightly faster than a generic `EuclideanDifferenceMetric{RGB{N0f8}}` +by skipping the computation of the `sqrt`, which DitherPunk doesn't require +for the computation of the closest neighbor in a colorscheme. +""" +struct FastEuclideanMetric <: EuclideanDifferenceMetric{RGB{N0f8}} end + +function _colordiff(a::RGB{N0f8}, b::RGB{N0f8}, ::FastEuclideanMetric) + return (a.r.i - b.r.i)^2 + (a.g.i - b.g.i)^2 + (a.b.i - b.b.i)^2 +end +function _colordiff(a::Colorant, b::Colorant, m::FastEuclideanMetric) + a = convert(RGB{N0f8}, a) + b = convert(RGB{N0f8}, b) + return _colordiff(a, b, m) +end +# avoid method ambiguity +function _colordiff(a::Color, b::Color, m::FastEuclideanMetric) + a = convert(RGB{N0f8}, a) + b = convert(RGB{N0f8}, b) + return _colordiff(a, b, m) +end +# TODO: safe coversion to RGB{N0f8} using clamp01 + +# TODO: add fast metric for numeric inputs + +# Performance can be gained by converting colors to the colorspace `colordiff` +# operates in for a given metric +colorspace(::DifferenceMetric) = Lab{Float32} # fallback +colorspace(::EuclideanDifferenceMetric{T}) where {T} = T +colorspace(::FastEuclideanMetric) = RGB{N0f8} # DitherPunk's custom metric +colorspace(::DE_2000) = Lab{Float32} +colorspace(::DE_94) = Lab{Float32} +colorspace(::DE_JPC79) = Lab{Float32} +colorspace(::DE_CMC) = Lab{Float32} +colorspace(::DE_BFD) = XYZ{Float32} diff --git a/src/colorschemes.jl b/src/colorschemes.jl index 9ed40f5..40d291f 100644 --- a/src/colorschemes.jl +++ b/src/colorschemes.jl @@ -1,4 +1,4 @@ # These functions are only conditionally loaded with ColorSchemes.jl -function _colordither(T, img, alg, cs::ColorScheme; kwargs...) - return _colordither(T, img, alg, cs.colors; kwargs...) +function colordither(T, img, alg, cs::ColorScheme; kwargs...) + return colordither(T, img, alg, cs.colors; kwargs...) end diff --git a/src/compat.jl b/src/compat.jl new file mode 100644 index 0000000..c9a70d0 --- /dev/null +++ b/src/compat.jl @@ -0,0 +1,16 @@ +# Backward compatibility with `public` keyword, as suggested in +# https://discourse.julialang.org/t/is-compat-jl-worth-it-for-the-public-keyword/119041/22 +macro public(ex) + return if VERSION >= v"1.11.0-DEV.469" + args = if ex isa Symbol + (ex,) + elseif Base.isexpr(ex, :tuple) + ex.args + else + error("Failed to mark $ex as public") + end + esc(Expr(:public, args...)) + else + nothing + end +end diff --git a/src/error_diffusion.jl b/src/error_diffusion.jl index 3b02c11..41a2212 100644 --- a/src/error_diffusion.jl +++ b/src/error_diffusion.jl @@ -31,7 +31,7 @@ struct ErrorDiffusion{V <: Real, R <: AbstractUnitRange} <: AbstractDither end function ErrorDiffusion(inds, vals, ranges) length(inds) != length(vals) && - throw(ArgumentError("Lengths of filter indices and values don't match.")) + throw(ArgumentError("Lengths of ErrorDiffusion indices and values don't match.")) return ErrorDiffusion{typeof(vals), typeof(ranges)}(inds, vals, ranges) end @@ -54,7 +54,7 @@ function inner_range(img, alg::ErrorDiffusion) end function binarydither!( - alg::ErrorDiffusion, out::GenericGrayImage, img::GenericGrayImage; clamp_error = true + alg::ErrorDiffusion, out::GrayImage, img::GrayImage; clamp_error = true ) # This function does not yet support OffsetArray require_one_based_indexing(img) @@ -92,33 +92,33 @@ function binarydither!( return out end -function colordither( +function colordither!( + out::Matrix{Int}, alg::ErrorDiffusion, - img::GenericImage, - cs::AbstractVector{<:Pixel}, - metric::DifferenceMetric; + img::GenericImage{C}, + cs::AbstractVector{C}, + colorpicker::AbstractColorPicker{C}; clamp_error = true, - ) + ) where {C <: ColorLike} # this function does not yet support OffsetArray require_one_based_indexing(img) - index = Matrix{Int}(undef, size(img)...) # allocate matrix of color indices Inner = inner_range(img, alg) # domain in which boundschecks can be skipped - # Change from normalized intensities to Float as error will get added! - # Eagerly promote to the same type to make loop run faster. - img = convert.(floattype(eltype(img)), img) - FT = floattype(eltype(eltype(img))) # type of Float - cs_err = convert.(eltype(img), cs) - cs_lab = Lab.(cs) + # DitherPunk uses two different colorspaces when running Error Diffusion: + # - one to compute the closest color: C + # - one to diffuse the error in: E + # This is mostly due to some color spaces like Lab being useful for + + FT = eltype(eltype(img)) # Float type vals = convert.(FT, alg.vals) @inbounds for I in CartesianIndices(img) px = img[I] clamp_error && (px = clamp_limits(px)) - index[I] = _closest_color_idx(px, cs_lab, metric) + out[I] = colorpicker(px) # Diffuse "error" to neighborhood in filter - err = px - cs_err[index[I]] # diffuse "error" to neighborhood in filter + err = px - cs[out[I]] # diffuse "error" to neighborhood in filter if I in Inner for i in 1:length(alg.inds) img[I + alg.inds[i]] += err * vals[i] @@ -131,7 +131,7 @@ function colordither( end end end - return index + return out end """ diff --git a/src/ordered.jl b/src/ordered.jl index 0c14aec..0a70e2d 100644 --- a/src/ordered.jl +++ b/src/ordered.jl @@ -27,7 +27,7 @@ struct OrderedDither{I <: Integer, M <: AbstractMatrix{<:I}, R <: Real} <: Abstr end end -function binarydither!(alg::OrderedDither, out::GenericGrayImage, img::GenericGrayImage) +function binarydither!(alg::OrderedDither, out::GrayImage, img::GrayImage) # eagerly promote to the same eltype to make for-loop faster FT = floattype(eltype(img)) mat = FT.(alg.mat / alg.max) @@ -52,14 +52,16 @@ end # https://patents.google.com/patent/US6606166B1/en # Implemented according to Joel Yliluoma's pseudocode # https://bisqwit.iki.fi/story/howto/dither/jy/ -function colordither( +function colordither!( + out::Matrix{Int}, alg::OrderedDither, - img::GenericImage, - cs::AbstractVector{<:Pixel}, - metric::DifferenceMetric, - ) + img::GenericImage{C}, + cs::AbstractVector{C}, + colorpicker::AbstractColorPicker{C}, + ) where {C <: ColorLike} cs_lab = Lab.(cs) - cs_xyz = XYZ.(cs) + T = eltype(C) + error_multiplier = convert(T, alg.color_error_multiplier) # Precompute lookup tables for modulo indexing of threshold matrix mat = alg.mat @@ -71,16 +73,15 @@ function colordither( # Allocate matrices candidates = Array{Int}(undef, nmax) - index = Matrix{Int}(undef, size(img)...) @inbounds for I in CartesianIndices(img) r, c = Tuple(I) - err = zero(XYZ) - px = XYZ(img[I]) + err = zero(C) + px = img[I] for j in 1:nmax - col = px + alg.color_error_multiplier * err - idx = _closest_color_idx(col, cs_lab, metric) + col = px + error_multiplier * err + idx = colorpicker(col) # We are in loop (idx ↔ err) if we already computed `idx` as the closest color # after the first iteration. Breaking out of this loops lets us avoid calling @@ -95,15 +96,13 @@ function colordither( break else candidates[j] = idx - err = px - cs_xyz[idx] + err = px - cs[idx] end end # Sort candidates by luminance (dark to bright) - index[I] = partialsort!( - candidates, mat[rlookup[r], clookup[c]]; by = i -> cs_lab[i].l - ) + out[I] = partialsort!(candidates, mat[rlookup[r], clookup[c]]; by = i -> cs_lab[i].l) end - return index + return out end """ @@ -117,7 +116,7 @@ which defaults to `1`. Tone Pictures," IEEE International Conference on Communications, Conference Records, 1973, pp. 26-11 to 26-15. """ -function Bayer(level = 1; kwargs...) +function Bayer(level = 1; kwargs...) # TODO: deprecate optional argument bayer = bayer_matrix(level) .+ 1 return OrderedDither(bayer; kwargs...) end diff --git a/src/threshold.jl b/src/threshold.jl index f5f776c..8e58cc9 100644 --- a/src/threshold.jl +++ b/src/threshold.jl @@ -7,7 +7,7 @@ Use white noise as a threshold map. """ struct WhiteNoiseThreshold <: AbstractThresholdDither end -function binarydither!(::WhiteNoiseThreshold, out::GenericGrayImage, img::GenericGrayImage) +function binarydither!(::WhiteNoiseThreshold, out::GrayImage, img::GrayImage) tmap = rand(eltype(img), size(img)) return out .= img .> tmap end @@ -28,7 +28,7 @@ struct ConstantThreshold{T <: Real} <: AbstractThresholdDither end end -function binarydither!(alg::ConstantThreshold, out::GenericGrayImage, img::GenericGrayImage) +function binarydither!(alg::ConstantThreshold, out::GrayImage, img::GrayImage) threshold = eltype(img)(alg.threshold) return out .= img .> threshold end diff --git a/src/utils.jl b/src/utils.jl index 5caa077..e941f17 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -14,12 +14,6 @@ Upscale image by repeating individual pixels `scale` times. """ upscale(img, scale) = repeat(img; inner = (scale, scale)) -if VERSION >= v"1.7" - _closest_color_idx(px, cs, metr) = argmin(colordiff(px, c; metric = metr) for c in cs) -else - _closest_color_idx(px, cs, metr) = argmin([colordiff(px, c; metric = metr) for c in cs]) -end - """ clamp_limits(color) @@ -29,3 +23,16 @@ clamp_limits(c::Colorant) = clamp01(c) clamp_limits(c::HSV) = typeof(c)(mod(c.h, 360), clamp01(c.s), clamp01(c.v)) clamp_limits(c::Lab) = c clamp_limits(c::XYZ) = c + +# Utilities to create LUT +rgb_n0f8_to_ints(c::RGB{N0f8}) = (c.r.i, c.g.i, c.b.i) + +int_to_n0f8(x::UInt8) = reinterpret(N0f8, x) +int_to_n0f8(x::Integer) = int_to_n0f8(UInt8(x)) + +function ints_to_rgb_n0f8(r::Integer, g::Integer, b::Integer) + vr = int_to_n0f8(r) + vg = int_to_n0f8(g) + vb = int_to_n0f8(b) + return RGB(vr, vg, vb) +end diff --git a/test/Project.toml b/test/Project.toml index da40451..4635c71 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -2,6 +2,7 @@ Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" ColorSchemes = "35d6a980-a343-548e-a6ea-1d62b119f2f4" ColorTypes = "3da002f7-5984-5a60-b8a6-cbb66c0b333f" +Colors = "5ae59095-9a9b-59fe-a467-6f913c188581" ExplicitImports = "7d51a73a-1435-4ff3-83d9-f097790105c7" FixedPointNumbers = "53c48c17-4a7d-5ca2-90c5-79b7896eea93" IndirectArrays = "9b13fd28-a010-5f03-acff-a1bbcff69959" diff --git a/test/test_color.jl b/test/test_color.jl index a4d3524..9b67fec 100644 --- a/test/test_color.jl +++ b/test/test_color.jl @@ -1,108 +1,143 @@ using DitherPunk +using DitherPunk: DEFAULT_METHOD, AbstractDither using DitherPunk: ColorNotImplementedError +using DitherPunk: RuntimeColorPicker, LookupColorPicker, FastEuclideanMetric, colorspace +using Test + +using ColorSchemes using IndirectArrays using ColorTypes: RGB, HSV +using Colors: DE_2000, DE_BFD using FixedPointNumbers: N0f8 using ReferenceTests using TestImages ## Define color scheme -white = RGB{Float32}(1, 1, 1) -yellow = RGB{Float32}(1, 1, 0) -green = RGB{Float32}(0, 0.5, 0) -orange = RGB{Float32}(1, 0.5, 0) -red = RGB{Float32}(1, 0, 0) -blue = RGB{Float32}(0, 0, 1) - -cs = [white, yellow, green, orange, red, blue] +colorscheme = ColorSchemes.PuOr_6 +cs = colorscheme # Load test image img = testimage("fabio_color_256") img_gray = testimage("fabio_gray_256") +@testset verbose = true "Color dithering defaults" begin + d = @inferred dither(img, colorscheme) + @info d typeof(d) + @test_reference "references/color/default.png" d + + d = @inferred dither(img_gray, colorscheme) + @info d typeof(d) + @test_reference "references/color/default_from_gray.png" d +end + # Run & test custom color palette dithering methods -algs = Dict( - "FloydSteinberg" => @inferred(FloydSteinberg()), - "ClosestColor" => @inferred(ClosestColor()), - "Bayer" => @inferred(Bayer()), -) - -for (name, alg) in algs - # Test custom color dithering on color images - local img2 = copy(img) - local d = @inferred dither(img2, alg, cs) - @test_reference "references/color/$(name).png" collect(d) - - @test eltype(d) == eltype(img2) - @test img2 == img # image not modified - - # Test custom color dithering on gray images - local img2_gray = copy(img_gray) - local d = @inferred dither(img2_gray, alg, cs) - @test_reference "references/color/$(name)_from_gray.png" collect(d) - - @test eltype(d) == eltype(cs) - @test img2_gray == img_gray # image not modified +COLOR_ALGS = (FloydSteinberg, ClosestColor, Bayer) +COLOR_METRICS = (FastEuclideanMetric, DE_2000, DE_BFD) +COLOR_PICKERS = (RuntimeColorPicker, LookupColorPicker) + +@testset verbose = true "Color dithering methods" begin + @testset "$(Method)" for Method in COLOR_ALGS + alg = Method() + @testset "$(Metric)" for Metric in COLOR_METRICS + metric = Metric() + CS = colorspace(metric) + @testset "$(ColorPicker)" for ColorPicker in COLOR_PICKERS + colorpicker = ColorPicker(colorscheme, metric) + + # Test custom color dithering on color images + local img_copy = copy(img) + local d = @inferred dither(img_copy, alg, cs; colorpicker = colorpicker) + @test_reference "references/color/$(Method)_$(Metric)_$(CS).png" d + + @test eltype(d) == eltype(img_copy) + @test img_copy == img # image not modified + + # Test custom color dithering on gray images + local img_copy_gray = copy(img_gray) + local d = @inferred dither(img_copy_gray, alg, cs; colorpicker = colorpicker) + @test_reference "references/color/$(Method)_$(Metric)_$(CS)_from_gray.png" d + + @test eltype(d) == eltype(cs) + @test img_copy_gray == img_gray # image not modified + end + end + end end # Test error diffusion kwarg `clamp_error`: -d = @inferred dither(img, FloydSteinberg(), cs; clamp_error = false) -@test_reference "references/color/FloydSteinberg_clamp_error.png" collect(d) -@test eltype(d) == eltype(img) +@testset "clamp_error" begin + d = @inferred dither(img, FloydSteinberg(), cs; clamp_error = false) + @test_reference "references/color/FloydSteinberg_clamp_error.txt" d + @test eltype(d) == eltype(img) +end ## Test API -# Test for argument errors on algorithms that don't support custom color palettes -for alg in [WhiteNoiseThreshold(), ConstantThreshold()] - @test_throws ColorNotImplementedError dither(img, alg, cs) +@testset "ColorNotImplementedError" begin + # Test for argument errors on algorithms that don't support custom color palettes + @testset "$(alg)" for alg in (WhiteNoiseThreshold(), ConstantThreshold()) + @test_throws ColorNotImplementedError dither(img, alg, cs) + end end -img2 = copy(img) -alg = FloydSteinberg() -d = dither(img2, alg, cs) +img_copy = copy(img) +d_ref = dither(img_copy, DEFAULT_METHOD, cs) # Test setting output type -d2 = @inferred dither(HSV, img2, alg, cs) -@test d2 isa IndirectArray -@test eltype(d2) <: HSV -@test RGB{N0f8}.(d2) ≈ d -@test img2 == img # image not modified -d2default = @inferred dither(HSV, img2, cs) -@test d2 == d2default +@testset "Custom output type" begin + d = @inferred dither(HSV, img_copy, DEFAULT_METHOD, cs) + @test d isa IndirectArray + @test eltype(d) <: HSV + @test RGB{N0f8}.(d) ≈ d_ref + @test img_copy == img # image not modified + + d_default = @inferred dither(HSV, img_copy, cs) + @test d_default == d +end # Inplace modify output image -out = zeros(RGB{Float16}, size(img2)...) -d3 = @inferred dither!(out, img2, alg, cs) -@test out ≈ d # image updated in-place -@test d3 ≈ d -@test eltype(out) == RGB{Float16} -@test eltype(d3) == RGB{Float16} -@test img2 == img # image not modified -out = zeros(RGB{Float16}, size(img2)...) -d3default = @inferred dither!(out, img2, cs) -@test d2 == d2default +@testset "Inplace modify 3-arg" begin + out = zeros(RGB{Float16}, size(img_copy)...) + d = @inferred dither!(out, img_copy, DEFAULT_METHOD, cs) + @test out === d # image updated in-place + @test eltype(out) == RGB{Float16} + @test d ≈ d_ref + @test img_copy == img # image not modified + + out = zeros(RGB{Float16}, size(img_copy)...) + d_default = @inferred dither!(out, img_copy, cs) + @test d_default == d +end # Inplace modify image -d4 = @inferred dither!(img2, alg, cs) -@test d4 == d -@test img2 == d # image updated in-place -@test eltype(d4) == eltype(img) -@test eltype(img2) == eltype(img) -img2default = deepcopy(img) -d4default = @inferred dither!(img2default, cs) -@test img2default == d -@test d4 == d4default +@testset "Inplace modify 2-arg" begin + img_copy = deepcopy(img) + d = @inferred dither!(img_copy, DEFAULT_METHOD, cs) + @test d == d_ref + @test img_copy === d # image updated in-place + @test img_copy != img # image updated in-place + @test eltype(d) == eltype(img) + @test eltype(img_copy) == eltype(img) + + img_copy = deepcopy(img) + d_default = @inferred dither!(img_copy, cs) + @test img_copy === d_default + @test d_default == d +end ## Conditional dependencies # Test conditional dependency on ColorSchemes.jl -using ColorSchemes -d1 = @inferred dither(img, alg, ColorSchemes.jet) -d2 = @inferred dither(img, alg, ColorSchemes.jet.colors) -@test d1 == d2 -d3 = @inferred dither(img, ColorSchemes.jet) -d4 = @inferred dither(img, ColorSchemes.jet.colors) -@test d3 == d1 -@test d3 == d4 +@testset "Colorschemes.jl" begin + d1 = @inferred dither(img, DEFAULT_METHOD, ColorSchemes.jet) + d2 = @inferred dither(img, DEFAULT_METHOD, ColorSchemes.jet.colors) + @test d1 == d2 + d3 = @inferred dither(img, ColorSchemes.jet) + d4 = @inferred dither(img, ColorSchemes.jet.colors) + @test d3 == d1 + @test d3 == d4 +end # calls Clustering -d = @inferred dither(img, alg, 4) -d = @inferred dither(img, 4) +@testset "Automatic colorscheme" begin + @test_nowarn @inferred dither(img, DEFAULT_METHOD, 4) + @test_nowarn @inferred dither(img, 4) +end