Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name = "RegisterFit"
uuid = "36121b08-3789-5198-aff2-59a3443d9b59"
version = "1.0.0"
version = "1.0.1"
authors = ["Tim Holy <tim.holy@gmail.com>"]

[deps]
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ using RegisterFit
fixed = zeros(5, 7); fixed[3, 2:6] .= 1.0
moving = zeros(7, 5); moving[2:6, 3] .= 1.0

tfms = pat_rotation(fixed, moving) # 2 candidate AffineMap transforms in 2D
tfms = principalaxes_rotation(fixed, moving) # 2 candidate AffineMap transforms in 2D
# Evaluate each candidate against mismatch data and pick the best
```

Expand Down
4 changes: 3 additions & 1 deletion docs/src/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@

```@docs
mismatch2affine
pat_rotation
principalaxes_rotation
optimize_per_aperture
```

## Quadratic fitting

```@docs
qfit
mms2fit
mms2fit!
qbuild
```
Expand All @@ -20,6 +21,7 @@ qbuild

```@docs
uisvalid
uclamp
uclamp!
principalaxes
```
6 changes: 3 additions & 3 deletions docs/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ result is an `AffineMap` from

### Principal Axes Transform

[`pat_rotation`](@ref) provides a complementary rigid-alignment approach that
[`principalaxes_rotation`](@ref) provides a complementary rigid-alignment approach that
matches the intensity-weighted covariance ellipsoids of two images. It is useful
as an initial guess before running the quadratic optimization. Because an ellipsoid
is symmetric, there are 2 candidate rotations in 2D and 4 in 3D; you must evaluate
Expand All @@ -74,7 +74,7 @@ cs, Qs, mmis = mms2fit!(mms, thresh)
tform = mismatch2affine(mms, thresh, knots)

# 4. (Optional) rigid pre-alignment with PAT
candidates = pat_rotation(fixed, moving)
candidates = principalaxes_rotation(fixed, moving)
# … pick best candidate, then refine with mismatch2affine / RegisterDeformation
```

Expand All @@ -100,6 +100,6 @@ using RegisterFit
fixed = zeros(5, 7); fixed[3, 2:6] .= 1.0 # horizontal bar
moving = zeros(7, 5); moving[2:6, 3] .= 1.0 # vertical bar

tfms = pat_rotation(fixed, moving) # 2 candidate AffineMap transforms
tfms = principalaxes_rotation(fixed, moving) # 2 candidate AffineMap transforms
# Select the candidate with the smallest mismatch
```
62 changes: 42 additions & 20 deletions src/RegisterFit.jl
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@ import Base.Cartesian: @nloops, @nexprs, @nref, @nif

export
mismatch2affine,
mms2fit,
mms2fit!,
optimize_per_aperture,
pat_rotation,
principalaxes,
principalaxes_rotation,
qbuild,
qfit,
uisvalid,
uclamp!
uclamp,
uclamp!,
uisvalid

"""
RegisterFit provides functions that compute affine transformations minimizing
Expand All @@ -30,7 +32,7 @@ image registration mismatch, given per-aperture mismatch data from `RegisterMism
## Global optimization

- [`mismatch2affine`](@ref): affine transform from mismatch data by least squares
- [`pat_rotation`](@ref): rigid alignment via a Principal Axes Transformation
- [`principalaxes_rotation`](@ref): rigid alignment via a Principal Axes Transformation
- [`optimize_per_aperture`](@ref): naive per-aperture displacement search

