Skip to content

Bases

Overview

Detect lateral root base points and compute base-related traits. Critical for understanding lateral root emergence patterns and density.

Key functions: - get_bases - Detect base points where laterals emerge from primary - get_base_length - Length of lateral root zone - get_base_ct_density - Lateral root density - get_root_widths - Root width measurements

Quick Example

import sleap_roots as sr

series = sr.Series.load("plant", primary_path="primary.slp", lateral_path="lateral.slp")
primary_pts = series.get_primary_points()
lateral_pts_list = series.get_lateral_points()

# Detect base points
bases = sr.get_bases(primary_pts, lateral_pts_list)
print(f"Base points shape: {bases.shape}")

# Compute base zone length
base_length = sr.get_base_length(bases)
print(f"Lateral root zone length: {base_length:.2f} px")

API Reference

get_bases

get_bases

get_bases(pts: ndarray) -> ndarray

Return bases (r1) from each root.

Parameters:

Name Type Description Default
pts ndarray

Root landmarks as array of shape (instances, nodes, 2) or (nodes, 2).

required

Returns:

Type Description
ndarray

Array of bases (instances, (x, y)). If the input is (nodes, 2), an array of shape (2,) will be returned.

Source code in sleap_roots/bases.py
def get_bases(pts: np.ndarray) -> np.ndarray:
    """Return bases (r1) from each root.

    Args:
        pts: Root landmarks as array of shape `(instances, nodes, 2)` or `(nodes, 2)`.

    Returns:
        Array of bases `(instances, (x, y))`. If the input is `(nodes, 2)`, an array of
            shape `(2,)` will be returned.
    """
    # If the input has shape `(nodes, 2)`, reshape it for consistency
    if pts.ndim == 2:
        pts = pts[np.newaxis, ...]

    # Get the first point of each instance
    base_pts = pts[:, 0]  # Shape is `(instances, 2)`

    # If the input was `(nodes, 2)`, return an array of shape `(2,)` instead of `(1, 2)`
    if base_pts.shape[0] == 1:
        return base_pts[0]

    return base_pts

get_base_length

get_base_length

get_base_length(lateral_base_ys: ndarray) -> float

Get the y-axis difference from the top lateral base to the bottom lateral base.

Parameters:

Name Type Description Default
lateral_base_ys ndarray

y-coordinates of the base points of lateral roots of shape (instances,).

required
Return

The distance between the top base y-coordinate and the deepest base y-coordinate. Returns np.nan when the input array is empty (e.g. plate frames with zero detected laterals). See issue #156.

Source code in sleap_roots/bases.py
def get_base_length(lateral_base_ys: np.ndarray) -> float:
    """Get the y-axis difference from the top lateral base to the bottom lateral base.

    Args:
        lateral_base_ys: y-coordinates of the base points of lateral roots of shape
            `(instances,)`.

    Return:
        The distance between the top base y-coordinate and the deepest
        base y-coordinate. Returns `np.nan` when the input array is empty
        (e.g. plate frames with zero detected laterals). See issue #156.
    """
    # Empty input — zero laterals detected. np.nanmax/nanmin raise ValueError
    # on zero-size arrays, so guard explicitly. (Non-empty all-NaN arrays are
    # fine: np.nanmax([nan, nan]) returns nan with a warning.)
    if lateral_base_ys.size == 0:
        return np.nan
    # Compute the difference between the maximum and minimum y-coordinates
    base_length = np.nanmax(lateral_base_ys) - np.nanmin(lateral_base_ys)
    return base_length

get_base_ct_density

get_base_ct_density

get_base_ct_density(
    primary_length_max: float, lateral_base_pts: ndarray
) -> float

Get a ratio of the number of base points to maximum primary root length.

Parameters:

Name Type Description Default
primary_length_max float

Scalar of maximum primary root length.

required
lateral_base_pts ndarray

Base points of lateral roots as returned by get_bases, shape (instances, 2) or (2,).

required
Return

Scalar of base count density.

Source code in sleap_roots/bases.py
def get_base_ct_density(
    primary_length_max: float, lateral_base_pts: np.ndarray
) -> float:
    """Get a ratio of the number of base points to maximum primary root length.

    Args:
        primary_length_max: Scalar of maximum primary root length.
        lateral_base_pts: Base points of lateral roots as returned by `get_bases`,
            shape `(instances, 2)` or `(2,)`.

    Return:
        Scalar of base count density.
    """
    # Check if the input is valid for lateral_base_pts
    if (
        isinstance(lateral_base_pts, (np.floating, float, np.integer, int))
        or np.isnan(lateral_base_pts).all()
    ):
        return np.nan

    # Handle the case where lateral_base_pts has shape `(2,)`
    if lateral_base_pts.ndim == 1 and lateral_base_pts.shape[0] == 2:
        base_ct = 1  # Only one base point in this case

    # Handle the case where lateral_base_pts has shape `(instances, 2)`
    else:
        base_ct = len(lateral_base_pts[~np.isnan(lateral_base_pts[:, 0])])

    # Handle cases where maximum primary length is zero or NaN to avoid division by zero
    if primary_length_max == 0 or np.isnan(primary_length_max):
        return np.nan

    # Calculate base_ct_density
    base_ct_density = base_ct / primary_length_max

    return base_ct_density

