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
143 changes: 143 additions & 0 deletions src/spatialdata/transformations/transformations.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
from warnings import warn

import numpy as np
import scipy
import xarray as xr
from xarray import DataArray

Expand DownExpand Up@@ -819,6 +820,148 @@ def _decompose_affine_into_linear_and_translation(affine: Affine) -> tuple[Affin
return linear_transformation, translation_transformation


def _compose_affine_from_linear_and_translation(
linear: ArrayLike, translation: ArrayLike, input_axes: tuple[ValidAxis_t, ...], output_axes: tuple[ValidAxis_t, ...]
) -> Affine:
matrix = np.zeros((linear.shape[0] + 1, linear.shape[1] + 1))
matrix[:-1, :-1] = linear
matrix[:-1, -1] = translation
matrix[-1, -1] = 1
return Affine(matrix, input_axes=input_axes, output_axes=output_axes)


def _decompose_transformation(
transformation: BaseTransformation, input_axes: tuple[ValidAxis_t, ...], simple_decomposition: bool = True
) -> Sequence:
"""
Decompose a given 2D transformation into a sequence of predetermined types of transformations.

Parameters
----------
transformation
The transformation to decompose. It is assumed to be of a type that can be represented as a single affine
transformation. It should leave the input axes unmodified, and it should not transform the c channel, if this
is present.
input_axes
The axes of the data the transformation is to be applied to
simple_decomposition
If true, decomposes a transformation into it's linear part (affine without translation) and translation part,
otherwise decomposes it into a sequence of reflection, rotation, shear, scale, translation.

Returns
-------
sequence
Returns a sequence of transformations (class :class:`~spatialdata.transformations.Sequence`) which operates only
on the spatial part (no c channel). The output sequence will contain either 2 either 5 transformations in the
following order (the first is applied first).
Case `simple_decomposition = True`.

1. Linear part (affine): linear part of the affine transformation, represented as a
:class:`~spatialdata.transformations.Affine` transformation.
2. Translation. Represented as a :class:`~spatialdata.transformations.Translation` transformation.

Case `simple_decomposition = False`.

1. Reflection. Represented as :class:`~spatialdata.transformations.Scale` transformation with elements in
{1, -1}.
2. Rotation. Represented as an :class:`~spatialdata.transformations.Affine` transformation which in its
matrix form presents itself as an homogeneous affine matrix with no translation part and determinant 1.
Please look at the source code of this function if you need to recover the angle theta.
3. Shear. Represented as an :class:`~spatialdata.transformations.Affine` transformation which in its matrix
form presents itself as an homogeneous affine matrix with no translation part. The matrix is upper
triangular with diagonal elements all equal to 1.
4. Scale. Represented as a :class:`~spatialdata.transformations.Scale` transformation with positive
elements.
5. Translation. Represented as a :class:`~spatialdata.transformations.Translation` transformation.

Note that some of these transformations may be identity transformations.
"""
output_axes = _get_current_output_axes(transformation=transformation, input_axes=input_axes)
if input_axes != output_axes:
raise ValueError("The transformation should leave the input axes unmodified.")
if "z" in input_axes:
raise ValueError("The transformation should not transform the z axis.")
affine = transformation.to_affine(input_axes=input_axes, output_axes=output_axes)
matrix = affine.matrix
if "c" in input_axes:
c_index = input_axes.index("c")
if (
matrix[c_index, c_index] != 1
or np.linalg.norm(matrix[c_index, :]) != 1
or np.linalg.norm(matrix[:, c_index]) != 1
):
raise ValueError("The transformation should not transform the c channel.")
axes = input_axes[:c_index] + input_axes[c_index + 1 :]
m = np.delete(matrix, c_index, 0)
m = np.delete(m, c_index, 1)
else:
axes = input_axes
m = matrix

translation_part = m[:-1, -1]
linear_part = m[:-1, :-1]

if simple_decomposition:
translation = Translation(translation_part, axes=axes)
linear = _compose_affine_from_linear_and_translation(
linear=linear_part,
translation=np.zeros(linear_part.shape[0]),
input_axes=axes,
output_axes=axes,
)
sequence = Sequence([linear, translation])
else:
# qr factorization
a = linear_part
r, q = scipy.linalg.rq(a)

theta = np.arctan2(q[1, 0], q[0, 0])
rotation_matrix = np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]])

scale_matrix = np.diag(np.abs(np.diag(r)))
shear_matrix = np.linalg.inv(scale_matrix) @ r
assert np.allclose(scale_matrix @ shear_matrix, r)
d = np.diag(np.diag(shear_matrix))

qq = rotation_matrix.T @ q
# check that qq is a diagonal matrix with diagonal values in {-1, 1}
assert np.allclose(np.diag(qq) ** 2, np.ones(qq.shape[0]))
assert np.isclose(np.sum(np.abs(qq.ravel())), qq.shape[0])
assert np.allclose(rotation_matrix @ qq, q)

adjusted_shear_matrix = shear_matrix @ d
adjusted_rotation_matrix = d @ rotation_matrix @ d
assert np.allclose(
adjusted_rotation_matrix @ adjusted_rotation_matrix.T, np.eye(adjusted_rotation_matrix.shape[0])
)
adjusted_qq = d @ qq

aaa = scale_matrix @ shear_matrix @ d @ d @ rotation_matrix @ d @ d @ qq
assert np.allclose(a, aaa)
aa = scale_matrix @ adjusted_shear_matrix @ adjusted_rotation_matrix @ adjusted_qq
assert np.allclose(a, aa)

scale = Scale(np.diag(scale_matrix), axes=axes)
shear = _compose_affine_from_linear_and_translation(
linear=adjusted_shear_matrix,
translation=np.zeros(shear_matrix.shape[0]),
input_axes=axes,
output_axes=axes,
)
rotation = _compose_affine_from_linear_and_translation(
linear=adjusted_rotation_matrix,
translation=np.zeros(rotation_matrix.shape[0]),
input_axes=axes,
output_axes=axes,
)
inversion = Scale(np.diag(adjusted_qq), axes=axes)
translation = Translation(translation_part, axes=axes)
sequence = Sequence([inversion, rotation, shear, scale, translation])
check_m = sequence.to_affine_matrix(input_axes=input_axes, output_axes=input_axes)
assert np.allclose(check_m, matrix)
return sequence


TRANSFORMATIONS_MAP[NgffIdentity] = Identity
TRANSFORMATIONS_MAP[NgffMapAxis] = MapAxis
TRANSFORMATIONS_MAP[NgffTranslation] = Translation
Expand Down
122 changes: 122 additions & 0 deletions tests/transformations/test_transformations.py
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
from contextlib import nullcontext
from copy import deepcopy

import numpy as np
Expand DownExpand Up@@ -29,6 +30,7 @@
Sequence,
Translation,
_decompose_affine_into_linear_and_translation,
_decompose_transformation,
_get_affine_for_element,
)
from xarray import DataArray
Expand DownExpand Up@@ -783,6 +785,126 @@ def test_decompose_affine_into_linear_and_translation():
assert np.allclose(translation.translation, np.array([10, 11]))