## Utilities
Expand Down Expand Up @@ -225,7 +227,7 @@ julia> r[0, 0]
5.0
```
"""
function qbuild(E0::Real, umin::Vector, Q::Matrix, maxshift)
function qbuild(E0::Real, umin::AbstractVector, Q::AbstractMatrix, maxshift::Union{AbstractVector,Tuple})
d = length(maxshift)
(size(Q, 1) == d && size(Q, 2) == d && length(umin) == d) || error("Size mismatch")
szout = ((2 * [maxshift...] .+ 1)...,)
Expand Down Expand Up @@ -261,7 +263,7 @@ julia> uisvalid([2.5, 0.5], (3, 3))
false
```
"""
function uisvalid(u::AbstractArray{T}, maxshift) where {T <: Number}
function uisvalid(u::AbstractArray{T}, maxshift::Union{AbstractVector,Tuple}) where {T <: Number}
nd = size(u, 1)
sztail = size(u)[2:end]
for j in CartesianIndices(sztail), idim in 1:nd
Expand Down Expand Up @@ -292,7 +294,7 @@ julia> uclamp!(u, (3, 3))
-2.49
```
"""
function uclamp!(u::AbstractArray{T}, maxshift) where {T <: Number}
function uclamp!(u::AbstractArray{T}, maxshift::Union{AbstractVector,Tuple}) where {T <: Number}
nd = size(u, 1)
sztail = size(u)[2:end]
for j in CartesianIndices(sztail), idim in 1:nd
Expand All @@ -301,11 +303,19 @@ function uclamp!(u::AbstractArray{T}, maxshift) where {T <: Number}
return u
end

function uclamp!(u::AbstractArray{T}, maxshift) where {T <: StaticVector}
function uclamp!(u::AbstractArray{T}, maxshift::Union{AbstractVector,Tuple}) where {T <: StaticVector}
uclamp!(reshape(reinterpret(eltype(T), vec(u)), (length(T), size(u)...)), maxshift)
return u
end

"""
uclamp(u, maxshift)

Non-mutating counterpart to [`uclamp!`](@ref). Returns a clamped copy of `u`;
the original is unmodified.
"""
uclamp(u::AbstractArray, maxshift::Union{AbstractVector,Tuple}) = uclamp!(copy(u), maxshift)

"""
center, cov = principalaxes(img)

Expand Down Expand Up @@ -379,10 +389,10 @@ end
end

"""
tfms = pat_rotation(fixed, moving)
tfms = pat_rotation(fixed, moving, SD)
tfms = pat_rotation(fixedpa, moving)
tfms = pat_rotation(fixedpa, moving, SD)
tfms = principalaxes_rotation(fixed, moving)
tfms = principalaxes_rotation(fixed, moving, SD)
tfms = principalaxes_rotation(fixedpa, moving)
tfms = principalaxes_rotation(fixedpa, moving, SD)

Compute the Principal Axes Transform (PAT) aligning the low-order intensity
moments of two images. `fixed` is the reference image and `moving` is the image
Expand All @@ -406,7 +416,7 @@ julia> fixed = zeros(5, 7); fixed[3, 2:6] .= 1.0; # horizontal bar

julia> moving = zeros(7, 5); moving[2:6, 3] .= 1.0; # vertical bar

julia> tfms = pat_rotation(fixed, moving);
julia> tfms = principalaxes_rotation(fixed, moving);

julia> length(tfms)
2
Expand All @@ -417,7 +427,7 @@ julia> tfms[1].linear # ≈ 90° rotation
-1.0 0.0
```
"""
function pat_rotation(
function principalaxes_rotation(
fixedmoments::Tuple{Vector, Matrix}, moving::AbstractArray,
SD = Matrix{Float64}(I, ndims(moving), ndims(moving))
)
Expand Down Expand Up @@ -463,8 +473,8 @@ function pat_rotation(
return tfms
end

pat_rotation(fixed::AbstractArray, moving::AbstractArray, SD = Matrix{Float64}(I, ndims(fixed), ndims(fixed))) =
pat_rotation(principalaxes(fixed), moving, SD)
principalaxes_rotation(fixed::AbstractArray, moving::AbstractArray, SD = Matrix{Float64}(I, ndims(fixed), ndims(fixed))) =
principalaxes_rotation(principalaxes(fixed), moving, SD)

function pat_at(S, SD, c, fmean, mmean)
Sp = SD \ (S * SD)
Expand Down Expand Up @@ -527,6 +537,8 @@ exist, returns `(zero(T), zeros(T, d), zeros(T, d, d))`.
`maxsep` restricts the fit to shifts satisfying `|u[d] - u0[d]| ≤ maxsep[d]`.
Setting `opt=false` uses a fast heuristic for `Q` instead of a full nonlinear
solve, trading accuracy for speed.
`solver_kwargs` is a `NamedTuple` of keyword arguments forwarded to `NLsolve.nlsolve`
when `opt=true` (e.g. `solver_kwargs=(iterations=100, ftol=1e-10)`).

# Returns
- `E0::T` — mismatch value at the fitted minimum
Expand Down Expand Up @@ -555,11 +567,11 @@ julia> Q ≈ [1.0 0.0; 0.0 1.0]
true
```
"""
function qfit(mm::MismatchArray, thresh::Real; maxsep = size(mm), opt::Bool = true)
return qfit(mm, thresh, maxsep, opt)
function qfit(mm::MismatchArray, thresh::Real; maxsep = size(mm), opt::Bool = true, solver_kwargs = (;))
return _qfit(mm, thresh, maxsep, opt; solver_kwargs)
end

function qfit(mm::MismatchArray, thresh::Real, maxsep, opt::Bool)
function _qfit(mm::MismatchArray, thresh::Real, maxsep, opt::Bool; solver_kwargs = (;))
T = eltype(eltype(mm))
threshT = convert(T, thresh)
d = ndims(mm)
Expand Down Expand Up @@ -621,7 +633,7 @@ function qfit(mm::MismatchArray, thresh::Real, maxsep, opt::Bool)
end
local results
function solveql(C, dE, QL, x)
return nlsolve((fx, x) -> QLerr!(x, fx, C, dE, similar(QL)), (gx, x) -> QLjac!(x, gx, C, similar(QL)), x)
return nlsolve((fx, x) -> QLerr!(x, fx, C, dE, similar(QL)), (gx, x) -> QLjac!(x, gx, C, similar(QL)), x; solver_kwargs...)
end
try
results = solveql(C, dE, QL, x)
Expand Down Expand Up @@ -686,6 +698,16 @@ function mms2fit!(mms::AbstractArray{A, N}, thresh) where {A <: MismatchArray, N
return cs, Qs, mmis
end

"""
mms2fit(mms, thresh)

Non-mutating counterpart to [`mms2fit!`](@ref). Returns the same `(cs, Qs, mmis)` tuple
without modifying `mms`. Uses `deepcopy` because `interpolate_mm!` writes B-spline
coefficients into the underlying data of each `MismatchArray` element in-place.
"""
mms2fit(mms::AbstractArray{A, N}, thresh) where {A <: MismatchArray, N} =
mms2fit!(deepcopy(mms), thresh)

function unpackL!(QL, x)
d = size(QL, 1)
indx = 0
Expand Down
48 changes: 45 additions & 3 deletions test/runtests.jl
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import RegisterFit
using Test, Aqua, ExplicitImports, Documenter, CoordinateTransformations, Interpolations, ImageBase, ImageTransformations, LinearAlgebra
using RegisterCore
using RegisterCore, StaticArrays

using RegisterUtilities

Expand Down Expand Up @@ -74,6 +74,10 @@ end
v1 = 0.3*((-5:5).+1).^2
v2 = 0.5*((-5:5).-1).^2
@test A.data ≈ v1.+v2'.+2

# accepts StaticArrays (as produced by mms2fit!)
A2 = RegisterFit.qbuild(2, SVector(-1.0, 1.0), SMatrix{2,2}(0.3, 0.0, 0.0, 0.5), (5,5))
@test A2.data ≈ A.data
end

@testset "uisvalid and uclamp!" begin
Expand All @@ -89,6 +93,18 @@ end
@test abs(u_clamp[1]) < maxshift[1]
@test abs(u_clamp[2]) < maxshift[2]
@test abs(u_clamp[3]) < maxshift[3]

# scalar maxshift must be rejected (Union{AbstractVector,Tuple} annotation)
@test_throws MethodError RegisterFit.uisvalid(u_valid, 3)
@test_throws MethodError RegisterFit.uclamp!(copy(u_clamp), 3)

# non-mutating uclamp returns clamped copy, leaves original intact
u_orig = reshape([5.0, -6.0, 0.1], 3, 1)
u_snapshot = copy(u_orig)
u_result = RegisterFit.uclamp(u_orig, maxshift)
@test u_result !== u_orig
@test u_orig == u_snapshot
@test u_result == RegisterFit.uclamp!(copy(u_snapshot), maxshift)
end

@testset "qfit edge cases" begin
Expand All @@ -99,6 +115,16 @@ end
@test E0 == 0
@test all(c .== 0)
@test all(Q .== 0)

# solver_kwargs forwarded to nlsolve (iterations=1 forces early exit but must not error)
denom2 = ones(11, 11)
Qmat = rand(Float64, 2, 2); Qmat = Qmat'*Qmat
num2 = quadratic(11, 11, [1, -2], Qmat)
@test_nowarn RegisterFit.qfit(MismatchArray(num2, denom2), 1e-3; solver_kwargs=(iterations=1,))

# positional 4-arg form no longer exists (Breaking: CHUNK-007)
mm2 = MismatchArray(num2, denom2)
@test_throws MethodError RegisterFit.qfit(mm2, 1e-3, size(mm2), false)
end

@testset "optimize_per_aperture" begin
Expand Down Expand Up @@ -128,6 +154,22 @@ end
@test cs[2, 1] ≈ [0.0, 1.0] atol=1e-10
end

@testset "mms2fit" begin
Q = [1.0 0; 0 1.0]
mm1 = MismatchArray(quadratic(5, 5, [1, -1], Q), ones(5, 5))
mm2 = MismatchArray(quadratic(5, 5, [0, 1], Q), ones(5, 5))
mm3 = MismatchArray(quadratic(5, 5, [-1, 0], Q), ones(5, 5))
mm4 = MismatchArray(quadratic(5, 5, [1, 0], Q), ones(5, 5))
mms = reshape([mm1, mm2, mm3, mm4], 2, 2)
# snapshot raw data before call; interpolate_mm! writes B-spline coefficients in-place
data_before = copy(mms[1, 1].data)
cs, Qs, mmis = RegisterFit.mms2fit(mms, 0.5)
@test mms[1, 1].data == data_before # original unmodified
@test size(cs) == (2, 2)
@test cs[1, 1] ≈ [1.0, -1.0] atol=1e-10 # same results as mms2fit!
@test cs[2, 1] ≈ [0.0, 1.0] atol=1e-10
end

@testset "PAT" begin
# Principal Axes Transformation
fixed = zeros(10,11)
Expand All @@ -137,7 +179,7 @@ end
moving[3:7,8] .= 1
moving[2:8,7] .= 1
fmean, fvar = RegisterFit.principalaxes(fixed)
tfm = RegisterFit.pat_rotation((fmean, fvar), moving)
tfm = RegisterFit.principalaxes_rotation((fmean, fvar), moving)
for i = 1:2
S = tfm[i].linear
@test abs(S[1,2]) ≈ 1
Expand All @@ -147,7 +189,7 @@ end
end

# Test the array-input convenience wrapper
tfms2 = RegisterFit.pat_rotation(fixed, moving)
tfms2 = RegisterFit.principalaxes_rotation(fixed, moving)
@test length(tfms2) == length(tfm)
for i in eachindex(tfm)
@test tfms2[i].linear ≈ tfm[i].linear
Expand Down
Loading