Example:

import sleap_roots as sr

series = sr.Series.load("dicot", primary_path="primary.slp", lateral_path="lateral.slp")
primary_pts = series.get_primary_points()
lateral_pts_list = series.get_lateral_points()

bases = sr.get_bases(primary_pts, lateral_pts_list)
density = sr.get_base_ct_density(bases, primary_pts)

print(f"Lateral root density: {density:.3f} roots/pixel")


get_root_widths

get_root_widths

get_root_widths(
    primary_max_length_pts: ndarray,
    lateral_pts: ndarray,
    tolerance: float = 0.02,
    return_inds: bool = False,
) -> Tuple[ndarray, list, ndarray, ndarray]

Estimate root width using bases of lateral roots.

Parameters:

Name Type Description Default
primary_max_length_pts ndarray

Longest primary root, represented as a 2D array of shape (nodes, 2).

required
lateral_pts ndarray

Lateral roots, represented as a 3D array of shape (n, nodes, 2).

required
tolerance float

Tolerance level for the projection difference between matched roots. Defaults to 0.02.

0.02
return_inds bool

Flag to indicate whether to return matched indices along with distances. Defaults to False.

False

Returns:

Type Description
ndarray
  • If return_inds is False (default): Returns an array of distances between the bases of matched roots. If no matched indices are found, NaN is returned.
list
  • If return_inds is True: Returns a tuple containing the following four elements:
    • matched_dists: Distances between the bases of matched roots. If no matched indices are found, NaN is returned.
    • matched_indices: List of tuples, each containing the indices of matched roots on the left and right sides. A list containing a tuple of NaNs is returned if no matched indices are found.
    • left_bases_final: (n, 2) array containing the (x, y) coordinates of the left bases of the matched roots. An array of NaNs is returned if no matched indices are found.
    • right_bases_final: (n, 2) array containing the (x, y) coordinates of the right bases of the matched roots. An array of NaNs is returned if no matched indices are found.