@pytest.mark.parametrize(
"matrix,input_axes,output_axes,valid",
[
# non-square matrix are not supported
(
np.array(
[
[1, 2, 3, 10],
[4, 5, 6, 11],
[0, 0, 0, 1],
]
),
("x", "y", "z"),
("x", "y"),
False,
),
(
np.array(
[
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
[0, 0, 1],
]
),
("x", "y"),
("x", "y", "z"),
False,
),
# z axis should not be present
(
np.array(
[
[1, 2, 3, 10],
[4, 5, 6, 11],
[7, 8, 9, 12],
[0, 0, 0, 1],
]
),
("x", "y", "z"),
("x", "y", "z"),
False,
),
# c channel is modified
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[8, 9, 1, 10],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[0, 0, 0, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
(
np.array(
[
[1, 2, 3, 4],
[4, 5, 6, 7],
[0, 0, 1, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
# valid, no c channel
(
np.array(
[
[1, 2, 3],
[4, 5, 6],
[0, 0, 1],
]
),
("x", "y"),
("x", "y"),
True,
),
# valid, c channel
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[0, 0, 1, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
True,
),
],
)
@pytest.mark.parametrize("simple_decomposition", [True, False])
def test_decompose_transformation(matrix, input_axes, output_axes, valid, simple_decomposition):
affine = Affine(matrix, input_axes=input_axes, output_axes=output_axes)
context = nullcontext() if valid else pytest.raises(ValueError)
with context:
_ = _decompose_transformation(affine, input_axes=input_axes, simple_decomposition=simple_decomposition)


def test_assign_xy_scale_to_cyx_image():
scale = Scale(np.array([2, 3]), axes=("x", "y"))
image = Image2DModel.parse(np.zeros((10, 10, 10)), dims=("c", "y", "x"))
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
143 changes: 143 additions & 0 deletions src/spatialdata/transformations/transformations.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
from warnings import warn

import numpy as np
import scipy
import xarray as xr
from xarray import DataArray

Expand DownExpand Up@@ -819,6 +820,148 @@ def _decompose_affine_into_linear_and_translation(affine: Affine) -> tuple[Affin
return linear_transformation, translation_transformation


def _compose_affine_from_linear_and_translation(
linear: ArrayLike, translation: ArrayLike, input_axes: tuple[ValidAxis_t, ...], output_axes: tuple[ValidAxis_t, ...]
) -> Affine:
matrix = np.zeros((linear.shape[0] + 1, linear.shape[1] + 1))
matrix[:-1, :-1] = linear
matrix[:-1, -1] = translation
matrix[-1, -1] = 1
return Affine(matrix, input_axes=input_axes, output_axes=output_axes)


def _decompose_transformation(
transformation: BaseTransformation, input_axes: tuple[ValidAxis_t, ...], simple_decomposition: bool = True
) -> Sequence:
"""
Decompose a given 2D transformation into a sequence of predetermined types of transformations.

Parameters
----------
transformation
The transformation to decompose. It is assumed to be of a type that can be represented as a single affine
transformation. It should leave the input axes unmodified, and it should not transform the c channel, if this
is present.
input_axes
The axes of the data the transformation is to be applied to
simple_decomposition
If true, decomposes a transformation into it's linear part (affine without translation) and translation part,
otherwise decomposes it into a sequence of reflection, rotation, shear, scale, translation.

Returns
-------
sequence
Returns a sequence of transformations (class :class:`~spatialdata.transformations.Sequence`) which operates only
on the spatial part (no c channel). The output sequence will contain either 2 either 5 transformations in the
following order (the first is applied first).
Case `simple_decomposition = True`.

1. Linear part (affine): linear part of the affine transformation, represented as a
:class:`~spatialdata.transformations.Affine` transformation.
2. Translation. Represented as a :class:`~spatialdata.transformations.Translation` transformation.

Case `simple_decomposition = False`.

1. Reflection. Represented as :class:`~spatialdata.transformations.Scale` transformation with elements in
{1, -1}.
2. Rotation. Represented as an :class:`~spatialdata.transformations.Affine` transformation which in its
matrix form presents itself as an homogeneous affine matrix with no translation part and determinant 1.
Please look at the source code of this function if you need to recover the angle theta.
3. Shear. Represented as an :class:`~spatialdata.transformations.Affine` transformation which in its matrix
form presents itself as an homogeneous affine matrix with no translation part. The matrix is upper
triangular with diagonal elements all equal to 1.
4. Scale. Represented as a :class:`~spatialdata.transformations.Scale` transformation with positive
elements.
5. Translation. Represented as a :class:`~spatialdata.transformations.Translation` transformation.

Note that some of these transformations may be identity transformations.
"""
output_axes = _get_current_output_axes(transformation=transformation, input_axes=input_axes)
if input_axes != output_axes:
raise ValueError("The transformation should leave the input axes unmodified.")
if "z" in input_axes:
raise ValueError("The transformation should not transform the z axis.")
affine = transformation.to_affine(input_axes=input_axes, output_axes=output_axes)
matrix = affine.matrix
if "c" in input_axes:
c_index = input_axes.index("c")
if (
matrix[c_index, c_index] != 1
or np.linalg.norm(matrix[c_index, :]) != 1
or np.linalg.norm(matrix[:, c_index]) != 1
):
raise ValueError("The transformation should not transform the c channel.")
axes = input_axes[:c_index] + input_axes[c_index + 1 :]
m = np.delete(matrix, c_index, 0)
m = np.delete(m, c_index, 1)
else:
axes = input_axes
m = matrix

translation_part = m[:-1, -1]
linear_part = m[:-1, :-1]

if simple_decomposition:
translation = Translation(translation_part, axes=axes)
linear = _compose_affine_from_linear_and_translation(
linear=linear_part,
translation=np.zeros(linear_part.shape[0]),
input_axes=axes,
output_axes=axes,
)
sequence = Sequence([linear, translation])
else:
# qr factorization
a = linear_part
r, q = scipy.linalg.rq(a)

theta = np.arctan2(q[1, 0], q[0, 0])
rotation_matrix = np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]])

scale_matrix = np.diag(np.abs(np.diag(r)))
shear_matrix = np.linalg.inv(scale_matrix) @ r
assert np.allclose(scale_matrix @ shear_matrix, r)
d = np.diag(np.diag(shear_matrix))

qq = rotation_matrix.T @ q
# check that qq is a diagonal matrix with diagonal values in {-1, 1}
assert np.allclose(np.diag(qq) ** 2, np.ones(qq.shape[0]))
assert np.isclose(np.sum(np.abs(qq.ravel())), qq.shape[0])
assert np.allclose(rotation_matrix @ qq, q)

adjusted_shear_matrix = shear_matrix @ d
adjusted_rotation_matrix = d @ rotation_matrix @ d
assert np.allclose(
adjusted_rotation_matrix @ adjusted_rotation_matrix.T, np.eye(adjusted_rotation_matrix.shape[0])
)
adjusted_qq = d @ qq

aaa = scale_matrix @ shear_matrix @ d @ d @ rotation_matrix @ d @ d @ qq
assert np.allclose(a, aaa)
aa = scale_matrix @ adjusted_shear_matrix @ adjusted_rotation_matrix @ adjusted_qq
assert np.allclose(a, aa)

scale = Scale(np.diag(scale_matrix), axes=axes)
shear = _compose_affine_from_linear_and_translation(
linear=adjusted_shear_matrix,
translation=np.zeros(shear_matrix.shape[0]),
input_axes=axes,
output_axes=axes,
)
rotation = _compose_affine_from_linear_and_translation(
linear=adjusted_rotation_matrix,
translation=np.zeros(rotation_matrix.shape[0]),
input_axes=axes,
output_axes=axes,
)
inversion = Scale(np.diag(adjusted_qq), axes=axes)
translation = Translation(translation_part, axes=axes)
sequence = Sequence([inversion, rotation, shear, scale, translation])
check_m = sequence.to_affine_matrix(input_axes=input_axes, output_axes=input_axes)
assert np.allclose(check_m, matrix)
return sequence


TRANSFORMATIONS_MAP[NgffIdentity] = Identity
TRANSFORMATIONS_MAP[NgffMapAxis] = MapAxis
TRANSFORMATIONS_MAP[NgffTranslation] = Translation
Expand Down
122 changes: 122 additions & 0 deletions tests/transformations/test_transformations.py
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
from contextlib import nullcontext
from copy import deepcopy

import numpy as np
Expand DownExpand Up@@ -29,6 +30,7 @@
Sequence,
Translation,
_decompose_affine_into_linear_and_translation,
_decompose_transformation,
_get_affine_for_element,
)
from xarray import DataArray
Expand DownExpand Up@@ -783,6 +785,126 @@ def test_decompose_affine_into_linear_and_translation():
assert np.allclose(translation.translation, np.array([10, 11]))


@pytest.mark.parametrize(
"matrix,input_axes,output_axes,valid",
[
# non-square matrix are not supported
(
np.array(
[
[1, 2, 3, 10],
[4, 5, 6, 11],
[0, 0, 0, 1],
]
),
("x", "y", "z"),
("x", "y"),
False,
),
(
np.array(
[
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
[0, 0, 1],
]
),
("x", "y"),
("x", "y", "z"),
False,
),
# z axis should not be present
(
np.array(
[
[1, 2, 3, 10],
[4, 5, 6, 11],
[7, 8, 9, 12],
[0, 0, 0, 1],
]
),
("x", "y", "z"),
("x", "y", "z"),
False,
),
# c channel is modified
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[8, 9, 1, 10],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[0, 0, 0, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
(
np.array(
[
[1, 2, 3, 4],
[4, 5, 6, 7],
[0, 0, 1, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
# valid, no c channel
(
np.array(
[
[1, 2, 3],
[4, 5, 6],
[0, 0, 1],
]
),
("x", "y"),
("x", "y"),
True,
),
# valid, c channel
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[0, 0, 1, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
True,
),
],
)
@pytest.mark.parametrize("simple_decomposition", [True, False])
def test_decompose_transformation(matrix, input_axes, output_axes, valid, simple_decomposition):
affine = Affine(matrix, input_axes=input_axes, output_axes=output_axes)
context = nullcontext() if valid else pytest.raises(ValueError)
with context:
_ = _decompose_transformation(affine, input_axes=input_axes, simple_decomposition=simple_decomposition)


def test_assign_xy_scale_to_cyx_image():
scale = Scale(np.array([2, 3]), axes=("x", "y"))
image = Image2DModel.parse(np.zeros((10, 10, 10)), dims=("c", "y", "x"))
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
143 changes: 143 additions & 0 deletions src/spatialdata/transformations/transformations.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
from warnings import warn

import numpy as np
import scipy
import xarray as xr
from xarray import DataArray

Expand DownExpand Up@@ -819,6 +820,148 @@ def _decompose_affine_into_linear_and_translation(affine: Affine) -> tuple[Affin
return linear_transformation, translation_transformation


def _compose_affine_from_linear_and_translation(
linear: ArrayLike, translation: ArrayLike, input_axes: tuple[ValidAxis_t, ...], output_axes: tuple[ValidAxis_t, ...]
) -> Affine:
matrix = np.zeros((linear.shape[0] + 1, linear.shape[1] + 1))
matrix[:-1, :-1] = linear
matrix[:-1, -1] = translation
matrix[-1, -1] = 1
return Affine(matrix, input_axes=input_axes, output_axes=output_axes)


def _decompose_transformation(
transformation: BaseTransformation, input_axes: tuple[ValidAxis_t, ...], simple_decomposition: bool = True
) -> Sequence:
"""
Decompose a given 2D transformation into a sequence of predetermined types of transformations.

Parameters
----------
transformation
The transformation to decompose. It is assumed to be of a type that can be represented as a single affine
transformation. It should leave the input axes unmodified, and it should not transform the c channel, if this
is present.
input_axes
The axes of the data the transformation is to be applied to
simple_decomposition
If true, decomposes a transformation into it's linear part (affine without translation) and translation part,
otherwise decomposes it into a sequence of reflection, rotation, shear, scale, translation.

Returns
-------
sequence
Returns a sequence of transformations (class :class:`~spatialdata.transformations.Sequence`) which operates only
on the spatial part (no c channel). The output sequence will contain either 2 either 5 transformations in the
following order (the first is applied first).
Case `simple_decomposition = True`.

1. Linear part (affine): linear part of the affine transformation, represented as a
:class:`~spatialdata.transformations.Affine` transformation.
2. Translation. Represented as a :class:`~spatialdata.transformations.Translation` transformation.

Case `simple_decomposition = False`.

1. Reflection. Represented as :class:`~spatialdata.transformations.Scale` transformation with elements in
{1, -1}.
2. Rotation. Represented as an :class:`~spatialdata.transformations.Affine` transformation which in its
matrix form presents itself as an homogeneous affine matrix with no translation part and determinant 1.
Please look at the source code of this function if you need to recover the angle theta.
3. Shear. Represented as an :class:`~spatialdata.transformations.Affine` transformation which in its matrix
form presents itself as an homogeneous affine matrix with no translation part. The matrix is upper
triangular with diagonal elements all equal to 1.
4. Scale. Represented as a :class:`~spatialdata.transformations.Scale` transformation with positive
elements.
5. Translation. Represented as a :class:`~spatialdata.transformations.Translation` transformation.

Note that some of these transformations may be identity transformations.
"""
output_axes = _get_current_output_axes(transformation=transformation, input_axes=input_axes)
if input_axes != output_axes:
raise ValueError("The transformation should leave the input axes unmodified.")
if "z" in input_axes:
raise ValueError("The transformation should not transform the z axis.")
affine = transformation.to_affine(input_axes=input_axes, output_axes=output_axes)
matrix = affine.matrix
if "c" in input_axes:
c_index = input_axes.index("c")
if (
matrix[c_index, c_index] != 1
or np.linalg.norm(matrix[c_index, :]) != 1
or np.linalg.norm(matrix[:, c_index]) != 1
):
raise ValueError("The transformation should not transform the c channel.")
axes = input_axes[:c_index] + input_axes[c_index + 1 :]
m = np.delete(matrix, c_index, 0)
m = np.delete(m, c_index, 1)
else:
axes = input_axes
m = matrix

translation_part = m[:-1, -1]
linear_part = m[:-1, :-1]

if simple_decomposition:
translation = Translation(translation_part, axes=axes)
linear = _compose_affine_from_linear_and_translation(
linear=linear_part,
translation=np.zeros(linear_part.shape[0]),
input_axes=axes,
output_axes=axes,
)
sequence = Sequence([linear, translation])
else:
# qr factorization
a = linear_part
r, q = scipy.linalg.rq(a)

theta = np.arctan2(q[1, 0], q[0, 0])
rotation_matrix = np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]])

scale_matrix = np.diag(np.abs(np.diag(r)))
shear_matrix = np.linalg.inv(scale_matrix) @ r
assert np.allclose(scale_matrix @ shear_matrix, r)
d = np.diag(np.diag(shear_matrix))

qq = rotation_matrix.T @ q
# check that qq is a diagonal matrix with diagonal values in {-1, 1}
assert np.allclose(np.diag(qq) ** 2, np.ones(qq.shape[0]))
assert np.isclose(np.sum(np.abs(qq.ravel())), qq.shape[0])
assert np.allclose(rotation_matrix @ qq, q)

adjusted_shear_matrix = shear_matrix @ d
adjusted_rotation_matrix = d @ rotation_matrix @ d
assert np.allclose(
adjusted_rotation_matrix @ adjusted_rotation_matrix.T, np.eye(adjusted_rotation_matrix.shape[0])
)
adjusted_qq = d @ qq

aaa = scale_matrix @ shear_matrix @ d @ d @ rotation_matrix @ d @ d @ qq
assert np.allclose(a, aaa)
aa = scale_matrix @ adjusted_shear_matrix @ adjusted_rotation_matrix @ adjusted_qq
assert np.allclose(a, aa)

scale = Scale(np.diag(scale_matrix), axes=axes)
shear = _compose_affine_from_linear_and_translation(
linear=adjusted_shear_matrix,
translation=np.zeros(shear_matrix.shape[0]),
input_axes=axes,
output_axes=axes,
)
rotation = _compose_affine_from_linear_and_translation(
linear=adjusted_rotation_matrix,
translation=np.zeros(rotation_matrix.shape[0]),
input_axes=axes,
output_axes=axes,
)
inversion = Scale(np.diag(adjusted_qq), axes=axes)
translation = Translation(translation_part, axes=axes)
sequence = Sequence([inversion, rotation, shear, scale, translation])
check_m = sequence.to_affine_matrix(input_axes=input_axes, output_axes=input_axes)
assert np.allclose(check_m, matrix)
return sequence


TRANSFORMATIONS_MAP[NgffIdentity] = Identity
TRANSFORMATIONS_MAP[NgffMapAxis] = MapAxis
TRANSFORMATIONS_MAP[NgffTranslation] = Translation
Expand Down
122 changes: 122 additions & 0 deletions tests/transformations/test_transformations.py
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
from contextlib import nullcontext
from copy import deepcopy

import numpy as np
Expand DownExpand Up@@ -29,6 +30,7 @@
Sequence,
Translation,
_decompose_affine_into_linear_and_translation,
_decompose_transformation,
_get_affine_for_element,
)
from xarray import DataArray
Expand DownExpand Up@@ -783,6 +785,126 @@ def test_decompose_affine_into_linear_and_translation():
assert np.allclose(translation.translation, np.array([10, 11]))


@pytest.mark.parametrize(
"matrix,input_axes,output_axes,valid",
[
# non-square matrix are not supported
(
np.array(
[
[1, 2, 3, 10],
[4, 5, 6, 11],
[0, 0, 0, 1],
]
),
("x", "y", "z"),
("x", "y"),
False,
),
(
np.array(
[
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
[0, 0, 1],
]
),
("x", "y"),
("x", "y", "z"),
False,
),
# z axis should not be present
(
np.array(
[
[1, 2, 3, 10],
[4, 5, 6, 11],
[7, 8, 9, 12],
[0, 0, 0, 1],
]
),
("x", "y", "z"),
("x", "y", "z"),
False,
),
# c channel is modified
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[8, 9, 1, 10],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[0, 0, 0, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
(
np.array(
[
[1, 2, 3, 4],
[4, 5, 6, 7],
[0, 0, 1, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
# valid, no c channel
(
np.array(
[
[1, 2, 3],
[4, 5, 6],
[0, 0, 1],
]
),
("x", "y"),
("x", "y"),
True,
),
# valid, c channel
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[0, 0, 1, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
True,
),
],
)
@pytest.mark.parametrize("simple_decomposition", [True, False])
def test_decompose_transformation(matrix, input_axes, output_axes, valid, simple_decomposition):
affine = Affine(matrix, input_axes=input_axes, output_axes=output_axes)
context = nullcontext() if valid else pytest.raises(ValueError)
with context:
_ = _decompose_transformation(affine, input_axes=input_axes, simple_decomposition=simple_decomposition)


def test_assign_xy_scale_to_cyx_image():
scale = Scale(np.array([2, 3]), axes=("x", "y"))
image = Image2DModel.parse(np.zeros((10, 10, 10)), dims=("c", "y", "x"))
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
143 changes: 143 additions & 0 deletions src/spatialdata/transformations/transformations.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
from warnings import warn

import numpy as np
import scipy
import xarray as xr
from xarray import DataArray

Expand DownExpand Up@@ -819,6 +820,148 @@ def _decompose_affine_into_linear_and_translation(affine: Affine) -> tuple[Affin
return linear_transformation, translation_transformation


def _compose_affine_from_linear_and_translation(
linear: ArrayLike, translation: ArrayLike, input_axes: tuple[ValidAxis_t, ...], output_axes: tuple[ValidAxis_t, ...]
) -> Affine:
matrix = np.zeros((linear.shape[0] + 1, linear.shape[1] + 1))
matrix[:-1, :-1] = linear
matrix[:-1, -1] = translation
matrix[-1, -1] = 1
return Affine(matrix, input_axes=input_axes, output_axes=output_axes)


def _decompose_transformation(
transformation: BaseTransformation, input_axes: tuple[ValidAxis_t, ...], simple_decomposition: bool = True
) -> Sequence:
"""
Decompose a given 2D transformation into a sequence of predetermined types of transformations.

Parameters
----------
transformation
The transformation to decompose. It is assumed to be of a type that can be represented as a single affine
transformation. It should leave the input axes unmodified, and it should not transform the c channel, if this
is present.
input_axes
The axes of the data the transformation is to be applied to
simple_decomposition
If true, decomposes a transformation into it's linear part (affine without translation) and translation part,
otherwise decomposes it into a sequence of reflection, rotation, shear, scale, translation.

Returns
-------
sequence
Returns a sequence of transformations (class :class:`~spatialdata.transformations.Sequence`) which operates only
on the spatial part (no c channel). The output sequence will contain either 2 either 5 transformations in the
following order (the first is applied first).
Case `simple_decomposition = True`.

1. Linear part (affine): linear part of the affine transformation, represented as a
:class:`~spatialdata.transformations.Affine` transformation.
2. Translation. Represented as a :class:`~spatialdata.transformations.Translation` transformation.

Case `simple_decomposition = False`.

1. Reflection. Represented as :class:`~spatialdata.transformations.Scale` transformation with elements in
{1, -1}.
2. Rotation. Represented as an :class:`~spatialdata.transformations.Affine` transformation which in its
matrix form presents itself as an homogeneous affine matrix with no translation part and determinant 1.
Please look at the source code of this function if you need to recover the angle theta.
3. Shear. Represented as an :class:`~spatialdata.transformations.Affine` transformation which in its matrix
form presents itself as an homogeneous affine matrix with no translation part. The matrix is upper
triangular with diagonal elements all equal to 1.
4. Scale. Represented as a :class:`~spatialdata.transformations.Scale` transformation with positive
elements.
5. Translation. Represented as a :class:`~spatialdata.transformations.Translation` transformation.

Note that some of these transformations may be identity transformations.
"""
output_axes = _get_current_output_axes(transformation=transformation, input_axes=input_axes)
if input_axes != output_axes:
raise ValueError("The transformation should leave the input axes unmodified.")
if "z" in input_axes:
raise ValueError("The transformation should not transform the z axis.")
affine = transformation.to_affine(input_axes=input_axes, output_axes=output_axes)
matrix = affine.matrix
if "c" in input_axes:
c_index = input_axes.index("c")
if (
matrix[c_index, c_index] != 1
or np.linalg.norm(matrix[c_index, :]) != 1
or np.linalg.norm(matrix[:, c_index]) != 1
):
raise ValueError("The transformation should not transform the c channel.")
axes = input_axes[:c_index] + input_axes[c_index + 1 :]
m = np.delete(matrix, c_index, 0)
m = np.delete(m, c_index, 1)
else:
axes = input_axes
m = matrix

translation_part = m[:-1, -1]
linear_part = m[:-1, :-1]

if simple_decomposition:
translation = Translation(translation_part, axes=axes)
linear = _compose_affine_from_linear_and_translation(
linear=linear_part,
translation=np.zeros(linear_part.shape[0]),
input_axes=axes,
output_axes=axes,
)
sequence = Sequence([linear, translation])
else:
# qr factorization
a = linear_part
r, q = scipy.linalg.rq(a)

theta = np.arctan2(q[1, 0], q[0, 0])
rotation_matrix = np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]])

scale_matrix = np.diag(np.abs(np.diag(r)))
shear_matrix = np.linalg.inv(scale_matrix) @ r
assert np.allclose(scale_matrix @ shear_matrix, r)
d = np.diag(np.diag(shear_matrix))

qq = rotation_matrix.T @ q
# check that qq is a diagonal matrix with diagonal values in {-1, 1}
assert np.allclose(np.diag(qq) ** 2, np.ones(qq.shape[0]))
assert np.isclose(np.sum(np.abs(qq.ravel())), qq.shape[0])
assert np.allclose(rotation_matrix @ qq, q)

adjusted_shear_matrix = shear_matrix @ d
adjusted_rotation_matrix = d @ rotation_matrix @ d
assert np.allclose(
adjusted_rotation_matrix @ adjusted_rotation_matrix.T, np.eye(adjusted_rotation_matrix.shape[0])
)
adjusted_qq = d @ qq

aaa = scale_matrix @ shear_matrix @ d @ d @ rotation_matrix @ d @ d @ qq
assert np.allclose(a, aaa)
aa = scale_matrix @ adjusted_shear_matrix @ adjusted_rotation_matrix @ adjusted_qq
assert np.allclose(a, aa)

scale = Scale(np.diag(scale_matrix), axes=axes)
shear = _compose_affine_from_linear_and_translation(
linear=adjusted_shear_matrix,
translation=np.zeros(shear_matrix.shape[0]),
input_axes=axes,
output_axes=axes,
)
rotation = _compose_affine_from_linear_and_translation(
linear=adjusted_rotation_matrix,
translation=np.zeros(rotation_matrix.shape[0]),
input_axes=axes,
output_axes=axes,
)
inversion = Scale(np.diag(adjusted_qq), axes=axes)
translation = Translation(translation_part, axes=axes)
sequence = Sequence([inversion, rotation, shear, scale, translation])
check_m = sequence.to_affine_matrix(input_axes=input_axes, output_axes=input_axes)
assert np.allclose(check_m, matrix)
return sequence


TRANSFORMATIONS_MAP[NgffIdentity] = Identity
TRANSFORMATIONS_MAP[NgffMapAxis] = MapAxis
TRANSFORMATIONS_MAP[NgffTranslation] = Translation
Expand Down
122 changes: 122 additions & 0 deletions tests/transformations/test_transformations.py
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
from contextlib import nullcontext
from copy import deepcopy

import numpy as np
Expand DownExpand Up@@ -29,6 +30,7 @@
Sequence,
Translation,
_decompose_affine_into_linear_and_translation,
_decompose_transformation,
_get_affine_for_element,
)
from xarray import DataArray
Expand DownExpand Up@@ -783,6 +785,126 @@ def test_decompose_affine_into_linear_and_translation():
assert np.allclose(translation.translation, np.array([10, 11]))


@pytest.mark.parametrize(
"matrix,input_axes,output_axes,valid",
[
# non-square matrix are not supported
(
np.array(
[
[1, 2, 3, 10],
[4, 5, 6, 11],
[0, 0, 0, 1],
]
),
("x", "y", "z"),
("x", "y"),
False,
),
(
np.array(
[
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
[0, 0, 1],
]
),
("x", "y"),
("x", "y", "z"),
False,
),
# z axis should not be present
(
np.array(
[
[1, 2, 3, 10],
[4, 5, 6, 11],
[7, 8, 9, 12],
[0, 0, 0, 1],
]
),
("x", "y", "z"),
("x", "y", "z"),
False,
),
# c channel is modified
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[8, 9, 1, 10],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[0, 0, 0, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
(
np.array(
[
[1, 2, 3, 4],
[4, 5, 6, 7],
[0, 0, 1, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
# valid, no c channel
(
np.array(
[
[1, 2, 3],
[4, 5, 6],
[0, 0, 1],
]
),
("x", "y"),
("x", "y"),
True,
),
# valid, c channel
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[0, 0, 1, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
True,
),
],
)
@pytest.mark.parametrize("simple_decomposition", [True, False])
def test_decompose_transformation(matrix, input_axes, output_axes, valid, simple_decomposition):
affine = Affine(matrix, input_axes=input_axes, output_axes=output_axes)
context = nullcontext() if valid else pytest.raises(ValueError)
with context:
_ = _decompose_transformation(affine, input_axes=input_axes, simple_decomposition=simple_decomposition)


def test_assign_xy_scale_to_cyx_image():
scale = Scale(np.array([2, 3]), axes=("x", "y"))
image = Image2DModel.parse(np.zeros((10, 10, 10)), dims=("c", "y", "x"))
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
143 changes: 143 additions & 0 deletions src/spatialdata/transformations/transformations.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
from warnings import warn

import numpy as np
import scipy
import xarray as xr
from xarray import DataArray

Expand DownExpand Up@@ -819,6 +820,148 @@ def _decompose_affine_into_linear_and_translation(affine: Affine) -> tuple[Affin
return linear_transformation, translation_transformation


def _compose_affine_from_linear_and_translation(
linear: ArrayLike, translation: ArrayLike, input_axes: tuple[ValidAxis_t, ...], output_axes: tuple[ValidAxis_t, ...]
) -> Affine:
matrix = np.zeros((linear.shape[0] + 1, linear.shape[1] + 1))
matrix[:-1, :-1] = linear
matrix[:-1, -1] = translation
matrix[-1, -1] = 1
return Affine(matrix, input_axes=input_axes, output_axes=output_axes)


def _decompose_transformation(
transformation: BaseTransformation, input_axes: tuple[ValidAxis_t, ...], simple_decomposition: bool = True
) -> Sequence:
"""
Decompose a given 2D transformation into a sequence of predetermined types of transformations.

Parameters
----------
transformation
The transformation to decompose. It is assumed to be of a type that can be represented as a single affine
transformation. It should leave the input axes unmodified, and it should not transform the c channel, if this
is present.
input_axes
The axes of the data the transformation is to be applied to
simple_decomposition
If true, decomposes a transformation into it's linear part (affine without translation) and translation part,
otherwise decomposes it into a sequence of reflection, rotation, shear, scale, translation.

Returns
-------
sequence
Returns a sequence of transformations (class :class:`~spatialdata.transformations.Sequence`) which operates only
on the spatial part (no c channel). The output sequence will contain either 2 either 5 transformations in the
following order (the first is applied first).
Case `simple_decomposition = True`.

1. Linear part (affine): linear part of the affine transformation, represented as a
:class:`~spatialdata.transformations.Affine` transformation.
2. Translation. Represented as a :class:`~spatialdata.transformations.Translation` transformation.

Case `simple_decomposition = False`.

1. Reflection. Represented as :class:`~spatialdata.transformations.Scale` transformation with elements in
{1, -1}.
2. Rotation. Represented as an :class:`~spatialdata.transformations.Affine` transformation which in its
matrix form presents itself as an homogeneous affine matrix with no translation part and determinant 1.
Please look at the source code of this function if you need to recover the angle theta.
3. Shear. Represented as an :class:`~spatialdata.transformations.Affine` transformation which in its matrix
form presents itself as an homogeneous affine matrix with no translation part. The matrix is upper
triangular with diagonal elements all equal to 1.
4. Scale. Represented as a :class:`~spatialdata.transformations.Scale` transformation with positive
elements.
5. Translation. Represented as a :class:`~spatialdata.transformations.Translation` transformation.

Note that some of these transformations may be identity transformations.
"""
output_axes = _get_current_output_axes(transformation=transformation, input_axes=input_axes)
if input_axes != output_axes:
raise ValueError("The transformation should leave the input axes unmodified.")
if "z" in input_axes:
raise ValueError("The transformation should not transform the z axis.")
affine = transformation.to_affine(input_axes=input_axes, output_axes=output_axes)
matrix = affine.matrix
if "c" in input_axes:
c_index = input_axes.index("c")
if (
matrix[c_index, c_index] != 1
or np.linalg.norm(matrix[c_index, :]) != 1
or np.linalg.norm(matrix[:, c_index]) != 1
):
raise ValueError("The transformation should not transform the c channel.")
axes = input_axes[:c_index] + input_axes[c_index + 1 :]
m = np.delete(matrix, c_index, 0)
m = np.delete(m, c_index, 1)
else:
axes = input_axes
m = matrix

translation_part = m[:-1, -1]
linear_part = m[:-1, :-1]

if simple_decomposition:
translation = Translation(translation_part, axes=axes)
linear = _compose_affine_from_linear_and_translation(
linear=linear_part,
translation=np.zeros(linear_part.shape[0]),
input_axes=axes,
output_axes=axes,
)
sequence = Sequence([linear, translation])
else:
# qr factorization
a = linear_part
r, q = scipy.linalg.rq(a)

theta = np.arctan2(q[1, 0], q[0, 0])
rotation_matrix = np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]])

scale_matrix = np.diag(np.abs(np.diag(r)))
shear_matrix = np.linalg.inv(scale_matrix) @ r
assert np.allclose(scale_matrix @ shear_matrix, r)
d = np.diag(np.diag(shear_matrix))

qq = rotation_matrix.T @ q
# check that qq is a diagonal matrix with diagonal values in {-1, 1}
assert np.allclose(np.diag(qq) ** 2, np.ones(qq.shape[0]))
assert np.isclose(np.sum(np.abs(qq.ravel())), qq.shape[0])
assert np.allclose(rotation_matrix @ qq, q)

adjusted_shear_matrix = shear_matrix @ d
adjusted_rotation_matrix = d @ rotation_matrix @ d
assert np.allclose(
adjusted_rotation_matrix @ adjusted_rotation_matrix.T, np.eye(adjusted_rotation_matrix.shape[0])
)
adjusted_qq = d @ qq

aaa = scale_matrix @ shear_matrix @ d @ d @ rotation_matrix @ d @ d @ qq
assert np.allclose(a, aaa)
aa = scale_matrix @ adjusted_shear_matrix @ adjusted_rotation_matrix @ adjusted_qq
assert np.allclose(a, aa)

scale = Scale(np.diag(scale_matrix), axes=axes)
shear = _compose_affine_from_linear_and_translation(
linear=adjusted_shear_matrix,
translation=np.zeros(shear_matrix.shape[0]),
input_axes=axes,
output_axes=axes,
)
rotation = _compose_affine_from_linear_and_translation(
linear=adjusted_rotation_matrix,
translation=np.zeros(rotation_matrix.shape[0]),
input_axes=axes,
output_axes=axes,
)
inversion = Scale(np.diag(adjusted_qq), axes=axes)
translation = Translation(translation_part, axes=axes)
sequence = Sequence([inversion, rotation, shear, scale, translation])
check_m = sequence.to_affine_matrix(input_axes=input_axes, output_axes=input_axes)
assert np.allclose(check_m, matrix)
return sequence


TRANSFORMATIONS_MAP[NgffIdentity] = Identity
TRANSFORMATIONS_MAP[NgffMapAxis] = MapAxis
TRANSFORMATIONS_MAP[NgffTranslation] = Translation
Expand Down
122 changes: 122 additions & 0 deletions tests/transformations/test_transformations.py
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
from contextlib import nullcontext
from copy import deepcopy

import numpy as np
Expand DownExpand Up@@ -29,6 +30,7 @@
Sequence,
Translation,
_decompose_affine_into_linear_and_translation,
_decompose_transformation,
_get_affine_for_element,
)
from xarray import DataArray
Expand DownExpand Up@@ -783,6 +785,126 @@ def test_decompose_affine_into_linear_and_translation():
assert np.allclose(translation.translation, np.array([10, 11]))


@pytest.mark.parametrize(
"matrix,input_axes,output_axes,valid",
[
# non-square matrix are not supported
(
np.array(
[
[1, 2, 3, 10],
[4, 5, 6, 11],
[0, 0, 0, 1],
]
),
("x", "y", "z"),
("x", "y"),
False,
),
(
np.array(
[
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
[0, 0, 1],
]
),
("x", "y"),
("x", "y", "z"),
False,
),
# z axis should not be present
(
np.array(
[
[1, 2, 3, 10],
[4, 5, 6, 11],
[7, 8, 9, 12],
[0, 0, 0, 1],
]
),
("x", "y", "z"),
("x", "y", "z"),
False,
),
# c channel is modified
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[8, 9, 1, 10],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[0, 0, 0, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
(
np.array(
[
[1, 2, 3, 4],
[4, 5, 6, 7],
[0, 0, 1, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
# valid, no c channel
(
np.array(
[
[1, 2, 3],
[4, 5, 6],
[0, 0, 1],
]
),
("x", "y"),
("x", "y"),
True,
),
# valid, c channel
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[0, 0, 1, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
True,
),
],
)
@pytest.mark.parametrize("simple_decomposition", [True, False])
def test_decompose_transformation(matrix, input_axes, output_axes, valid, simple_decomposition):
affine = Affine(matrix, input_axes=input_axes, output_axes=output_axes)
context = nullcontext() if valid else pytest.raises(ValueError)
with context:
_ = _decompose_transformation(affine, input_axes=input_axes, simple_decomposition=simple_decomposition)


def test_assign_xy_scale_to_cyx_image():
scale = Scale(np.array([2, 3]), axes=("x", "y"))
image = Image2DModel.parse(np.zeros((10, 10, 10)), dims=("c", "y", "x"))
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
143 changes: 143 additions & 0 deletions src/spatialdata/transformations/transformations.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
from warnings import warn

import numpy as np
import scipy
import xarray as xr
from xarray import DataArray

Expand DownExpand Up@@ -819,6 +820,148 @@ def _decompose_affine_into_linear_and_translation(affine: Affine) -> tuple[Affin
return linear_transformation, translation_transformation


def _compose_affine_from_linear_and_translation(
linear: ArrayLike, translation: ArrayLike, input_axes: tuple[ValidAxis_t, ...], output_axes: tuple[ValidAxis_t, ...]
) -> Affine:
matrix = np.zeros((linear.shape[0] + 1, linear.shape[1] + 1))
matrix[:-1, :-1] = linear
matrix[:-1, -1] = translation
matrix[-1, -1] = 1
return Affine(matrix, input_axes=input_axes, output_axes=output_axes)


def _decompose_transformation(
transformation: BaseTransformation, input_axes: tuple[ValidAxis_t, ...], simple_decomposition: bool = True
) -> Sequence:
"""
Decompose a given 2D transformation into a sequence of predetermined types of transformations.

Parameters
----------
transformation
The transformation to decompose. It is assumed to be of a type that can be represented as a single affine
transformation. It should leave the input axes unmodified, and it should not transform the c channel, if this
is present.
input_axes
The axes of the data the transformation is to be applied to
simple_decomposition
If true, decomposes a transformation into it's linear part (affine without translation) and translation part,
otherwise decomposes it into a sequence of reflection, rotation, shear, scale, translation.

Returns
-------
sequence
Returns a sequence of transformations (class :class:`~spatialdata.transformations.Sequence`) which operates only
on the spatial part (no c channel). The output sequence will contain either 2 either 5 transformations in the
following order (the first is applied first).
Case `simple_decomposition = True`.

1. Linear part (affine): linear part of the affine transformation, represented as a
:class:`~spatialdata.transformations.Affine` transformation.
2. Translation. Represented as a :class:`~spatialdata.transformations.Translation` transformation.

Case `simple_decomposition = False`.

1. Reflection. Represented as :class:`~spatialdata.transformations.Scale` transformation with elements in
{1, -1}.
2. Rotation. Represented as an :class:`~spatialdata.transformations.Affine` transformation which in its
matrix form presents itself as an homogeneous affine matrix with no translation part and determinant 1.
Please look at the source code of this function if you need to recover the angle theta.
3. Shear. Represented as an :class:`~spatialdata.transformations.Affine` transformation which in its matrix
form presents itself as an homogeneous affine matrix with no translation part. The matrix is upper
triangular with diagonal elements all equal to 1.
4. Scale. Represented as a :class:`~spatialdata.transformations.Scale` transformation with positive
elements.
5. Translation. Represented as a :class:`~spatialdata.transformations.Translation` transformation.

Note that some of these transformations may be identity transformations.
"""
output_axes = _get_current_output_axes(transformation=transformation, input_axes=input_axes)
if input_axes != output_axes:
raise ValueError("The transformation should leave the input axes unmodified.")
if "z" in input_axes:
raise ValueError("The transformation should not transform the z axis.")
affine = transformation.to_affine(input_axes=input_axes, output_axes=output_axes)
matrix = affine.matrix
if "c" in input_axes:
c_index = input_axes.index("c")
if (
matrix[c_index, c_index] != 1
or np.linalg.norm(matrix[c_index, :]) != 1
or np.linalg.norm(matrix[:, c_index]) != 1
):
raise ValueError("The transformation should not transform the c channel.")
axes = input_axes[:c_index] + input_axes[c_index + 1 :]
m = np.delete(matrix, c_index, 0)
m = np.delete(m, c_index, 1)
else:
axes = input_axes
m = matrix

translation_part = m[:-1, -1]
linear_part = m[:-1, :-1]

if simple_decomposition:
translation = Translation(translation_part, axes=axes)
linear = _compose_affine_from_linear_and_translation(
linear=linear_part,
translation=np.zeros(linear_part.shape[0]),
input_axes=axes,
output_axes=axes,
)
sequence = Sequence([linear, translation])
else:
# qr factorization
a = linear_part
r, q = scipy.linalg.rq(a)

theta = np.arctan2(q[1, 0], q[0, 0])
rotation_matrix = np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]])

scale_matrix = np.diag(np.abs(np.diag(r)))
shear_matrix = np.linalg.inv(scale_matrix) @ r
assert np.allclose(scale_matrix @ shear_matrix, r)
d = np.diag(np.diag(shear_matrix))

qq = rotation_matrix.T @ q
# check that qq is a diagonal matrix with diagonal values in {-1, 1}
assert np.allclose(np.diag(qq) ** 2, np.ones(qq.shape[0]))
assert np.isclose(np.sum(np.abs(qq.ravel())), qq.shape[0])
assert np.allclose(rotation_matrix @ qq, q)

adjusted_shear_matrix = shear_matrix @ d
adjusted_rotation_matrix = d @ rotation_matrix @ d
assert np.allclose(
adjusted_rotation_matrix @ adjusted_rotation_matrix.T, np.eye(adjusted_rotation_matrix.shape[0])
)
adjusted_qq = d @ qq

aaa = scale_matrix @ shear_matrix @ d @ d @ rotation_matrix @ d @ d @ qq
assert np.allclose(a, aaa)
aa = scale_matrix @ adjusted_shear_matrix @ adjusted_rotation_matrix @ adjusted_qq
assert np.allclose(a, aa)

scale = Scale(np.diag(scale_matrix), axes=axes)
shear = _compose_affine_from_linear_and_translation(
linear=adjusted_shear_matrix,
translation=np.zeros(shear_matrix.shape[0]),
input_axes=axes,
output_axes=axes,
)
rotation = _compose_affine_from_linear_and_translation(
linear=adjusted_rotation_matrix,
translation=np.zeros(rotation_matrix.shape[0]),
input_axes=axes,
output_axes=axes,
)
inversion = Scale(np.diag(adjusted_qq), axes=axes)
translation = Translation(translation_part, axes=axes)
sequence = Sequence([inversion, rotation, shear, scale, translation])
check_m = sequence.to_affine_matrix(input_axes=input_axes, output_axes=input_axes)
assert np.allclose(check_m, matrix)
return sequence


TRANSFORMATIONS_MAP[NgffIdentity] = Identity
TRANSFORMATIONS_MAP[NgffMapAxis] = MapAxis
TRANSFORMATIONS_MAP[NgffTranslation] = Translation
Expand Down
122 changes: 122 additions & 0 deletions tests/transformations/test_transformations.py
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
from contextlib import nullcontext
from copy import deepcopy

import numpy as np
Expand DownExpand Up@@ -29,6 +30,7 @@
Sequence,
Translation,
_decompose_affine_into_linear_and_translation,
_decompose_transformation,
_get_affine_for_element,
)
from xarray import DataArray
Expand DownExpand Up@@ -783,6 +785,126 @@ def test_decompose_affine_into_linear_and_translation():
assert np.allclose(translation.translation, np.array([10, 11]))


@pytest.mark.parametrize(
"matrix,input_axes,output_axes,valid",
[
# non-square matrix are not supported
(
np.array(
[
[1, 2, 3, 10],
[4, 5, 6, 11],
[0, 0, 0, 1],
]
),
("x", "y", "z"),
("x", "y"),
False,
),
(
np.array(
[
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
[0, 0, 1],
]
),
("x", "y"),
("x", "y", "z"),
False,
),
# z axis should not be present
(
np.array(
[
[1, 2, 3, 10],
[4, 5, 6, 11],
[7, 8, 9, 12],
[0, 0, 0, 1],
]
),
("x", "y", "z"),
("x", "y", "z"),
False,
),
# c channel is modified
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[8, 9, 1, 10],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[0, 0, 0, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
(
np.array(
[
[1, 2, 3, 4],
[4, 5, 6, 7],
[0, 0, 1, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
# valid, no c channel
(
np.array(
[
[1, 2, 3],
[4, 5, 6],
[0, 0, 1],
]
),
("x", "y"),
("x", "y"),
True,
),
# valid, c channel
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[0, 0, 1, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
True,
),
],
)
@pytest.mark.parametrize("simple_decomposition", [True, False])
def test_decompose_transformation(matrix, input_axes, output_axes, valid, simple_decomposition):
affine = Affine(matrix, input_axes=input_axes, output_axes=output_axes)
context = nullcontext() if valid else pytest.raises(ValueError)
with context:
_ = _decompose_transformation(affine, input_axes=input_axes, simple_decomposition=simple_decomposition)


def test_assign_xy_scale_to_cyx_image():
scale = Scale(np.array([2, 3]), axes=("x", "y"))
image = Image2DModel.parse(np.zeros((10, 10, 10)), dims=("c", "y", "x"))
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
143 changes: 143 additions & 0 deletions src/spatialdata/transformations/transformations.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
from warnings import warn

import numpy as np
import scipy
import xarray as xr
from xarray import DataArray

Expand DownExpand Up@@ -819,6 +820,148 @@ def _decompose_affine_into_linear_and_translation(affine: Affine) -> tuple[Affin
return linear_transformation, translation_transformation


def _compose_affine_from_linear_and_translation(
linear: ArrayLike, translation: ArrayLike, input_axes: tuple[ValidAxis_t, ...], output_axes: tuple[ValidAxis_t, ...]
) -> Affine:
matrix = np.zeros((linear.shape[0] + 1, linear.shape[1] + 1))
matrix[:-1, :-1] = linear
matrix[:-1, -1] = translation
matrix[-1, -1] = 1
return Affine(matrix, input_axes=input_axes, output_axes=output_axes)


def _decompose_transformation(
transformation: BaseTransformation, input_axes: tuple[ValidAxis_t, ...], simple_decomposition: bool = True
) -> Sequence:
"""
Decompose a given 2D transformation into a sequence of predetermined types of transformations.

Parameters
----------
transformation
The transformation to decompose. It is assumed to be of a type that can be represented as a single affine
transformation. It should leave the input axes unmodified, and it should not transform the c channel, if this
is present.
input_axes
The axes of the data the transformation is to be applied to
simple_decomposition
If true, decomposes a transformation into it's linear part (affine without translation) and translation part,
otherwise decomposes it into a sequence of reflection, rotation, shear, scale, translation.

Returns
-------
sequence
Returns a sequence of transformations (class :class:`~spatialdata.transformations.Sequence`) which operates only
on the spatial part (no c channel). The output sequence will contain either 2 either 5 transformations in the
following order (the first is applied first).
Case `simple_decomposition = True`.

1. Linear part (affine): linear part of the affine transformation, represented as a
:class:`~spatialdata.transformations.Affine` transformation.
2. Translation. Represented as a :class:`~spatialdata.transformations.Translation` transformation.

Case `simple_decomposition = False`.

1. Reflection. Represented as :class:`~spatialdata.transformations.Scale` transformation with elements in
{1, -1}.
2. Rotation. Represented as an :class:`~spatialdata.transformations.Affine` transformation which in its
matrix form presents itself as an homogeneous affine matrix with no translation part and determinant 1.
Please look at the source code of this function if you need to recover the angle theta.
3. Shear. Represented as an :class:`~spatialdata.transformations.Affine` transformation which in its matrix
form presents itself as an homogeneous affine matrix with no translation part. The matrix is upper
triangular with diagonal elements all equal to 1.
4. Scale. Represented as a :class:`~spatialdata.transformations.Scale` transformation with positive
elements.
5. Translation. Represented as a :class:`~spatialdata.transformations.Translation` transformation.

Note that some of these transformations may be identity transformations.
"""
output_axes = _get_current_output_axes(transformation=transformation, input_axes=input_axes)
if input_axes != output_axes:
raise ValueError("The transformation should leave the input axes unmodified.")
if "z" in input_axes:
raise ValueError("The transformation should not transform the z axis.")
affine = transformation.to_affine(input_axes=input_axes, output_axes=output_axes)
matrix = affine.matrix
if "c" in input_axes:
c_index = input_axes.index("c")
if (
matrix[c_index, c_index] != 1
or np.linalg.norm(matrix[c_index, :]) != 1
or np.linalg.norm(matrix[:, c_index]) != 1
):
raise ValueError("The transformation should not transform the c channel.")
axes = input_axes[:c_index] + input_axes[c_index + 1 :]
m = np.delete(matrix, c_index, 0)
m = np.delete(m, c_index, 1)
else:
axes = input_axes
m = matrix

translation_part = m[:-1, -1]
linear_part = m[:-1, :-1]

if simple_decomposition:
translation = Translation(translation_part, axes=axes)
linear = _compose_affine_from_linear_and_translation(
linear=linear_part,
translation=np.zeros(linear_part.shape[0]),
input_axes=axes,
output_axes=axes,
)
sequence = Sequence([linear, translation])
else:
# qr factorization
a = linear_part
r, q = scipy.linalg.rq(a)

theta = np.arctan2(q[1, 0], q[0, 0])
rotation_matrix = np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]])

scale_matrix = np.diag(np.abs(np.diag(r)))
shear_matrix = np.linalg.inv(scale_matrix) @ r
assert np.allclose(scale_matrix @ shear_matrix, r)
d = np.diag(np.diag(shear_matrix))

qq = rotation_matrix.T @ q
# check that qq is a diagonal matrix with diagonal values in {-1, 1}
assert np.allclose(np.diag(qq) ** 2, np.ones(qq.shape[0]))
assert np.isclose(np.sum(np.abs(qq.ravel())), qq.shape[0])
assert np.allclose(rotation_matrix @ qq, q)

adjusted_shear_matrix = shear_matrix @ d
adjusted_rotation_matrix = d @ rotation_matrix @ d
assert np.allclose(
adjusted_rotation_matrix @ adjusted_rotation_matrix.T, np.eye(adjusted_rotation_matrix.shape[0])
)
adjusted_qq = d @ qq

aaa = scale_matrix @ shear_matrix @ d @ d @ rotation_matrix @ d @ d @ qq
assert np.allclose(a, aaa)
aa = scale_matrix @ adjusted_shear_matrix @ adjusted_rotation_matrix @ adjusted_qq
assert np.allclose(a, aa)

scale = Scale(np.diag(scale_matrix), axes=axes)
shear = _compose_affine_from_linear_and_translation(
linear=adjusted_shear_matrix,
translation=np.zeros(shear_matrix.shape[0]),
input_axes=axes,
output_axes=axes,
)
rotation = _compose_affine_from_linear_and_translation(
linear=adjusted_rotation_matrix,
translation=np.zeros(rotation_matrix.shape[0]),
input_axes=axes,
output_axes=axes,
)
inversion = Scale(np.diag(adjusted_qq), axes=axes)
translation = Translation(translation_part, axes=axes)
sequence = Sequence([inversion, rotation, shear, scale, translation])
check_m = sequence.to_affine_matrix(input_axes=input_axes, output_axes=input_axes)
assert np.allclose(check_m, matrix)
return sequence


TRANSFORMATIONS_MAP[NgffIdentity] = Identity
TRANSFORMATIONS_MAP[NgffMapAxis] = MapAxis
TRANSFORMATIONS_MAP[NgffTranslation] = Translation
Expand Down
122 changes: 122 additions & 0 deletions tests/transformations/test_transformations.py
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
from contextlib import nullcontext
from copy import deepcopy

import numpy as np
Expand DownExpand Up@@ -29,6 +30,7 @@
Sequence,
Translation,
_decompose_affine_into_linear_and_translation,
_decompose_transformation,
_get_affine_for_element,
)
from xarray import DataArray
Expand DownExpand Up@@ -783,6 +785,126 @@ def test_decompose_affine_into_linear_and_translation():
assert np.allclose(translation.translation, np.array([10, 11]))


@pytest.mark.parametrize(
"matrix,input_axes,output_axes,valid",
[
# non-square matrix are not supported
(
np.array(
[
[1, 2, 3, 10],
[4, 5, 6, 11],
[0, 0, 0, 1],
]
),
("x", "y", "z"),
("x", "y"),
False,
),
(
np.array(
[
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
[0, 0, 1],
]
),
("x", "y"),
("x", "y", "z"),
False,
),
# z axis should not be present
(
np.array(
[
[1, 2, 3, 10],
[4, 5, 6, 11],
[7, 8, 9, 12],
[0, 0, 0, 1],
]
),
("x", "y", "z"),
("x", "y", "z"),
False,
),
# c channel is modified
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[8, 9, 1, 10],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[0, 0, 0, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
(
np.array(
[
[1, 2, 3, 4],
[4, 5, 6, 7],
[0, 0, 1, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
# valid, no c channel
(
np.array(
[
[1, 2, 3],
[4, 5, 6],
[0, 0, 1],
]
),
("x", "y"),
("x", "y"),
True,
),
# valid, c channel
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[0, 0, 1, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
True,
),
],
)
@pytest.mark.parametrize("simple_decomposition", [True, False])
def test_decompose_transformation(matrix, input_axes, output_axes, valid, simple_decomposition):
affine = Affine(matrix, input_axes=input_axes, output_axes=output_axes)
context = nullcontext() if valid else pytest.raises(ValueError)
with context:
_ = _decompose_transformation(affine, input_axes=input_axes, simple_decomposition=simple_decomposition)


def test_assign_xy_scale_to_cyx_image():
scale = Scale(np.array([2, 3]), axes=("x", "y"))
image = Image2DModel.parse(np.zeros((10, 10, 10)), dims=("c", "y", "x"))
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
143 changes: 143 additions & 0 deletions src/spatialdata/transformations/transformations.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
from warnings import warn

import numpy as np
import scipy
import xarray as xr
from xarray import DataArray

Expand DownExpand Up@@ -819,6 +820,148 @@ def _decompose_affine_into_linear_and_translation(affine: Affine) -> tuple[Affin
return linear_transformation, translation_transformation


def _compose_affine_from_linear_and_translation(
linear: ArrayLike, translation: ArrayLike, input_axes: tuple[ValidAxis_t, ...], output_axes: tuple[ValidAxis_t, ...]
) -> Affine:
matrix = np.zeros((linear.shape[0] + 1, linear.shape[1] + 1))
matrix[:-1, :-1] = linear
matrix[:-1, -1] = translation
matrix[-1, -1] = 1
return Affine(matrix, input_axes=input_axes, output_axes=output_axes)


def _decompose_transformation(
transformation: BaseTransformation, input_axes: tuple[ValidAxis_t, ...], simple_decomposition: bool = True
) -> Sequence:
"""
Decompose a given 2D transformation into a sequence of predetermined types of transformations.

Parameters
----------
transformation
The transformation to decompose. It is assumed to be of a type that can be represented as a single affine
transformation. It should leave the input axes unmodified, and it should not transform the c channel, if this
is present.
input_axes
The axes of the data the transformation is to be applied to
simple_decomposition
If true, decomposes a transformation into it's linear part (affine without translation) and translation part,
otherwise decomposes it into a sequence of reflection, rotation, shear, scale, translation.

Returns
-------
sequence
Returns a sequence of transformations (class :class:`~spatialdata.transformations.Sequence`) which operates only
on the spatial part (no c channel). The output sequence will contain either 2 either 5 transformations in the
following order (the first is applied first).
Case `simple_decomposition = True`.

1. Linear part (affine): linear part of the affine transformation, represented as a
:class:`~spatialdata.transformations.Affine` transformation.
2. Translation. Represented as a :class:`~spatialdata.transformations.Translation` transformation.

Case `simple_decomposition = False`.

1. Reflection. Represented as :class:`~spatialdata.transformations.Scale` transformation with elements in
{1, -1}.
2. Rotation. Represented as an :class:`~spatialdata.transformations.Affine` transformation which in its
matrix form presents itself as an homogeneous affine matrix with no translation part and determinant 1.
Please look at the source code of this function if you need to recover the angle theta.
3. Shear. Represented as an :class:`~spatialdata.transformations.Affine` transformation which in its matrix
form presents itself as an homogeneous affine matrix with no translation part. The matrix is upper
triangular with diagonal elements all equal to 1.
4. Scale. Represented as a :class:`~spatialdata.transformations.Scale` transformation with positive
elements.
5. Translation. Represented as a :class:`~spatialdata.transformations.Translation` transformation.

Note that some of these transformations may be identity transformations.
"""
output_axes = _get_current_output_axes(transformation=transformation, input_axes=input_axes)
if input_axes != output_axes:
raise ValueError("The transformation should leave the input axes unmodified.")
if "z" in input_axes:
raise ValueError("The transformation should not transform the z axis.")
affine = transformation.to_affine(input_axes=input_axes, output_axes=output_axes)
matrix = affine.matrix
if "c" in input_axes:
c_index = input_axes.index("c")
if (
matrix[c_index, c_index] != 1
or np.linalg.norm(matrix[c_index, :]) != 1
or np.linalg.norm(matrix[:, c_index]) != 1
):
raise ValueError("The transformation should not transform the c channel.")
axes = input_axes[:c_index] + input_axes[c_index + 1 :]
m = np.delete(matrix, c_index, 0)
m = np.delete(m, c_index, 1)
else:
axes = input_axes
m = matrix

translation_part = m[:-1, -1]
linear_part = m[:-1, :-1]

if simple_decomposition:
translation = Translation(translation_part, axes=axes)
linear = _compose_affine_from_linear_and_translation(
linear=linear_part,
translation=np.zeros(linear_part.shape[0]),
input_axes=axes,
output_axes=axes,
)
sequence = Sequence([linear, translation])
else:
# qr factorization
a = linear_part
r, q = scipy.linalg.rq(a)

theta = np.arctan2(q[1, 0], q[0, 0])
rotation_matrix = np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]])

scale_matrix = np.diag(np.abs(np.diag(r)))
shear_matrix = np.linalg.inv(scale_matrix) @ r
assert np.allclose(scale_matrix @ shear_matrix, r)
d = np.diag(np.diag(shear_matrix))

qq = rotation_matrix.T @ q
# check that qq is a diagonal matrix with diagonal values in {-1, 1}
assert np.allclose(np.diag(qq) ** 2, np.ones(qq.shape[0]))
assert np.isclose(np.sum(np.abs(qq.ravel())), qq.shape[0])
assert np.allclose(rotation_matrix @ qq, q)

adjusted_shear_matrix = shear_matrix @ d
adjusted_rotation_matrix = d @ rotation_matrix @ d
assert np.allclose(
adjusted_rotation_matrix @ adjusted_rotation_matrix.T, np.eye(adjusted_rotation_matrix.shape[0])
)
adjusted_qq = d @ qq

aaa = scale_matrix @ shear_matrix @ d @ d @ rotation_matrix @ d @ d @ qq
assert np.allclose(a, aaa)
aa = scale_matrix @ adjusted_shear_matrix @ adjusted_rotation_matrix @ adjusted_qq
assert np.allclose(a, aa)

scale = Scale(np.diag(scale_matrix), axes=axes)
shear = _compose_affine_from_linear_and_translation(
linear=adjusted_shear_matrix,
translation=np.zeros(shear_matrix.shape[0]),
input_axes=axes,
output_axes=axes,
)
rotation = _compose_affine_from_linear_and_translation(
linear=adjusted_rotation_matrix,
translation=np.zeros(rotation_matrix.shape[0]),
input_axes=axes,
output_axes=axes,
)
inversion = Scale(np.diag(adjusted_qq), axes=axes)
translation = Translation(translation_part, axes=axes)
sequence = Sequence([inversion, rotation, shear, scale, translation])
check_m = sequence.to_affine_matrix(input_axes=input_axes, output_axes=input_axes)
assert np.allclose(check_m, matrix)
return sequence


TRANSFORMATIONS_MAP[NgffIdentity] = Identity
TRANSFORMATIONS_MAP[NgffMapAxis] = MapAxis
TRANSFORMATIONS_MAP[NgffTranslation] = Translation
Expand Down
122 changes: 122 additions & 0 deletions tests/transformations/test_transformations.py
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
from contextlib import nullcontext
from copy import deepcopy

import numpy as np
Expand DownExpand Up@@ -29,6 +30,7 @@
Sequence,
Translation,
_decompose_affine_into_linear_and_translation,
_decompose_transformation,
_get_affine_for_element,
)
from xarray import DataArray
Expand DownExpand Up@@ -783,6 +785,126 @@ def test_decompose_affine_into_linear_and_translation():
assert np.allclose(translation.translation, np.array([10, 11]))


@pytest.mark.parametrize(
"matrix,input_axes,output_axes,valid",
[
# non-square matrix are not supported
(
np.array(
[
[1, 2, 3, 10],
[4, 5, 6, 11],
[0, 0, 0, 1],
]
),
("x", "y", "z"),
("x", "y"),
False,
),
(
np.array(
[
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
[0, 0, 1],
]
),
("x", "y"),
("x", "y", "z"),
False,
),
# z axis should not be present
(
np.array(
[
[1, 2, 3, 10],
[4, 5, 6, 11],
[7, 8, 9, 12],
[0, 0, 0, 1],
]
),
("x", "y", "z"),
("x", "y", "z"),
False,
),
# c channel is modified
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[8, 9, 1, 10],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[0, 0, 0, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
(
np.array(
[
[1, 2, 3, 4],
[4, 5, 6, 7],
[0, 0, 1, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
False,
),
# valid, no c channel
(
np.array(
[
[1, 2, 3],
[4, 5, 6],
[0, 0, 1],
]
),
("x", "y"),
("x", "y"),
True,
),
# valid, c channel
(
np.array(
[
[1, 2, 0, 4],
[4, 5, 0, 7],
[0, 0, 1, 0],
[0, 0, 0, 1],
]
),
("x", "y", "c"),
("x", "y", "c"),
True,
),
],
)
@pytest.mark.parametrize("simple_decomposition", [True, False])
def test_decompose_transformation(matrix, input_axes, output_axes, valid, simple_decomposition):
affine = Affine(matrix, input_axes=input_axes, output_axes=output_axes)
context = nullcontext() if valid else pytest.raises(ValueError)
with context:
_ = _decompose_transformation(affine, input_axes=input_axes, simple_decomposition=simple_decomposition)


def test_assign_xy_scale_to_cyx_image():
scale = Scale(np.array([2, 3]), axes=("x", "y"))
image = Image2DModel.parse(np.zeros((10, 10, 10)), dims=("c", "y", "x"))
Expand Down