Skip to content
Open
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,7 +1,7 @@
name = "GaussianMixtureAlignment"
uuid = "f2431ed1-b9c2-4fdb-af1b-a74d6c93b3b3"
authors = ["Tom McGrath <[email protected]> and contributors"]
version = "0.2.2"
version = "0.2.3"

[deps]
Colors = "5ae59095-9a9b-59fe-a467-6f913c188581"
Expand Down
4 changes: 3 additions & 1 deletion src/GaussianMixtureAlignment.jl
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ using Colors

export AbstractGaussian, AbstractGMM
export IsotropicGaussian, IsotropicGMM, IsotropicMultiGMM
export overlap, force!, gogma_align, rot_gogma_align, trl_gogma_align, tiv_gogma_align
export overlap, generic_overlap, gaussian_overlap, force!
export lowestlbblock, randomblock
export gogma_align, rot_gogma_align, trl_gogma_align, tiv_gogma_align
export rocs_align
export PointSet, MultiPointSet
export kabsch, icp, iterative_hungarian, goicp_align, goih_align, tiv_goicp_align, tiv_goih_align
Expand Down
28 changes: 14 additions & 14 deletions src/gogma/align.jl
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
function gogma_align(gmmx::AbstractGMM, gmmy::AbstractGMM; interactions=nothing, kwargs...)
function gogma_align(gmmx::AbstractGMM, gmmy::AbstractGMM; interactions=nothing, objective=gaussian_overlap, kwargs...)
pσ, pϕ = pairwise_consts(gmmx,gmmy,interactions)
boundsfun(x,y,block) = gauss_l2_bounds(x,y,block,pσ,pϕ)
localfun(x,y,block) = local_align(x,y,block,pσ,pϕ)
boundsfun(x,y,block) = generic_bounds(x,y,block,pσ,pϕ; objective=objective)
localfun(x,y,block) = local_align(x,y,block,pσ,pϕ; objective=objective)
return branchbound(gmmx, gmmy; boundsfun=boundsfun, localfun=localfun, kwargs...)
end
function rot_gogma_align(gmmx::AbstractGMM, gmmy::AbstractGMM; interactions=nothing, kwargs...)
function rot_gogma_align(gmmx::AbstractGMM, gmmy::AbstractGMM; interactions=nothing, objective=gaussian_overlap, kwargs...)
pσ, pϕ = pairwise_consts(gmmx,gmmy,interactions)
boundsfun(x,y,block) = gauss_l2_bounds(x,y,block,pσ,pϕ)
localfun(x,y,block) = local_align(x,y,block,pσ,pϕ)
boundsfun(x,y,block) = generic_bounds(x,y,block,pσ,pϕ; objective=objective)
localfun(x,y,block) = local_align(x,y,block,pσ,pϕ; objective=objective)
rot_branchbound(gmmx, gmmy; boundsfun=boundsfun, localfun=localfun, kwargs...)
end
function trl_gogma_align(gmmx::AbstractGMM, gmmy::AbstractGMM; interactions=nothing, kwargs...)
function trl_gogma_align(gmmx::AbstractGMM, gmmy::AbstractGMM; interactions=nothing, objective=gaussian_overlap, kwargs...)
pσ, pϕ = pairwise_consts(gmmx,gmmy,interactions)
boundsfun(x,y,block) = gauss_l2_bounds(x,y,block,pσ,pϕ)
localfun(x,y,block) = local_align(x,y,block,pσ,pϕ)
boundsfun(x,y,block) = generic_bounds(x,y,block,pσ,pϕ; objective=objective)
localfun(x,y,block) = local_align(x,y,block,pσ,pϕ; objective=objective)
trl_branchbound(gmmx, gmmy; boundsfun=boundsfun, localfun=localfun, kwargs...)
end
function tiv_gogma_align(gmmx::AbstractGMM, gmmy::AbstractGMM, cx=Inf, cy=Inf; kwargs...)
function tiv_gogma_align(gmmx::AbstractGMM, gmmy::AbstractGMM, cx=Inf, cy=Inf; objective=gaussian_overlap, kwargs...)
tivgmmx, tivgmmy = tivgmm(gmmx, cx), tivgmm(gmmy, cy)
pσ, pϕ = pairwise_consts(gmmx,gmmy)
tivpσ, tivpϕ = pairwise_consts(tivgmmx,tivgmmy)
boundsfun(x,y,block) = gauss_l2_bounds(x,y,block,pσ,pϕ)
rot_boundsfun(x,y,block) = gauss_l2_bounds(x,y,block,tivpσ,tivpϕ)
localfun(x,y,block) = local_align(x,y,block,pσ,pϕ)
rot_localfun(x,y,block) = local_align(x,y,block,tivpσ,tivpϕ)
boundsfun(x,y,block) = generic_bounds(x,y,block,pσ,pϕ; objective=objective)
rot_boundsfun(x,y,block) = generic_bounds(x,y,block,tivpσ,tivpϕ; objective=objective)
localfun(x,y,block) = local_align(x,y,block,pσ,pϕ; objective=objective)
rot_localfun(x,y,block) = local_align(x,y,block,tivpσ,tivpϕ; objective=objective)
tiv_branchbound(gmmx, gmmy, tivgmm(gmmx, cx), tivgmm(gmmy, cy); boundsfun=boundsfun, rot_boundsfun=rot_boundsfun, localfun=localfun, rot_localfun=rot_localfun, kwargs...)
end
57 changes: 40 additions & 17 deletions src/gogma/bounds.jl
Original file line number Diff line number Diff line change
Expand Up @@ -63,38 +63,40 @@ end


"""
lowerbound, upperbound = gauss_l2_bounds(x::Union{IsotropicGaussian, AbstractGMM}, y::Union{IsotropicGaussian, AbstractGMM}, σᵣ, σₜ)
lowerbound, upperbound = gauss_l2_bounds(x, y, R::RotationVec, T::SVector{3}, σᵣ, σₜ)
lowerbound, upperbound = generic_bounds(x::Union{IsotropicGaussian, AbstractGMM}, y::Union{IsotropicGaussian, AbstractGMM}, σᵣ, σₜ; objective = overlap)
lowerbound, upperbound = generic_bounds(x, y, R::RotationVec, T::SVector{3}, σᵣ, σₜ; objective = gaussian_overlap)

Finds the bounds for overlap between two isotropic Gaussian distributions, two isotropic GMMs, or `two sets of
Finds the bounds for the specified `objective` function between two isotropic Gaussian distributions, two isotropic GMMs, or two sets of
labeled isotropic GMMs for a particular region in 6-dimensional rigid rotation space, defined by `R`, `T`, `σᵣ` and `σₜ`.

`R` and `T` represent the rotation and translation, respectively, that are at the center of the uncertainty region. If they are not provided,
the uncertainty region is assumed to be centered at the origin (i.e. x has already been transformed).

`σᵣ` and `σₜ` represent the sizes of the rotation and translation uncertainty regions.

The `objective` should be a function that takes the squared distance between the means of two `IsotropicGaussian`s, the sum of their variances, and the product of their amplitudes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should spell out the exact arguments. From this I infer that the argument order is objective(Δμ, σ²sum, ϕprod), but best to be explicit.

Also, does objective need to satisfy certain requirements? E.g., does it have to be monotonic? (Lennard-Jones comes to mind.) Do you need to specialize any methods for your objective function? E.g., estimate_lower_bound(::typeof(lennardjones), Δμ, σ²sum, ϕprod). If there is an API that the user-supplied function needs to satisfy, it should be spelled out.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right that Lennard-Jones wouldn't work here without some careful thought -- maybe handling the attractive and repulsive terms separately.

The assumption is that the objective needs to be monotonically decreasing with increasing Δμ² (which is absolutely important to be clear about).


See [Campbell & Peterson, 2016](https://arxiv.org/abs/1603.00150)
"""
function gauss_l2_bounds(x::AbstractIsotropicGaussian, y::AbstractIsotropicGaussian, R::RotationVec, T::SVector{3}, σᵣ, σₜ, s=x.σ^2 + y.σ^2, w=x.ϕ*y.ϕ; distance_bound_fun = tight_distance_bounds)
function generic_bounds(x::AbstractIsotropicGaussian, y::AbstractIsotropicGaussian, R::RotationVec, T::SVector{3}, σᵣ, σₜ, s=x.σ^2 + y.σ^2, w=x.ϕ*y.ϕ; distance_bound_fun = tight_distance_bounds, objective = gauss_l2_bounds, kwargs...)
(lbdist, ubdist) = distance_bound_fun(R*x.μ, y.μ-T, σᵣ, σₜ, w < 0)

# evaluate objective function at each distance to get upper and lower bounds
return -overlap(lbdist^2, s, w), -overlap(ubdist^2, s, w)
return -objective(lbdist^2, s, w; kwargs...), -objective(ubdist^2, s, w; kwargs...)
end

# gauss_l2_bounds(x::AbstractGaussian, y::AbstractGaussian, R::RotationVec, T::SVector{3}, σᵣ, σₜ, s=x.σ^2 + y.σ^2, w=x.ϕ*y.ϕ; kwargs...
# ) = gauss_l2_bounds(R*x, y-T, σᵣ, σₜ, tform.translation, s, w; kwargs...)

gauss_l2_bounds(x::AbstractGaussian, y::AbstractGaussian, block::UncertaintyRegion, s=x.σ^2 + y.σ^2, w=x.ϕ*y.ϕ; kwargs...
) = gauss_l2_bounds(x, y, block.R, block.T, block.σᵣ, block.σₜ, s, w; kwargs...)
generic_bounds(x::AbstractGaussian, y::AbstractGaussian, block::UncertaintyRegion, s=x.σ^2 + y.σ^2, w=x.ϕ*y.ϕ; kwargs...
) = generic_bounds(x, y, block.R, block.T, block.σᵣ, block.σₜ, s, w; kwargs...)

gauss_l2_bounds(x::AbstractGaussian, y::AbstractGaussian, block::SearchRegion, s=x.σ^2 + y.σ^2, w=x.ϕ*y.ϕ; kwargs...
) = gauss_l2_bounds(x, y, UncertaintyRegion(block), s, w; kwargs...)
generic_bounds(x::AbstractGaussian, y::AbstractGaussian, block::SearchRegion, s=x.σ^2 + y.σ^2, w=x.ϕ*y.ϕ; kwargs...
) = generic_bounds(x, y, UncertaintyRegion(block), s, w; kwargs...)