Source code in sleap_roots/bases.py
def get_root_widths(
    primary_max_length_pts: np.ndarray,
    lateral_pts: np.ndarray,
    tolerance: float = 0.02,
    return_inds: bool = False,
) -> Tuple[np.ndarray, list, np.ndarray, np.ndarray]:
    """Estimate root width using bases of lateral roots.

    Args:
        primary_max_length_pts: Longest primary root, represented
            as a 2D array of shape (nodes, 2).
        lateral_pts: Lateral roots, represented as a 3D array of
            shape (n, nodes, 2).
        tolerance: Tolerance level for the projection difference between matched roots.
            Defaults to 0.02.
        return_inds: Flag to indicate whether to return matched indices along with
            distances. Defaults to False.

    Returns:
        - If `return_inds` is False (default):
            Returns an array of distances between the bases of matched roots. If no
            matched indices are found, NaN is returned.

        - If `return_inds` is True:
            Returns a tuple containing the following four elements:
                - matched_dists: Distances between the bases of matched roots. If no
                    matched indices are found, NaN is returned.
                - matched_indices: List of tuples, each containing the indices
                    of matched roots on the left and right sides. A list containing a
                    tuple of NaNs is returned if no matched indices are found.
                - left_bases_final: (n, 2) array containing the (x, y)
                    coordinates of the left bases of the matched roots. An array of
                    NaNs is returned if no matched indices are found.
                - right_bases_final: (n, 2) array containing the (x, y)
                    coordinates of the right bases of the matched roots. An array of
                    NaNs is returned if no matched indices are found.
    """
    # Validate tolerance
    if tolerance <= 0:
        raise ValueError("Tolerance should be a positive number")

    # Check array dimensions
    if primary_max_length_pts.ndim != 2 or lateral_pts.ndim != 3:
        raise ValueError("Input arrays should be 2-dimensional and 3-dimensional")

    # Check the shape of the last dimensions
    if primary_max_length_pts.shape[1] != 2 or lateral_pts.shape[2] != 2:
        raise ValueError("The last dimension should contain x and y coordinates")

    # Initialize default return values
    default_dists = np.nan
    default_indices = [(np.nan, np.nan)]  # List of tuples with NaN values
    default_left_bases = np.full((1, 2), np.nan)  # 2D array filled with NaN values
    default_right_bases = np.full((1, 2), np.nan)  # 2D array filled with NaN values

    # Check for minimum length, or all NaNs in arrays
    if (
        len(primary_max_length_pts) < 2
        or len(lateral_pts) < 2
        or np.isnan(primary_max_length_pts).all()
        or np.isnan(lateral_pts).all()
    ):
        if return_inds:
            # Return the distances, matched indices, and the final left and right bases
            return (
                default_dists,
                default_indices,
                default_left_bases,
                default_right_bases,
            )
        else:
            # Default: Return the distances
            return default_dists

    # Filter out any NaN points from the primary root points
    primary_pts_filtered = primary_max_length_pts[
        ~np.isnan(primary_max_length_pts).any(axis=-1)
    ]
    # Create a LineString object for the primary root
    primary_line = LineString(primary_pts_filtered)

    # Identify lateral roots that have a defined base (not NaN)
    has_base = ~np.isnan(lateral_pts[:, 0, 0])
    # Filter the lateral roots based on the valid base points
    lateral_pts = lateral_pts[has_base]

    # Determine if the base of each lateral root is to the left or right of the rest of
    # the root
    is_left = lateral_pts[:, 0, 0] > np.nanmin(lateral_pts[:, 1:, 0], axis=1)

    # If all lateral roots are on the same side, return default values
    if is_left.all() or (~is_left).all():
        if return_inds:
            # Return the distances, matched indices, and the final left and right bases
            return (
                default_dists,
                default_indices,
                default_left_bases,
                default_right_bases,
            )
        else:
            # Default: Return the distances
            return default_dists

    # Split lateral roots into left and right bases
    left_bases, right_bases = lateral_pts[is_left, 0], lateral_pts[~is_left, 0]

    # Find the nearest points on the primary root for each right base
    nearest_primary_right = [
        nearest_points(primary_line, Point(right_base))[0] for right_base in right_bases
    ]

    # Find the nearest points on the primary root for each left base
    nearest_primary_left = [
        nearest_points(primary_line, Point(left_base))[0] for left_base in left_bases
    ]

    # Calculate the normalized projection of each nearest point on the primary root
    # (right side)
    nearest_primary_norm_right = np.array(
        [primary_line.project(pt, normalized=True) for pt in nearest_primary_right]
    )

    # Calculate the normalized projection of each nearest point on the primary root
    # (left side)
    nearest_primary_norm_left = np.array(
        [primary_line.project(pt, normalized=True) for pt in nearest_primary_left]
    )

    # Create a cost matrix based on the differences in projections between left and
    # right bases
    cost_matrix = np.abs(
        nearest_primary_norm_left.reshape(-1, 1)
        - nearest_primary_norm_right.reshape(1, -1)
    )

    # Use the Hungarian algorithm to find an optimal pairing that minimizes the sum of
    # projection differences
    left_inds, right_inds = linear_sum_assignment(cost_matrix)

    # Filter out pairs where the projection difference exceeds the given tolerance
    valid_pairs = cost_matrix[left_inds, right_inds] <= tolerance
    left_inds = left_inds[valid_pairs]
    right_inds = right_inds[valid_pairs]

    # If no valid pairs remain, return default values
    if len(left_inds) == 0 or len(right_inds) == 0:
        if return_inds:
            # Return the distances, matched indices, and the final left and right bases
            return (
                default_dists,
                default_indices,
                default_left_bases,
                default_right_bases,
            )
        else:
            # Default: Return the distances
            return default_dists

    # Filter out pairs that do not intersect the primary root
    is_intersecting = np.array(
        [
            primary_line.intersects(
                LineString([left_bases[left_ind], right_bases[right_ind]])
            )
            for left_ind, right_ind in zip(left_inds, right_inds)
        ]
    )
    left_inds = left_inds[is_intersecting]
    right_inds = right_inds[is_intersecting]

    # If no valid pairs remain, return default values
    if len(left_inds) == 0 or len(right_inds) == 0:
        if return_inds:
            # Return the distances, matched indices, and the final left and right bases
            return (
                default_dists,
                default_indices,
                default_left_bases,
                default_right_bases,
            )
        else:
            # Default: Return the distances
            return default_dists

    # Update the left and right bases of the final paired coordinates
    left_bases_final = left_bases[left_inds]
    right_bases_final = right_bases[right_inds]

    # Calculate the Euclidean distance between the bases of the valid pairs
    match_dists = np.linalg.norm(
        left_bases[left_inds] - right_bases[right_inds],
        axis=-1,
    )

    # Create a list of tuples representing the indices of the matched pairs
    matched_indices = list(zip(left_inds, right_inds))

    if return_inds:
        # Return the distances, matched indices, and the final left and right bases
        return match_dists, matched_indices, left_bases_final, right_bases_final
    else:
        # Default: Return the distances
        return match_dists