Skip to content

Commit 02407fa

Browse files
Move extremization, blob_LoG from Images (#223)
This makes some departures from our old implementations: - add a `show` method for `BlobLog` - encode the entire multidimensional `σ` (after multiplying by `σshape`) - add a threshold to dispose of spurious maxima by default - separate the filtering part of `blob_LoG` into an independent but non-exported function (easier debugging that way) - change the API of `findlocalmaxima` etc to be `window`-based rather than `dims`-based. This is strictly more flexible. Co-authored-by: Johnny Chen <[email protected]>
1 parent f85e7b0 commit 02407fa

File tree

6 files changed

+243
-2
lines changed

6 files changed

+243
-2
lines changed

Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name = "ImageFiltering"
22
uuid = "6a3955dd-da59-5b1f-98d4-e7296123deb5"
33
author = ["Tim Holy <[email protected]>", "Jan Weidner <[email protected]>"]
4-
version = "0.6.22"
4+
version = "0.7.0"
55

66
[deps]
77
CatIndices = "aafaddc9-749c-510e-ac4f-586e18779b91"

src/ImageFiltering.jl

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ export Kernel, KernelFactors,
1818
imfilter, imfilter!,
1919
mapwindow, mapwindow!,
2020
imgradients, padarray, centered, kernelfactors, reflect,
21-
freqkernel, spacekernel
21+
freqkernel, spacekernel,
22+
findlocalminima, findlocalmaxima,
23+
blob_LoG, BlobLoG
2224

2325
FixedColorant{T<:Normed} = Colorant{T}
2426
StaticOffsetArray{T,N,A<:StaticArray} = OffsetArray{T,N,A}
@@ -56,6 +58,7 @@ using .Algorithm: Alg, FFT, FIR, FIRTiled, IIR, Mixed
5658
Alg(r::AbstractResource{A}) where {A<:Alg} = r.settings
5759

5860
include("utils.jl")
61+
include("compat.jl")
5962
include("kernelfactors.jl")
6063
using .KernelFactors: TriggsSdika, IIRFilter, ReshapedOneD, iterdims, kernelfactors
6164

@@ -85,6 +88,7 @@ include("specialty.jl")
8588

8689
include("mapwindow.jl")
8790
using .MapWindow
91+
include("extrema.jl")
8892

8993
function __init__()
9094
# See ComputationalResources README for explanation

src/compat.jl

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
if VERSION <= v"1.0.5"
2+
# https://github.com/JuliaLang/julia/pull/29442
3+
_oneunit(::CartesianIndex{N}) where {N} = _oneunit(CartesianIndex{N})
4+
_oneunit(::Type{CartesianIndex{N}}) where {N} = CartesianIndex(ntuple(x -> 1, Val(N)))
5+
else
6+
const _oneunit = Base.oneunit
7+
end
8+
9+
# Equivalent to I:J on later Julia versions
10+
_colon(I::CartesianIndex{N}, J::CartesianIndex{N}) where N =
11+
CartesianIndices(map((i,j) -> i:j, Tuple(I), Tuple(J)))

src/extrema.jl

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"""
2+
BlobLoG stores information about the location of peaks as discovered by `blob_LoG`.
3+
It has fields:
4+
5+
- location: the location of a peak in the filtered image (a CartesianIndex)
6+
- σ: the value of σ which lead to the largest `-LoG`-filtered amplitude at this location
7+
- amplitude: the value of the `-LoG(σ)`-filtered image at the peak
8+
9+
Note that the radius is equal to σ√2.
10+
11+
See also: [`blob_LoG`](@ref).
12+
"""
13+
struct BlobLoG{T,S,N}
14+
location::CartesianIndex{N}
15+
σ::S
16+
amplitude::T
17+
end
18+
BlobLoG(; location, σ, amplitude) = BlobLoG(location, σ, amplitude)
19+
20+
function Base.show(io::IO, bl::BlobLoG)
21+
print(io, "BlobLoG(location=", bl.location, ", σ=", bl.σ, ", amplitude=", bl.amplitude, ")")
22+
end
23+
24+
25+
"""
26+
blob_LoG(img, σscales; edges::Tuple=(true, false, ...), σshape::Tuple=(1, ...), rthresh=0.001) -> Vector{BlobLoG}
27+
28+
Find "blobs" in an N-D image using the negative Lapacian of Gaussians
29+
with the specifed vector or tuple of σ values. The algorithm searches for places
30+
where the filtered image (for a particular σ) is at a peak compared to all
31+
spatially- and σ-adjacent voxels, where σ is `σscales[i] * σshape` for some i.
32+
By default, `σshape` is an ntuple of 1s.
33+
34+
The optional `edges` argument controls whether peaks on the edges are
35+
included. `edges` can be `true` or `false`, or a N+1-tuple in which
36+
the first entry controls whether edge-σ values are eligible to serve
37+
as peaks, and the remaining N entries control each of the N dimensions
38+
of `img`.
39+
40+
`rthresh` controls the minimum amplitude of peaks in the -LoG-filtered image,
41+
as a fraction of `maximum(abs, img)` and the volume of the Gaussian.
42+
43+
# Examples
44+
45+
While most images are 2- or 3-dimensional, it will be easier to illustrate this with
46+
a one-dimensional "image" containing two Gaussian blobs of different sizes:
47+
48+
```jldoctest; setup=:(using ImageFiltering), filter=r"amplitude=.*"]
49+
julia> σs = 2.0.^(1:6);
50+
51+
julia> img = zeros(100); img[20:30] = [exp(-x^2/(2*4^2)) for x=-5:5]; img[50:80] = [exp(-x^2/(2*8^2)) for x=-15:15];
52+
53+
julia> blob_LoG(img, σs; edges=false)
54+
2-element Vector{BlobLoG{Float64, Tuple{Float64}, 1}}:
55+
location=CartesianIndex(25,), σ=(4.0,), amplitude=0.10453155018303673
56+
location=CartesianIndex(65,), σ=(8.0,), amplitude=0.046175719034527364
57+
```
58+
59+
The other two are centered in their corresponding "features," and the width `σ`
60+
reflects the width of the feature itself.
61+
62+
`blob_LoG` tends to work best for shapes that are "Gaussian-like" but does
63+
generalize somewhat.
64+
65+
# Citation:
66+
67+
Lindeberg T (1998), "Feature Detection with Automatic Scale Selection",
68+
International Journal of Computer Vision, 30(2), 79–116.
69+
70+
See also: [`BlobLoG`](@ref).
71+
"""
72+
function blob_LoG(img::AbstractArray{T,N}, σscales;
73+
edges::Union{Bool,Tuple{Bool,Vararg{Bool,N}}}=(true, ntuple(d->false, Val(N))...),
74+
σshape::NTuple{N,Real}=ntuple(d->1, Val(N)),
75+
rthresh::Real=1//1000) where {T<:Union{AbstractGray,Real},N}
76+
if edges isa Bool
77+
edges = (edges, ntuple(d->edges,Val(N))...)
78+
end
79+
sigmas = sort(σscales)
80+
img_LoG = multiLoG(img, sigmas, σshape)
81+
maxima = findlocalmaxima(img_LoG; edges=edges)
82+
# The "density" should not be much smaller than 1/volume of the Gaussian
83+
if !iszero(rthresh)
84+
athresh = rthresh./(sigmas.^N .* prod(σshape))
85+
imgmax = maximum(abs, img)
86+
[BlobLoG(CartesianIndex(tail(x.I)), sigmas[x[1]].*σshape, img_LoG[x]) for x in maxima if img_LoG[x] > athresh[x[1]]*imgmax]
87+
else
88+
[BlobLoG(CartesianIndex(tail(x.I)), sigmas[x[1]].*σshape, img_LoG[x]) for x in maxima]
89+
end
90+
end
91+
92+
function multiLoG(img::AbstractArray{T,N}, sigmas, σshape) where {T,N}
93+
issorted(sigmas) || error("sigmas must be sorted")
94+
img_LoG = similar(img, float(eltype(T)), (Base.OneTo(length(sigmas)), axes(img)...))
95+
colons = ntuple(d->Colon(), Val(N))
96+
@inbounds for (isigma, σ) in enumerate(sigmas)
97+
LoG_slice = @view img_LoG[isigma, colons...]
98+
imfilter!(LoG_slice, img, Kernel.LoG(ntuple(i->σ*σshape[i], Val(N))), "reflect")
99+
LoG_slice .*= -σ
100+
end
101+
return img_LoG
102+
end
103+
104+
default_window(img) = (cs = coords_spatial(img); ntuple(d -> d cs ? 3 : 1, ndims(img)))
105+
106+
"""
107+
findlocalmaxima(img; window=default_window(img), edges=true) -> Vector{CartesianIndex}
108+
109+
Returns the coordinates of elements whose value is larger than all of
110+
their immediate neighbors. `edges` is a boolean specifying whether to include the
111+
first and last elements of each dimension, or a tuple-of-Bool
112+
specifying edge behavior for each dimension separately.
113+
114+
The `default_window` is 3 for each spatial dimension of `img`, and 1 otherwise, implying
115+
that maxima are detected over nearest-neighbors in each spatial "slice" by default.
116+
"""
117+
findlocalmaxima(img::AbstractArray; window=default_window(img), edges=true) =
118+
findlocalextrema(>, img, window, edges)
119+
120+
"""
121+
findlocalminima(img; window=default_window(img), edges=true) -> Vector{CartesianIndex}
122+
123+
Like [`findlocalmaxima`](@ref), but returns the coordinates of the smallest elements.
124+
"""
125+
findlocalminima(img::AbstractArray; window=default_window(img), edges=true) =
126+
findlocalextrema(<, img, window, edges)
127+
128+
129+
findlocalextrema(f, img::AbstractArray{T,N}, window, edges::Bool) where {T,N} = findlocalextrema(f, img, window, ntuple(d->edges,Val(N)))
130+
131+
function findlocalextrema(f::F, img::AbstractArray{T,N}, window::Dims{N}, edges::NTuple{N,Bool}) where {F,T<:Union{Gray,Number},N}
132+
extrema = Vector{CartesianIndex{N}}(undef, 0)
133+
Iedge = CartesianIndex(map(!, edges))
134+
R0 = CartesianIndices(img)
135+
R = clippedinds(R0, Iedge)
136+
halfwindow = CartesianIndex(map(x -> x >> 1, window))
137+
Rinterior = clippedinds(R0, halfwindow)
138+
Rwindow = _colon(-halfwindow, halfwindow)
139+
z = zero(halfwindow)
140+
for i in R
141+
isextrema = true
142+
img_i = img[i]
143+
if i Rinterior
144+
# If i is in the interior, we don't have to worry about i+j being out-of-bounds
145+
for j in Rwindow
146+
j == z && continue
147+
if !f(img_i, img[i+j])
148+
isextrema = false
149+
break
150+
end
151+
end
152+
else
153+
for j in Rwindow
154+
(j == z || i+j R0) && continue
155+
if !f(img_i, img[i+j])
156+
isextrema = false
157+
break
158+
end
159+
end
160+
end
161+
isextrema && push!(extrema, i)
162+
end
163+
extrema
164+
end
165+
166+
clippedinds(Router, Iclip) = _colon(first(Router)+Iclip, last(Router)-Iclip)

test/extrema.jl

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
@testset "extrema" begin
2+
@testset "local extrema" begin
3+
A = zeros(Int, 9, 9); A[[1:2;5],5].=1
4+
@test findlocalmaxima(A) == [CartesianIndex((5,5))]
5+
@test findlocalmaxima(A; window=(1,3)) == [CartesianIndex((1,5)),CartesianIndex((2,5)),CartesianIndex((5,5))]
6+
@test findlocalmaxima(A; window=(1,3), edges=false) == [CartesianIndex((2,5)),CartesianIndex((5,5))]
7+
A = zeros(Int, 9, 9, 9); A[[1:2;5],5,5].=1
8+
@test findlocalmaxima(A) == [CartesianIndex((5,5,5))]
9+
@test findlocalmaxima(A; window=(1,3,1)) == [CartesianIndex((1,5,5)),CartesianIndex((2,5,5)),CartesianIndex((5,5,5))]
10+
@test findlocalmaxima(A, window=(1,3,1), edges=false) == [CartesianIndex((2,5,5)),CartesianIndex((5,5,5))]
11+
A = zeros(Int, 9, 9); A[[1:2;5],5].=-1
12+
@test findlocalminima(A) == [CartesianIndex((5,5))]
13+
end
14+
15+
@testset "blob_LoG" begin
16+
A = zeros(Int, 9, 9); A[5, 5] = 1
17+
blobs = blob_LoG(A, 2.0.^[0.5,0,1])
18+
@test length(blobs) == 1
19+
blob = blobs[1]
20+
@test blob.amplitude 0.3183098861837907
21+
@test blob.σ === (1.0, 1.0)
22+
@test blob.location == CartesianIndex((5,5))
23+
str = sprint(print, blob)
24+
@test occursin("σ=$((1.0, 1.0))", str)
25+
@test eval(Meta.parse(str)) == blob
26+
@test blob_LoG(A, [1.0]) == blobs
27+
@test blob_LoG(A, [1.0]; edges=(true, false, false)) == blobs
28+
@test isempty(blob_LoG(A, [1.0]; edges=false))
29+
A = zeros(Int, 9, 9); A[1, 5] = 1
30+
blobs = blob_LoG(A, 2.0.^[0,0.5,1])
31+
A = zeros(Int, 9, 9); A[1,5] = 1
32+
blobs = blob_LoG(A, 2.0.^[0.5,0,1])
33+
@test all(b.amplitude < 1e-16 for b in blobs)
34+
blobs = filter(b->b.amplitude > 0.1, blob_LoG(A, 2.0.^[0.5,0,1]; edges=true))
35+
@test length(blobs) == 1
36+
@test blobs[1].location == CartesianIndex((1,5))
37+
@test filter(b->b.amplitude > 0.1, blob_LoG(A, 2.0.^[0.5,0,1], edges=(true, true, false))) == blobs
38+
@test isempty(blob_LoG(A, 2.0.^[0,1], edges=(false, true, false)))
39+
blobs = blob_LoG(A, 2.0.^[0,0.5,1], edges=(true, false, true))
40+
@test all(b.amplitude < 1e-16 for b in blobs)
41+
# stub test for N-dimensional blob_LoG:
42+
A = zeros(Int, 9, 9, 9); A[5, 5, 5] = 1
43+
blobs = blob_LoG(A, 2.0.^[0.5, 0, 1])
44+
@test length(blobs) == 1
45+
@test blobs[1].location == CartesianIndex((5,5,5))
46+
# kinda anisotropic image
47+
A = zeros(Int,9,9,9); A[5,4:6,5] .= 1;
48+
blobs = blob_LoG(A,2 .^ [1.,0,0.5], σshape=(1.,3.,1.))
49+
@test length(blobs) == 1
50+
@test blobs[1].location == CartesianIndex((5,5,5))
51+
A = zeros(Int,9,9,9); A[1,1,4:6] .= 1;
52+
blobs = filter(b->b.amplitude > 0.1, blob_LoG(A, 2.0.^[0.5,0,1], edges=true, σshape=(1.,1.,3.)))
53+
@test length(blobs) == 1
54+
@test blobs[1].location == CartesianIndex((1,1,5))
55+
@test filter(b->b.amplitude > 0.1, blob_LoG(A, 2.0.^[0.5,0,1], edges=(true, true, true, false), σshape=(1.,1.,3.))) == blobs
56+
@test isempty(blob_LoG(A, 2.0.^[0,1], edges=(false, true, false, false), σshape=(1.,1.,3.)))
57+
@test length(blob_LoG([zeros(10); 1.0; 0.0], [4]; edges=true, rthresh=0)) > length(blob_LoG([zeros(10); 1.0; 0.0], [4]; edges=true))
58+
end
59+
end

test/runtests.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ include("cascade.jl")
2828
include("specialty.jl")
2929
include("gradient.jl")
3030
include("mapwindow.jl")
31+
include("extrema.jl")
3132
include("basic.jl")
3233
include("gabor.jl")
3334

0 commit comments

Comments
 (0)