function gauss_l2_bounds(gmmx::AbstractSingleGMM, gmmy::AbstractSingleGMM, R::RotationVec, T::SVector{3}, σᵣ::Number, σₜ::Number, pσ=nothing, pϕ=nothing, interactions=nothing; kwargs...)
function generic_bounds(gmmx::AbstractSingleGMM, gmmy::AbstractSingleGMM, R::RotationVec, T::SVector{3}, σᵣ::Number, σₜ::Number, pσ=nothing, pϕ=nothing, interactions=nothing; kwargs...)
# prepare pairwise widths and weights, if not provided
if isnothing(pσ) || isnothing(pϕ)
pσ, pϕ = pairwise_consts(gmmx, gmmy)
Expand All @@ -105,24 +107,28 @@ function gauss_l2_bounds(gmmx::AbstractSingleGMM, gmmy::AbstractSingleGMM, R::Ro
ub = 0.
for (i,x) in enumerate(gmmx.gaussians)
for (j,y) in enumerate(gmmy.gaussians)
lb, ub = (lb, ub) .+ gauss_l2_bounds(x, y, R, T, σᵣ, σₜ, pσ[i,j], pϕ[i,j]; kwargs...)
lb, ub = (lb, ub) .+ generic_bounds(x, y, R, T, σᵣ, σₜ, pσ[i,j], pϕ[i,j]; kwargs...)
end
end
return lb, ub
end

function gauss_l2_bounds(mgmmx::AbstractMultiGMM, mgmmy::AbstractMultiGMM, R::RotationVec, T::SVector{3}, σᵣ::Number, σₜ::Number, mpσ=nothing, mpϕ=nothing, interactions=nothing)
function generic_bounds(mgmmx::AbstractMultiGMM, mgmmy::AbstractMultiGMM, R::RotationVec, T::SVector{3}, σᵣ::Number, σₜ::Number, mpσ=nothing, mpϕ=nothing, interactions=nothing; objective=gaussian_overlap, kwargs...)
# prepare pairwise widths and weights, if not provided
if isnothing(mpσ) || isnothing(mpϕ)
mpσ, mpϕ = pairwise_consts(mgmmx, mgmmy, interactions)
end

# allow for different objective functions for each pair of keys
isdict = isa(objective, Dict)

# sum bounds for each pair of points
lb = 0.
ub = 0.
for (key1, intrs) in mpσ
for (key2, pσ) in intrs
lb, ub = (lb, ub) .+ gauss_l2_bounds(mgmmx.gmms[key1], mgmmy.gmms[key2], R, T, σᵣ, σₜ, pσ, mpϕ[key1][key2])
obj = !isdict ? objective : (haskey(objective, (key1,key2)) ? objective[(key1,key2)] : objective[(key2,key1)])
lb, ub = (lb, ub) .+ generic_bounds(mgmmx.gmms[key1], mgmmy.gmms[key2], R, T, σᵣ, σₜ, pσ, mpϕ[key1][key2]; objective = obj, kwargs...)
Comment on lines +130 to +131
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If profiling reveals this line to be a bottleneck, here's one way that might help a tiny bit:

Suggested change
obj = !isdict ? objective : (haskey(objective, (key1,key2)) ? objective[(key1,key2)] : objective[(key2,key1)])
lb, ub = (lb, ub) .+ generic_bounds(mgmmx.gmms[key1], mgmmy.gmms[key2], R, T, σᵣ, σₜ, pσ, mpϕ[key1][key2]; objective = obj, kwargs...)
if !isdict
lb, ub = (lb, ub) .+ generic_bounds(mgmmx.gmms[key1], mgmmy.gmms[key2], R, T, σᵣ, σₜ, pσ, mpϕ[key1][key2]; objective, kwargs...) # this call *might* be inferrable
else
obj = get(objective, (key1, key2), nothing)
if obj === nothing
obj = get(objective, (key2, key1), nothing)
end
if obj !== nothing
lb, ub = (lb, ub) .+ generic_bounds(mgmmx.gmms[key1], mgmmy.gmms[key2], R, T, σᵣ, σₜ, pσ, mpϕ[key1][key2]; objective = obj, kwargs...) # this call is not
end

The important part of this is the !isdict case, which gives Julia a chance to "pass down" knowledge of typeof(objective) from the input arguments to the call to generic_bounds which can be specialized for objective=objective. For the second one, if you know (or can compute) the return type of generic_bounds then you might want to add a type-annotation, e.g., generic_bounds(args...; kwargs...)::Tuple{T,T} so that lb, ub has known type even if Julia can't infer its way through the entire call.

The other part of the change is vastly less important, but exploits the fact that

haskey(dict, a) ? dict[a] : nothing

involves looking up the key a twice, whereas

get(dict, a, nothing)

only looks up the key a once. Note that while nothing is a conventional default, if nothing is in fact a legitimate user-supplied value in the dictionary, you can ensure there's no ambiguity about whether the key was present in the dictionary as follows:

struct NotFound end    # a private type for internal use only
const notfound = NotFound()

x = get(dict, key, notfound)
if x !== notfound
    ...

Then there's no way that notfound was retrieved from dict (or if it is, it's clearly the user's fault).

You probably have enough places in your code that might check both orders of the keys that it might be worth splitting the double-get block into a utility function.

end
end
return lb, ub
Expand All @@ -131,8 +137,25 @@ end
# gauss_l2_bounds(x::AbstractGMM, y::AbstractGMM, R::RotationVec, T::SVector{3}, args...; kwargs...
# ) = gauss_l2_bounds(R*x, y-T, args...; kwargs...)

gauss_l2_bounds(x::AbstractGMM, y::AbstractGMM, block::UncertaintyRegion, args...; kwargs...
) = gauss_l2_bounds(x, y, block.R, block.T, block.σᵣ, block.σₜ, args...; kwargs...)
generic_bounds(x::AbstractGMM, y::AbstractGMM, block::UncertaintyRegion, args...; kwargs...
) = generic_bounds(x, y, block.R, block.T, block.σᵣ, block.σₜ, args...; kwargs...)

generic_bounds(x::AbstractGMM, y::AbstractGMM, block::SearchRegion, args...; kwargs...
) = generic_bounds(x, y, UncertaintyRegion(block), args...; kwargs...)


"""
lowerbound, upperbound = gauss_l2_bounds(x::Union{IsotropicGaussian, AbstractGMM}, y::Union{IsotropicGaussian, AbstractGMM}, σᵣ, σₜ)
lowerbound, upperbound = gauss_l2_bounds(x, y, R::RotationVec, T::SVector{3}, σᵣ, σₜ)

gauss_l2_bounds(x::AbstractGMM, y::AbstractGMM, block::SearchRegion, args...; kwargs...
) = gauss_l2_bounds(x, y, UncertaintyRegion(block), args...; kwargs...)
Finds the bounds for overlap between two isotropic Gaussian distributions, two isotropic GMMs, or two sets of
labeled isotropic GMMs for a particular region in 6-dimensional rigid rotation space, defined by `R`, `T`, `σᵣ` and `σₜ`.

`R` and `T` represent the rotation and translation, respectively, that are at the center of the uncertainty region. If they are not provided,
the uncertainty region is assumed to be centered at the origin (i.e. x has already been transformed).

`σᵣ` and `σₜ` represent the sizes of the rotation and translation uncertainty regions.

See [Campbell & Peterson, 2016](https://arxiv.org/abs/1603.00150)
"""
gauss_l2_bounds(args...; kwargs...) = generic_bounds(args...; objective = gaussian_overlap, kwargs...)
52 changes: 21 additions & 31 deletions src/gogma/overlap.jl
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,21 @@
Calculates the unnormalized overlap between two Gaussian distributions with width `s`,
weight `w', and squared distance `distsq`.
"""
function overlap(distsq::Real, s::Real, w::Real)
function gaussian_overlap(distsq::Real, s::Real, w::Real)
return w * exp(-distsq / (2*s)) # / (sqrt2pi * sqrt(s))^ndims
# Note, the normalization term for the Gaussians is left out, since it is not required that the total "volume" of each Gaussian
# is equal to 1 (e.g. satisfying the requirements for a probability distribution)
end

"""
ovlp = overlap(dist, σx, σy, ϕx, ϕy)

Calculates the unnormalized overlap between two Gaussian distributions with variances
`σx` and `σy`, weights `ϕx` and `ϕy`, and means separated by distance `dist`.
"""
function overlap(dist::Real, σx::Real, σy::Real, ϕx::Real, ϕy::Real)
return overlap(dist^2, σx^2 + σy^2, ϕx*ϕy)
function generic_overlap(dist::Real, σx::Real, σy::Real, ϕx::Real, ϕy::Real; objective=gaussian_overlap, kwargs...)
return objective(dist^2, σx^2 + σy^2, ϕx*ϕy; kwargs...)
end

"""
ovlp = overlap(x::IsotropicGaussian, y::IsotropicGaussian)

Calculates the unnormalized overlap between two `IsotropicGaussian` objects.
"""
function overlap(x::AbstractIsotropicGaussian, y::AbstractIsotropicGaussian, s=x.σ^2+y.σ^2, w=x.ϕ*y.ϕ)
return overlap(sum(abs2, x.μ.-y.μ), s, w)
function generic_overlap(x::AbstractIsotropicGaussian, y::AbstractIsotropicGaussian, s=x.σ^2+y.σ^2, w=x.ϕ*y.ϕ; objective=gaussian_overlap, kwargs...)
return objective(sum(abs2, x.μ.-y.μ), s, w; kwargs...)
end

"""
ovlp = overlap(x::AbstractSingleGMM, y::AbstractSingleGMM)

Calculates the unnormalized overlap between two `AbstractSingleGMM` objects.
"""
function overlap(x::AbstractSingleGMM, y::AbstractSingleGMM, pσ=nothing, pϕ=nothing)
function generic_overlap(x::AbstractSingleGMM, y::AbstractSingleGMM, pσ=nothing, pϕ=nothing; kwargs...)
# prepare pairwise widths and weights, if not provided
if isnothing(pσ) && isnothing(pϕ)
pσ, pϕ = pairwise_consts(x, y)
Expand All @@ -44,33 +28,39 @@ function overlap(x::AbstractSingleGMM, y::AbstractSingleGMM, pσ=nothing, pϕ=no
ovlp = zero(promote_type(numbertype(x),numbertype(y)))
for (i,gx) in enumerate(x.gaussians)
for (j,gy) in enumerate(y.gaussians)
ovlp += overlap(gx, gy, pσ[i,j], pϕ[i,j])
ovlp += generic_overlap(gx, gy, pσ[i,j], pϕ[i,j]; kwargs...)
end
end
return ovlp
end

"""
ovlp = overlap(x::AbstractMultiGMM, y::AbstractMultiGMM)

Calculates the unnormalized overlap between two `AbstractMultiGMM` objects.
"""
function overlap(x::AbstractMultiGMM, y::AbstractMultiGMM, mpσ=nothing, mpϕ=nothing, interactions=nothing)
function generic_overlap(x::AbstractMultiGMM, y::AbstractMultiGMM, mpσ=nothing, mpϕ=nothing, interactions=nothing; objective=gaussian_overlap, kwargs...)
# prepare pairwise widths and weights, if not provided
if isnothing(mpσ) && isnothing(mpϕ)
mpσ, mpϕ = pairwise_consts(x, y, interactions)
end

isdict = isa(objective, Dict)

# sum overlaps from each keyed pairs of GMM
ovlp = zero(promote_type(numbertype(x),numbertype(y)))
for k1 in keys(mpσ)
for k2 in keys(mpσ[k1])
ovlp += overlap(x.gmms[k1], y.gmms[k2], mpσ[k1][k2], mpϕ[k1][k2])
obj = !isdict ? objective : (haskey(objective, (k1,k2)) ? objective[(k1,k2)] : objective[(k2,k1)])
ovlp += generic_overlap(x.gmms[k1], y.gmms[k2], mpσ[k1][k2], mpϕ[k1][k2]; objective = obj, kwargs...)
end
end
return ovlp
end

"""
ovlp = overlap(x::G, y::G) where G<:Union{AbstractGaussian, AbstractGMM}

Calculates the unnormalized overlap between two Gaussians or GMMs.
"""
overlap(args...; kwargs...) = generic_overlap(args...; objective=gaussian_overlap, kwargs...)


"""
l2dist = distance(x, y)

Expand All @@ -94,7 +84,7 @@ end

function force!(f::AbstractVector, x::AbstractVector, y::AbstractVector, s::Real, w::Real)
Δ = y - x
f .+= Δ / s * overlap(sum(abs2, Δ), s, w)
f .+= Δ / s * gaussian_overlap(sum(abs2, Δ), s, w)
end

function force!(f::AbstractVector, x::AbstractIsotropicGaussian, y::AbstractIsotropicGaussian,
Expand Down
6 changes: 3 additions & 3 deletions src/localalign.jl
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ tformwithparams(X,x) = RotationVec(X[1:3]...)*x + SVector{3}(X[4:6]...)
# return R*x + t
# end

overlapobj(X,x,y,args...) = -overlap(tformwithparams(X,x), y, args...)
overlapobj(X,x,y,args...; kwargs...) = -generic_overlap(tformwithparams(X,x), y, args...; kwargs...)

function distanceobj(X, x, y; correspondence = hungarian_assignment)
tformedx = tformwithparams(X,x)
return squared_deviation(tformedx, y, correspondence(tformedx,y))
end

function alignment_objective(X, x::AbstractModel, y::AbstractModel, args...; objfun=overlapobj)
return objfun(X,x,y,args...)
function alignment_objective(X, x::AbstractModel, y::AbstractModel, args...; objfun=overlapobj, kwargs...)
return objfun(X,x,y,args...; kwargs...)
end

# alignment objective for a rigid transformation
Expand Down
Loading