Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
73336e3
Move `closest_color_idx` to new file
adrhill Dec 27, 2023
80a7619
Add utils for `RGB{N0f8}` conversions
adrhill Dec 27, 2023
3e0acb9
Add `FastEuclideanMetric`
adrhill Dec 27, 2023
123727b
Add `ClosestColorLookup`
adrhill Dec 27, 2023
799e35a
Rename color selectors to `ColorPicker`
adrhill Dec 29, 2023
da793d4
Define `colorspace`s of `DifferenceMetric`s
adrhill Dec 29, 2023
5ba9c6a
Add `RuntimeColorPicker`
adrhill Dec 29, 2023
625017f
Merge branch 'master' into ah/lut
adrhill Sep 14, 2024
682401f
Fix imports
adrhill Sep 14, 2024
8d09bf2
Refactor types
adrhill Sep 14, 2024
3559755
Refactor color tests
adrhill Sep 17, 2024
96bbbef
Further refactoring of color tests
adrhill Sep 17, 2024
4eac73c
Merge branch 'master' into ah/lut
adrhill Sep 17, 2024
71763f7
Rename `closes_color_index`
adrhill Sep 17, 2024
5b142a0
Warn only on missing docs
adrhill Sep 17, 2024
cfbe04a
Use `RunTimeColorpicker` in all methods
adrhill Sep 17, 2024
170098c
Use parametric color picker types
adrhill Sep 23, 2024
4b8c074
Refactor color dithering algorithms
adrhill Sep 23, 2024
b76cdfc
Update reference tests
adrhill Sep 23, 2024
23a6707
Use `public` keyword
adrhill Sep 23, 2024
962478b
Test colorpickers
adrhill Sep 23, 2024
196b4c0
Update test references
adrhill Sep 23, 2024
b161cf3
Refactor color picker selection
adrhill Sep 23, 2024
dd88aeb
Stricter typing on `colordither!` error
adrhill Sep 23, 2024
ecea882
More colorpicker tests
adrhill Sep 23, 2024
6ae06e3
Add concrete colorspace float types
adrhill Sep 23, 2024
dae50f7
Refactor `closest_color_index_runtime`
adrhill Sep 23, 2024
1c68288
Support ColorSchemes in color pickers
adrhill Sep 24, 2024
7b941ef
Refactor color tests
adrhill Sep 25, 2024
25cffe8
Rename `colordither`
adrhill Sep 25, 2024
47a8c96
Test defaults
adrhill Sep 25, 2024
08ac09c
Remove old test references
adrhill Mar 13, 2025
4c383f8
Merge remote-tracking branch 'origin/main' into ah/lut
adrhill Feb 1, 2026
f02d311
Run Runic
adrhill Feb 1, 2026
4121c7d
Ignore formatting commit
adrhill Feb 1, 2026
8326607
Notes on state of PR
adrhill Feb 1, 2026
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
2 changes: 2 additions & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Runic formatting
371100390d916ccc49093b63f69c5a043fd5fd01
57 changes: 57 additions & 0 deletions NOTES.md
Original file line number Diff line number Diff line change
@@ -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`
2 changes: 2 additions & 0 deletions Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
1 change: 1 addition & 0 deletions docs/make.jl
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ makedocs(;
],
linkcheck = true,
checkdocs = :exports,
warnonly = [:missing_docs],
)

deploydocs(; repo = "github.com/JuliaImages/DitherPunk.jl")
41 changes: 31 additions & 10 deletions src/DitherPunk.jl
Original file line number Diff line number Diff line change
@@ -1,28 +1,43 @@
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
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")
Expand All @@ -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
Expand All @@ -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
Expand Down
12 changes: 4 additions & 8 deletions src/api/binary.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -53,20 +53,16 @@ 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...)
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...,
Expand Down
106 changes: 67 additions & 39 deletions src/api/color.jl
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions src/braille.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand Down
15 changes: 9 additions & 6 deletions src/closest_color.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading