diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 553ddbf26782d250f14bb1e9d98ee8b41744de60..5738c126c7c3aedc3f931d29aeb7c8fa91540af8 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -96,9 +96,9 @@ variables: - branches@fsl/fslpy -.only_master: &only_master +.only_main: &only_main only: - - master@fsl/fslpy + - main@fsl/fslpy .only_release_branches: &only_release_branches @@ -210,7 +210,7 @@ style: ############# # I would like to have separate doc deploys for -# both the master and latest release branches, +# both the main and latest release branches, # but this is awkward with gitlab pages. So # currently the most recently executed pages # job is the one that gets deployed. diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e2fd66a75e6ca97652f15f2280b5ebc7121d8642..c95570f231ee6a1a098251438b200abbc208bc3f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,14 @@ order. -------------------------- +Added +^^^^^ + + +* New :func:`.affine.flip` function, for applying a flip/inversion to the + axes of an affine transformation (!403). + + Changed ^^^^^^^ diff --git a/README.rst b/README.rst index 352946137bbe145b49870d572ca7b6c6325671d6..4eafb12b92e44ca4b191259461576a40cc20857f 100644 --- a/README.rst +++ b/README.rst @@ -41,11 +41,10 @@ Dependencies All of the core dependencies of ``fslpy`` are listed in the -`requirements.txt <requirements.txt>`_ file. +`pyproject.toml <pyproject.toml>`_ file. -Some extra dependencies are listed in -`requirements-extra.txt <requirements-extra.txt>`_ -which provide addditional functionality: +Some optional dependencies (labelled ``extra`` in ``pyproject.toml``) provide +addditional functionality: - ``wxPython``: The `fsl.utils.idle <fsl/utils/idle.py>`_ module has functionality to schedule functions on the ``wx`` idle loop. @@ -70,13 +69,13 @@ specific platform:: Once wxPython has been installed, you can type the following to install the -rest of the extra dependencies:: +remaining optional dependencies:: - pip install fslpy[extras] + pip install "fslpy[extra]" -Dependencies for testing and documentation are listed in the -`requirements-dev.txt <requirements-dev.txt>`_ file. +Dependencies for testing and documentation are also listed in ``pyproject.toml``, +and are respectively labelled as ``test`` and ``doc``. Non-Python dependencies @@ -104,10 +103,10 @@ https://open.win.ox.ac.uk/pages/fsl/fslpy/. ``fslpy`` is documented using `sphinx <http://http://sphinx-doc.org/>`_. You can build the API documentation by running:: - pip install -r requirements-dev.txt - python setup.py doc + pip install ".[doc]" + sphinx-build doc html -The HTML documentation will be generated and saved in the ``doc/html/`` +The HTML documentation will be generated and saved in the ``html/`` directory. @@ -116,11 +115,15 @@ Tests Run the test suite via:: - pip install -r requirements-dev.txt - python setup.py test + pip install ".[test]" + pytest -A test report will be generated at ``report.html``, and a code coverage report -will be generated in ``htmlcov/``. + +Some tests will only pass if the test environment meets certain criteria - +refer to the ``tool.pytest.init_options`` section of +[``pyproject.toml``](pyproject.toml) for a list of [pytest +marks](https://docs.pytest.org/en/7.1.x/example/markers.html) which can be +selectively enabled or disabled. Contributing diff --git a/fsl/scripts/atlasq.py b/fsl/scripts/atlasq.py index 01ed67b4c373247b65e4571b14dba2d6a57f16ea..b0ef32a949f8e127f8544442b87fe8cb29980ceb 100644 --- a/fsl/scripts/atlasq.py +++ b/fsl/scripts/atlasq.py @@ -569,10 +569,10 @@ def parseArgs(args): usages = { 'main' : 'usage: atlasq [-h] command [options]', 'ohi' : textwrap.dedent(""" - usage: atlasq ohi -h - atlasq ohi --dumpatlases - atlasq ohi -a atlas -c X,Y,Z - atlasq ohi -a atlas -m mask + usage: atlasquery -h + atlasquery --dumpatlases + atlasquery -a atlas -c X,Y,Z + atlasquery -a atlas -m mask """).strip(), 'list' : 'usage: atlasq list [-e]', 'summary' : 'usage: atlasq summary atlas', diff --git a/fsl/tests/test_transform/test_affine.py b/fsl/tests/test_transform/test_affine.py index 1512109af6c10a3047f2a4174d7d897bdc2ab3bd..100b5db6daaa1aaa9e703b4eb5d1391e4375acbd 100644 --- a/fsl/tests/test_transform/test_affine.py +++ b/fsl/tests/test_transform/test_affine.py @@ -147,6 +147,24 @@ def test_normalise(seed): assert np.all(pars) +def test_flip(): + + shape = [5, 5, 5] + xform = np.diag([2, 2, 2, 1]) + xform[:3, 3] = [-4.5, -4.5, -4.5] + + allaxes = list(it.chain( + it.combinations([0, 1, 2], 1), + it.combinations([0, 1, 2], 2), + it.combinations([0, 1, 2], 3))) + + for axes in allaxes: + flipped = affine.flip(shape, xform, *axes) + flo, fhi = affine.axisBounds(shape, flipped, boundary=None) + assert np.all(np.isclose(flo, [-5, -5, -5])) + assert np.all(np.isclose(fhi, [ 5, 5, 5])) + + def test_scaleOffsetXform(): # Test numerically @@ -209,6 +227,17 @@ def test_scaleOffsetXform(): result = affine.scaleOffsetXform(1, offset) assert np.all(np.isclose(result, expected)) + # both args are optional + assert np.all(np.isclose( + affine.scaleOffsetXform(), np.eye(4))) + assert np.all(np.isclose( + affine.scaleOffsetXform([1, 2, 3]), np.diag([1, 2, 3, 1]))) + + exp = np.eye(4) + exp[:3, 3] = [1, 2, 3] + assert np.all(np.isclose( + affine.scaleOffsetXform(None, [1, 2, 3]), exp)) + def test_compose_and_decompose(): diff --git a/fsl/transform/affine.py b/fsl/transform/affine.py index 83ee70b77e724f3f2684810cc3299d4c6416e239..fe8efd35e4faed848e1a9099d297db13166ce6d3 100644 --- a/fsl/transform/affine.py +++ b/fsl/transform/affine.py @@ -13,6 +13,7 @@ transformations. The following functions are available: transform scaleOffsetXform invert + flip concat compose decompose @@ -79,7 +80,41 @@ def normalise(vec): return n -def scaleOffsetXform(scales, offsets): +def flip(shape, xform, *axes): + """Applies a flip/inversion to an affine transform along specified axes. + + :arg shape: The ``(x, y, z)`` shape of the data. + :arg xform: Transformation matrix which transforms voxel coordinates + to a world coordinate system. + :arg axes: Indices of axes to flip + """ + + if len(axes) == 0: + return xform + + axes = sorted(set(axes)) + if any(a not in (0, 1, 2) for a in axes): + raise ValueError(f'Invalid axis: {axes}') + + los, his = axisBounds(shape, xform, axes, boundary=None) + scales = [1, 1, 1] + pre = [0, 0, 0] + post = [0, 0, 0] + + for ax, lo, hi in zip(axes, los, his): + mid = lo + (hi - lo) / 2 + scales[ax] = -1 + pre[ ax] = -mid + post[ ax] = mid + + pre = scaleOffsetXform(None, pre) + post = scaleOffsetXform(None, post) + scales = scaleOffsetXform(scales) + + return concat(post, scales, pre, xform) + + +def scaleOffsetXform(scales=None, offsets=None): """Creates and returns an affine transformation matrix which encodes the specified scale(s) and offset(s). @@ -95,6 +130,9 @@ def scaleOffsetXform(scales, offsets): :returns: A ``numpy.float32`` array of size :math:`4 \\times 4`. """ + if scales is None: scales = [1, 1, 1] + if offsets is None: offsets = [0, 0, 0] + oktypes = (abc.Sequence, np.ndarray) if not isinstance(scales, oktypes): scales = [scales]