The principles of Images.jl: Part I
This blog demonstrates how we (my mentor @zygmuntszpak and me @johnnychen94) plan to evolve the entire ecosystem in my JSoC project "Towards Better Images.jl
Ecosystem". As a side product of it, a new package ImageQualityIndexes.jl
is released.
In this blog, I'll make a short introduction of ImageQualityIndexes.jl,
then explain how we make all the decisions.
A quick demo of ImageQualityIndexes.jl
Before explaining the coding idea in details, I'd like to show how we can use this package.
# Let's add and import some package first pkg"activate ." pkg"add ImageQualityIndexes ImageCore ImageDistances ImageFiltering ImageShow TestImages" using ImageCore, ImageDistances, ImageShow, TestImages, ImageFiltering, ImageQualityIndexes, Test # ImageQualityIndexes will be reexported by Images in the future
Assume that you have implemented a fantastic image restoration algorithm, which in our case is mean filter, we want to show how fantastic this algorithm is. Two most famous indexes/methods used here are: Peak signal-to-noise ratio (PSNR) and Structural similarity (SSIM). They're implemented in ImageQualityIndexes.jl
now🎉
# let's load lena with great respect, who significantly pushes the progress of image processing.😄 img = testimage("lena_gray_256") .|> float64 noisy_img = colorview(Gray, img .+ 0.1 .* randn(size(img))) # add gaussian noise of std 0.1 kernel = centered(ones(3, 3)./9) denoised_img = imfilter(noisy_img, kernel) # denoise image with mean filter # great, it works # now let's see how fantastic it is: # 1. quantity performance: psnr, ssim psnrs = [psnr(img, img), psnr(noisy_img, img), psnr(denoised_img, img)] ssims = [ssim(img, img), ssim(noisy_img, img), ssim(denoised_img, img)] println("PSNRs:", psnrs) println("SSIMs:", ssims) # 2. visual quality performance [img noisy_img denoised_img]
As your algorithm becoming better, PSNR goes to Inf
and SSIM goes to 1
.
That's how we use it in most cases, quite easy, right? Actually, it's also made robust and smart enough, of course, in a simple way.
Review different image types
If you did some real image processing work in other languages, you probably know that
- there is a
uint8
storage type with values in{0, 1, ..., 255}
, and adouble
storage type with values in[0, 1]
. Don't forget that there'reuint16
, etc. - there's a
double(img)
which simply does a type conversion andim2double(img)
that does additional value mapping from{0, 1, ..., 255}
to[0,1]
- there is a
m*n*3
RGB
image with red(R), green(G) and blue(B) channels, and am*n*3 Lab
image with lightness(L) and a, b channels. No doubt there are more.
It immediately becomes a nightmare for package developers and package users, that nobody knows what exactly a m*n*3 array
represents. Is it Lab image, RGB image or even 3D gray image?
In short, as you can imagine, flexibility and robustness seems conflict with each other.
How Images.jl save your life?
Luckily, this is a lovely story in JuliaImages, thanks to the work from @timholy and all other contributors in the dark age of Julia.
In JuliaImages, we have an intermediate type Colorant
that mimics the meaning of pixel; all images are of type AbstractArray{<:Colorant, N}
. This notation is extremely powerful in the following senses:
- we immediately know that
Array{RGB, 2}
is a 2D RGB image, thatArray{Lab, 2}
is a 2D Lab image, and thatArray{Gray, 3}
is a 3D Gray image. - we can easily write flexible and robust algorithms using the multiple dispatches on
eltype(img)
.
To ease our life by avoiding unnecessary ambiguities, we made the following decisions:
Rule of thumb:
AbstractArray{<:Number}
is treated as gray images by default- abandon
UIntxx
type in favor ofFixedPointNumbers.Normed
Take the example of PSNR, the definition is quite simple for gray images
# definition of PSNR function psnr_1(x::AbstractArray{T}, ref::AbstractArray{T}, peakval) where T <: Union{AbstractGray, Number} 20log10(peakval) - 10log10(mse(x, ref)) end # RGB images are treated as 3D Gray images, so we only need to unwrap it. function psnr_1(x::AbstractArray{T}, ref::AbstractArray{T}, peakval) where T <: AbstractRGB psnr_1(channelview(x), channelview(ref), peakval) end psnr_1(noisy_img, img, 1) == psnr(noisy_img, img)
Since we don't need to guess the input types by checking the array value, which consists of a major part of codes in other languages, we need less codes here. Also, it's as robust as you can imagine; if you pass Lab
images, you will get a MethodError
. No we don't guess the inputs in JuliaImages; a lot of type annotations are used in Images.jl
to avoid ambiguity.
In other languages, we also need to guess the peak signal value, while in Julia, it's just an additional three lines of code:
# infer the peakvalue according to pixel type peakval(::Type{T}) where T<:AbstractGray = 1 peakval(::Type{T}) where T<:AbstractRGB = 1 psnr_1(x, ref) = psnr_1(x, ref, peakval(eltype(ref))) psnr_1(noisy_img, img) == psnr(noisy_img, img) # great!
Is that all you're expecting? Good. ImageQualityIndexes is designed in this simple, flexible and robust way. We are going to make this happen to other JuliaImages packages.
Oh there's another demo
Recall how we calculate psnrs in the demo:
psnrs = [psnr(img, img), psnr(noisy_img, img), psnr(denoised_img, img)] ssims = [ssim(img, img), ssim(noisy_img, img), ssim(denoised_img, img)] println("PSNRs:", psnrs) println("SSIMs:", ssims)
One may ask: can we make it shorter? Yes we can do this using list comprehension.
psnrs = [psnr(x, img) for x in (img, noisy_img, denoised_img)] ssims = [ssim(x, img) for x in (img, noisy_img, denoised_img)] println("PSNRs:", psnrs) println("SSIMs:", ssims)
Is this the shortest? Definitely NO.
psnrs, ssims = [[assess(iqi, x, img) for x in (img, noisy_img, denoised_img)] for iqi in (PSNR(), SSIM())] println("PSNRs:", psnrs) println("SSIMs:", ssims)
That said, the last one isn't as easy to be understood as the first two. But you may notice another way of calling functions in ImageQualityIndexes.jl
through assess
. Actually, there's one more, PSNR()(noisy_img, img)
, which sometimes is called a functor in other languages, i.e., a class instance that behaves like a function.
# three set of APIs in ImageQualityIndexes.jl iqi = PSNR() assess(iqi, noisy_img, img) == iqi(noisy_img, img) == psnr(noisy_img, img)
Why we invent a new name?
I bet you must be wondering why we need to invent a new name `assess`? Our reason for it is:
> Readability counts.
Still take the example of ImageQualityIndexes.jl
, but please do think it in a more general way:
Although assess
and functor work equivalently,
assess(iqi, x, ref)
reads as "assess the image quality of x using method iqi with information ref"iqi(x, ref)
is not easy to be understood at the first look, since one might guess whatiqi
is -- It might be a Flux model though :D
Although assess
and psnr
/ssim
work equivalently,
- From
assess(SSIM(kernel, W), x, ref)
one immediately knows thatkernel, W
are arguments toSSIM
without even looking into the docsting. - From
ssim(x, ref, kernel, W)
we don't know this fact unless we check the docstring, if there is a well-documented one. (check?ssim
for it)
So far I think assess
is the most explicit and accurate API.
Don't worry, the codes are still pretty simple even if we have three sets of APIs
abstract type ImageQualityIndex_1 end # we use functor to link assess to implementations assess_1(iqi::ImageQualityIndex_1, x, ref) = iqi(x, ref) struct PSNR_1 <:ImageQualityIndex_1 end peakval(::Type{T}) where T<:AbstractGray = 1 peakval(::Type{T}) where T<:AbstractRGB = 1 # hide implementation details in another not-exported name (iqi::PSNR_1)(x, ref, peakval) = _psnr(x, ref, peakval) (iqi::PSNR_1)(x, ref) = iqi(x, ref, peakval(eltype(ref))) psnr_1(x, ref, peakval) = _psnr(x, ref, peakval) psnr_1(x, ref) = psnr_1(x, ref, peakval(eltype(ref))) function _psnr(x::AbstractArray{T}, ref::AbstractArray{T}, peakval) where T <: Union{AbstractGray, Number} 20log10(peakval) - 10log10(mse(x, ref)) end function _psnr(x::AbstractArray{T}, ref::AbstractArray{T}, peakval) where T <: AbstractRGB _psnr(channelview(x), channelview(ref), peakval) end
The following figure shows how I design this package.
assess_1(PSNR_1(), noisy_img, img) == assess(PSNR(), noisy_img, img) PSNR_1()(noisy_img, img) == PSNR()(noisy_img, img)
How we understand the three sets of APIs?
assess
is the standard way, aka protocol, of calling any image quality assessment method- functors like
iqi(x, ref)
belongs to implementation details that links different algorithms toassess
without much coupling. - convenient names
psnr
andssim
are made for convenient and compatibility usage. After all, users from MATLAB are more familiar topsnr
andssim
.
Conclusion
This is going to be the end of the story. Not all things are organized logically, but I still hope you now understand the basic and noteworthy principles of JuliaImages. Just to make it like a conclusion, they're:
AbstractArray{<:Number}
is treated as gray images by default- no
UIntxx
- take the advantage of dispatch, and, don't overuse dispatch
- Readability counts