Skip to content

Commit

Permalink
feature(measure): Adds the height_profiles sub-module
Browse files Browse the repository at this point in the history
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
  • Loading branch information
ns-rse committed Jun 12, 2024
1 parent 9eb3d52 commit b221ca3
Show file tree
Hide file tree
Showing 11 changed files with 773 additions and 12 deletions.
130 changes: 120 additions & 10 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@
from pathlib import Path

import numpy as np
import numpy.typing as npt
import pandas as pd
import pySPM
import pytest
import yaml
from skimage import draw, filters
from skimage.morphology import skeletonize

import topostats
from topostats.filters import Filters
Expand Down Expand Up @@ -164,32 +167,32 @@ def plotting_config_with_plot_dict(default_config: dict) -> dict:


@pytest.fixture()
def image_random() -> np.ndarray:
def image_random() -> npt.NDArray:
"""Random image as NumPy array."""
rng = np.random.default_rng(seed=1000)
return rng.random((1024, 1024))


@pytest.fixture()
def small_array() -> np.ndarray:
def small_array() -> npt.NDArray:
"""Small (10x10) image array for testing."""
return RNG.random(SMALL_ARRAY_SIZE)


@pytest.fixture()
def small_mask() -> np.ndarray:
def small_mask() -> npt.NDArray:
"""Small (10x10) mask array for testing."""
return RNG.uniform(low=0, high=1, size=SMALL_ARRAY_SIZE) > 0.5


@pytest.fixture()
def synthetic_scars_image() -> np.ndarray:
def synthetic_scars_image() -> npt.NDArray:
"""Small synthetic image for testing scar removal."""
return np.load(RESOURCES / "test_scars_synthetic_scar_image.npy")


@pytest.fixture()
def synthetic_marked_scars() -> np.ndarray:
def synthetic_marked_scars() -> npt.NDArray:
"""Small synthetic boolean array of marked scar coordinates corresponding to synthetic_scars_image."""
return np.load(RESOURCES / "test_scars_synthetic_mark_scars.npy")

Expand Down Expand Up @@ -777,7 +780,7 @@ def minicircle_all_statistics() -> pd.DataFrame:

# Skeletonizing Fixtures
@pytest.fixture()
def skeletonize_circular() -> np.ndarray:
def skeletonize_circular() -> npt.NDArray:
"""A circular molecule for testing skeletonizing."""
return np.array(
[
Expand Down Expand Up @@ -807,13 +810,13 @@ def skeletonize_circular() -> np.ndarray:


@pytest.fixture()
def skeletonize_circular_bool_int(skeletonize_circular: np.ndarray) -> np.ndarray:
def skeletonize_circular_bool_int(skeletonize_circular: np.ndarray) -> npt.NDArray:
"""A circular molecule for testing skeletonizing as a boolean integer array."""
return np.array(skeletonize_circular, dtype="bool").astype(int)


@pytest.fixture()
def skeletonize_linear() -> np.ndarray:
def skeletonize_linear() -> npt.NDArray:
"""A linear molecule for testing skeletonizing."""
return np.array(
[
Expand Down Expand Up @@ -846,9 +849,116 @@ def skeletonize_linear() -> np.ndarray:


@pytest.fixture()
def skeletonize_linear_bool_int(skeletonize_linear) -> np.ndarray:
def skeletonize_linear_bool_int(skeletonize_linear) -> npt.NDArray:
"""A linear molecule for testing skeletonizing as a boolean integer array."""
return np.array(skeletonize_linear, dtype="bool").astype(int)


# Curvature Fixtures
# Pruning and Height profile fixtures
#
# Skeletons are generated by...
#
# 1. Generate random boolean images using scikit-image.
# 2. Skeletonize these shapes (gives boolean skeletons), these are our targets
# 3. Scale the skeletons by a factor (100)
# 4. Apply Gaussian filter to blur the heights and give an example original im
# for.


def _generate_heights(skeleton: npt.NDArray, scale: float = 100, sigma: float = 5.0, cval: float = 20.0) -> npt.NDArray:
"""Generate heights from skeletons by scaling image and applying Gaussian blurring.
Uses scikit-image 'skimage.filters.gaussian()' to generate heights from skeletons.
Parameters
----------
skeleton : npt.NDArray
Binary array of skeleton.
scale : float
Factor to scale heights by. Boolean arrays are 0/1 and so the factor will be the height of the skeleton ridge.
sigma : float
Standard deviation for Gaussian kernel passed to `skimage.filters.gaussian()'.
cval : float
Value to fill past edges of input, passed to `skimage.filters.gaussian()'.
Returns
-------
npt.NDArray
Array with heights of image based on skeleton which will be the backbone and target.
"""
return filters.gaussian(skeleton * scale, sigma=sigma, cval=cval)


def _generate_random_skeleton(**extra_kwargs):
"""Generate random skeletons and heights using skimage.draw's random_shapes()."""
kwargs = {
"image_shape": (128, 128),
"max_shapes": 20,
"channel_axis": None,
"shape": None,
"allow_overlap": True,
}
heights = {"scale": 100, "sigma": 5.0, "cval": 20.0}
random_image, _ = draw.random_shapes(**kwargs, **extra_kwargs)
mask = random_image != 255
skeleton = skeletonize(mask)
return {"img": _generate_heights(skeleton, **heights), "skeleton": skeleton}


@pytest.fixture()
# def pruning_skeleton_loop1(heights=heights) -> dict:
def skeleton_loop1() -> dict:
"""Skeleton with loop to be retained and side-branches."""
return _generate_random_skeleton(rng=1, min_size=20)


@pytest.fixture()
def skeleton_loop2() -> dict:
"""Skeleton with loop to be retained and side-branches."""
return _generate_random_skeleton(rng=165103, min_size=60)


@pytest.fixture()
def skeleton_linear1() -> dict:
"""Linear skeleton with lots of large side-branches, some forked."""
return _generate_random_skeleton(rng=13588686514, min_size=20)


@pytest.fixture()
def skeleton_linear2() -> dict:
"""Linear Skeleton with simple fork at one end."""
return _generate_random_skeleton(rng=21, min_size=20)


@pytest.fixture()
def skeleton_linear3() -> dict:
"""Linear Skeletons (i.e. multiple) with branches."""
return _generate_random_skeleton(rng=894632511, min_size=20)


# Helper functions for visualising skeletons and heights
#
# def pruned_plot(gen_shape: dict) -> None:
# """Plot the original skeleton, its derived height and the pruned skeleton."""
# img_skeleton = gen_shape()
# pruned = topostatsPrune(
# img_skeleton["heights"],
# img_skeleton["skeleton"],
# max_length=-1,
# height_threshold=90,
# method_values="min",
# method_outlier="abs",
# )
# pruned_skeleton = pruned._prune_by_length(pruned.skeleton, pruned.max_length)
# fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2)
# ax1.imshow(img_skeleton["skeleton"])
# ax2.imshow(img_skeleton["heights"])
# ax3.imshow(pruned_skeleton)
# plt.show()


# pruned_plot(skeleton_loop1)
# pruned_plot(skeleton_loop2)
# pruned_plot(skeleton_linear1)
# pruned_plot(skeleton_linear2)
# pruned_plot(skeleton_linear3)

0 comments on commit b221ca3

Please sign in to comment.