Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature(measure): Adds the height_profiles sub-module #859

Merged
merged 2 commits into from
Jun 22, 2024

Conversation

ns-rse
Copy link
Collaborator

@ns-rse ns-rse commented Jun 11, 2024

Further work on height profiles this PR adds the topostats.measure.height_profiles sub-module which for a given pair of feret coordinates...

  1. Determines the orientation of of the feret co-ordinates relative to horizontal.
  2. Rotates the image so that the feret is horizontal.
  3. Recalculates the co-ordinates of the feret after rotation.
  4. Extracts the height profile along the feret.

Includes a function in topostats.plotting.plot_height_profiles() which produces a line-plot of multiple height profiles.

Test shapes defined tests/measure/test_feret.py moved to fixtures in tests/measure/conftest.py to avoid duplication and tests for all functions are included. Because these fixtures are then used in @pytest.mark.parameterize() it has necessitated using the request fixture and its .getfixturevalue() method.

More on this can be read in this blog
post
.

ToDo...

Still a fair few steps to integrate this into the processing.

  • Add configuration options to topostats/default_config.yaml of whether to calculate height_profiles.
  • Update GrainStats() to calculate the height profile for the image being processed if required based on configuration.
  • Return the height_profile (1-D Numpy array) as part of the tuple GrainStats() returns.
  • Collect height_profile across grains into a dictionary (may require current code as written to be adapted to work with dictionaries, currently works with lists in
    plot_height_profiles()).
  • Add functionality to write the profiles to JSON for subsequent use/plotting (e.g. customising style/axis labels/etc.
  • Currently only configured to give height profiles across maximum feret, would need additional work and consideration for profiles perpendicular to the mid-point of the maximum feret (but code is structured such that the extraction of this would be relatively easy, but would require additional work on plotting and returning data).

Related : #748 #755

In starting work on extracting the height profiles now the feret module is in place (see #755) I realised I would need
some shapes to test rotation. These already existed as arrays defined in `tests/measure/test_feret.py` and in order to
avoid duplication these have been moved to `tests/measure/conftest.py` as fixtures.

Because these fixtures are then used in `@pytest.mark.parameterize()` it has necessitated using the `request` fixture
and its `.getfixturevalue()` method. A note is left in place for future reference.

More on this can be read in [this blog
post](https://blog.nshephard.dev/posts/pytest-param/#parameterising-with-fixtures).
@ns-rse ns-rse added GrainStats Issues pertaining to the GrainStats class Plotting Issues pertaining to the plotting class tests Issues pertaining to testing measure Issues relating to the mesaure sub-module labels Jun 11, 2024
@ns-rse ns-rse changed the title test(topostats.measure): Convert test images to fixtures feature(measure): Adds the height_profiles sub-module Jun 11, 2024
@ns-rse ns-rse force-pushed the ns-rse/748-height-profiles branch from 6fc080f to 2d96a6e Compare June 12, 2024 08:19
@ns-rse
Copy link
Collaborator Author

ns-rse commented Jun 12, 2024

@llwiggins has identified a scenario where behaviour is not as expected.

import numpy as np
from topostats.measure import height_profiles

circle = np.array([
    [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0],
    [0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0],
    [0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0],
    [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
    [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
    [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
    [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
    [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
    [0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0],
    [0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0],
    [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0]
])

binary_mask = circle

height_profiles.extract_feret_profiles(circle, binary_mask)

This fails the following test...

import pytest
import numpy.typing as npt

@pytest.mark.parametrize(
    ("img", "target"),
    [
        pytest.param(
            {
                "img": np.asarray(
                    [
                        [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0],
                        [0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0],
                        [0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0],
                        [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
                        [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
                        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
                        [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
                        [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
                        [0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0],
                        [0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0],
                        [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0],
                    ]
                ),
                "skeleton": np.asarray(
                    [
                        [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0],
                        [0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0],
                        [0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0],
                        [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
                        [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
                        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
                        [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
                        [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
                        [0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0],
                        [0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0],
                        [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0],
                    ]
                ),
            },
            np.asarray([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]),
        ),
    ],
)
def test_extract_feret_profiles2(img: npt.NDArray, target: npt.NDArray, request) -> None:
    """Test extract_feret_profiles()."""
    # _img = request.getfixturevalue(img)
    # Convert boolean to 0/1 and extract profiles
    feret_profiles = height_profiles.extract_feret_profiles(img=img["img"], skeleton=np.where(img["skeleton"], 1, 0))
    # This fails...
    # feret_profiles = height_profiles.extract_feret_profiles(img=_img["img"], skeleton=_img["skeleton"])
    np.testing.assert_array_almost_equal(feret_profiles, target, decimal=21)
E           AssertionError: 
E           Arrays are not almost equal to 21 decimals
E           
E           Mismatched elements: 10 / 11 (90.9%)
E           Max absolute difference: 1
E           Max relative difference: 1.
E            x: array([0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0])
E            y: array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1])

Why does this fail?

Whilst the image doesn't need rotating this fails because the maximum feret co-ordinates after rotating (which wasn't in effect performed) are a vertical line rather than the expected horizontal line...

rotated_feret_stats={'max_feret': 10.0, 
                     'min_feret': 8.48528137423857, 
                     'max_feret_coords': array([[10,  5],
                                                [ 0,  5]]),
                     'min_feret_coords': array([[1., 3.],
                                                [7., 9.]])}

And the profile() function is very basic, taking an image and the row from which the profile is to be returned...

def profile(img: npt.NDArray, row: int) -> npt.NDArray:
    """
    ...
    """
    try:
        return img[row]
    except IndexError as e:
        raise IndexError("The slice (row) is outside the image boundary.") from e

And so because the row being used is rotated_feret_stats["max_feret_coords"][1, 0] a slice is taken across the first row which is why we see the returned value.

@ns-rse ns-rse force-pushed the ns-rse/748-height-profiles branch 5 times, most recently from 6257874 to 716760a Compare June 13, 2024 09:14
@ns-rse
Copy link
Collaborator Author

ns-rse commented Jun 13, 2024

@llwiggins I've revised how the profiles are obtained and ditched the rotation method instead using scipy.interpolate.RegularGridInterpolator.

As a consequence the topostats.measure.heights_profile sub-module is considerably smaller and consists of a single function interpolate_height_profiles() which performs interpolation of heights directly between two points by first getting evenly spaced points on the x-axis between the maximum feret co-ordinates and then interpolating the corresponding y-coordinates. These x and y co-ordinates then have the height interpolated using RegularGridInterpolator.

Arguments can be passed via **kwargs to RegularGridInterpolator() and the effect of modifying the method is tested. I've included your example of a basic circle which caused problems previously and those tests pass now (whenever a scenario that causes failure is encountered its a good candidate for a test so thank you).

I've found for some reason that interpolation (using the method = linear) on M$-Win gives different results so have marked those tests to be conditionally skipped if running on a Windows system.

Further to #755 this adds the `topostats.measure.height_profiles` sub-module which interpolates the heights between the
maximum feret co-ordinates.

Includes a function in `topostats.plotting.plot_height_profiles()` which produces a line-plot of multiple height
profiles.

ToDo...

Still a fair few steps to integrate this into the processing.

+ Add configuration options to `topostats/default_config.yaml` of whether to calculate `height_profiles`.
+ Add configuration option for the `scipy.interpolate.RegularGridInterpolator()` options which are passed via
  `**kwargs`.
+ Update `GrainStats()` to calculate the height profile for the image being processed if required.
+ Return the `height_profile` (1-D Numpy array).
+ Collect `height_profile` acrss grains into a dictionary (may require current code as written to be adapted to work
  with dictionaries, currently works with lists in `plot_height_profiles()`).
+ Add functionality to write the profiles to JSON for subsequent use/plotting (e.g. customising style/axis labels/etc.
  of plot)

Related : #748 #755
@ns-rse ns-rse force-pushed the ns-rse/748-height-profiles branch from 716760a to 058c546 Compare June 15, 2024 21:47
@SylviaWhittle
Copy link
Collaborator

Been reading the PR and looks good to me. Much prefer the interpolation method, so thank you. Not given it a thorough enough read & tinker with to approve yet, but I like the method and implementation! I'll test it out soon (once my report is done at the latest). Apologies for not being active much on TopoStats recently.

Copy link
Collaborator

@llwiggins llwiggins left a comment

Choose a reason for hiding this comment

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

Thank you @ns-rse for revising the original method for obtaining profiles - I really like the use of scipy's RegularGridInterpolator() here. I've tested the code out with various dummy examples, plotting graphs throughout the workflow to assess different stages, and all seems to be working as expected. Happy for this work to be merged. 😄

@ns-rse ns-rse added this pull request to the merge queue Jun 22, 2024
Merged via the queue into main with commit f5b40f7 Jun 22, 2024
14 checks passed
@ns-rse ns-rse deleted the ns-rse/748-height-profiles branch June 22, 2024 21:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
GrainStats Issues pertaining to the GrainStats class measure Issues relating to the mesaure sub-module Plotting Issues pertaining to the plotting class tests Issues pertaining to testing
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants