Source code for topostats.tracing.tracingfuncs

"""Miscellaneous tracing functions."""

from __future__ import annotations
import numpy as np
import numpy.typing as npt
import matplotlib.pyplot as plt
import math

from topostats.utils import convolve_skeleton


[docs] class reorderTrace: """ Class to aid the consecutive ordering of adjacent coordinates of a pixel grid. """
[docs] @staticmethod def linearTrace(trace_coordinates: list | npt.NDArray) -> npt.NDArray: """ Function to order the points from a linear trace. This works by checking the local neighbours for a given pixel (starting at one of the ends). If this pixel has only one neighbour in the array of unordered points, this must be the next pixel in the trace -- and it is added to the ordered points trace and removed from the remaining_unordered_coords array. If there is more than one neighbouring pixel, a fairly simple function (check_vectors_candidate_points) finds which pixel incurs the smallest change in angle compared with the rest of the trace and chooses that as the next point. This process is repeated until all the points are placed in the ordered trace array or the other end point is reached. Parameters ---------- trace_coordinates : list | npt.NDArray Unordered trace coordinates. Returns ------- npt.NDArray An array of ordered coordinates from one end of a linear trace to the other. """ try: trace_coordinates = trace_coordinates.tolist() except AttributeError: # array is already a python list pass # Find one of the end points for i, (x, y) in enumerate(trace_coordinates): if genTracingFuncs.count_and_get_neighbours(x, y, trace_coordinates)[0] == 1: ordered_points = [[x, y]] trace_coordinates.pop(i) break remaining_unordered_coords = trace_coordinates[:] while remaining_unordered_coords: if len(ordered_points) > len(trace_coordinates): break x_n, y_n = ordered_points[-1] # get the last point to be added to the array and find its neighbour no_of_neighbours, neighbour_array = genTracingFuncs.count_and_get_neighbours( x_n, y_n, remaining_unordered_coords ) if ( no_of_neighbours == 1 ): # if there's only one candidate - its the next point add it to array and delete from candidate points ordered_points.append(neighbour_array[0]) remaining_unordered_coords.pop(remaining_unordered_coords.index(neighbour_array[0])) continue elif no_of_neighbours > 1: best_next_pixel = genTracingFuncs.check_vectors_candidate_points(ordered_points, neighbour_array) ordered_points.append(best_next_pixel) remaining_unordered_coords.pop(remaining_unordered_coords.index(best_next_pixel)) continue elif no_of_neighbours == 0: # nn, neighbour_array_all_coords = genTracingFuncs.count_and_get_neighbours(x_n, y_n, trace_coordinates) # best_next_pixel = genTracingFuncs.check_vectors_candidate_points(ordered_points, neighbour_array_all_coords) best_next_pixel = genTracingFuncs.find_best_next_point( x_n, y_n, ordered_points, remaining_unordered_coords ) if not best_next_pixel: return np.array(ordered_points) ordered_points.append(best_next_pixel) # If the tracing has reached the other end of the trace then its finished if genTracingFuncs.count_and_get_neighbours(x_n, y_n, trace_coordinates)[0] == 1: break return np.array(ordered_points)
[docs] @staticmethod def circularTrace(trace_coordinates): """ Alternative implementation of the linear tracing algorithm but adapted to work with circular DNA molecules. Parameters ---------- trace_coordinates : list | npt.NDArray Unordered trace coordinates. Returns ------- npt.NDArray An array of ordered coordinates from one end of a linear trace to the other. """ try: trace_coordinates = trace_coordinates.tolist() except AttributeError: # array is already a python list pass remaining_unordered_coords = trace_coordinates[:] # Find a sensible point to start of the end points for i, (x, y) in enumerate(trace_coordinates): if genTracingFuncs.count_and_get_neighbours(x, y, trace_coordinates)[0] == 2: ordered_points = [[x, y]] remaining_unordered_coords.pop(i) break # Randomly choose one of the neighbouring points as the next point x_n = ordered_points[0][0] y_n = ordered_points[0][1] no_of_neighbours, neighbour_array = genTracingFuncs.count_and_get_neighbours( x_n, y_n, remaining_unordered_coords ) ordered_points.append(neighbour_array[0]) remaining_unordered_coords.pop(remaining_unordered_coords.index(neighbour_array[0])) count = 0 while remaining_unordered_coords: x_n, y_n = ordered_points[-1] # get the last point to be added to the array and find its neighbour no_of_neighbours, neighbour_array = genTracingFuncs.count_and_get_neighbours( x_n, y_n, remaining_unordered_coords ) if ( no_of_neighbours == 1 ): # if there's only one candidate - its the next point add it to array and delete from candidate points ordered_points.append(neighbour_array[0]) remaining_unordered_coords.pop(remaining_unordered_coords.index(neighbour_array[0])) continue elif no_of_neighbours > 1: best_next_pixel = genTracingFuncs.check_vectors_candidate_points(ordered_points, neighbour_array) ordered_points.append(best_next_pixel) remaining_unordered_coords.pop(remaining_unordered_coords.index(best_next_pixel)) continue elif len(ordered_points) > len(trace_coordinates): vector_start_end = abs( math.hypot( ordered_points[0][0] - ordered_points[-1][0], ordered_points[0][1] - ordered_points[-1][1] ) ) if vector_start_end > 5: # Checks if trace has basically finished i.e. is close to where it started ordered_points.pop(-1) return np.array(ordered_points), False else: break elif no_of_neighbours == 0: # Check if the tracing is finished nn, neighbour_array_all_coords = genTracingFuncs.count_and_get_neighbours(x_n, y_n, trace_coordinates) if ordered_points[0] in neighbour_array_all_coords: break # Checks for bug that happens when tracing messes up if ordered_points[-1] == ordered_points[-3]: ordered_points = ordered_points[:-6] return np.array(ordered_points), False # Maybe at a crossing with all neighbours deleted - this is crucially a point where errors often occur else: # best_next_pixel = genTracingFuncs.check_vectors_candidate_points(ordered_points, remaining_unordered_coords) best_next_pixel = genTracingFuncs.find_best_next_point( x_n, y_n, ordered_points, remaining_unordered_coords ) if not best_next_pixel: return np.array(ordered_points), False vector_to_new_point = abs(math.hypot(best_next_pixel[0] - x_n, best_next_pixel[1] - y_n)) if vector_to_new_point > 5: # arbitrary distinction but mostly valid probably return np.array(ordered_points), False else: ordered_points.append(best_next_pixel) if ordered_points[-1] == ordered_points[-3] and ordered_points[-3] == ordered_points[-5]: ordered_points = ordered_points[:-6] return np.array(ordered_points), False continue ordered_points.append(ordered_points[0]) return np.array(ordered_points), True
[docs] class genTracingFuncs: """Class of tracing functions."""
[docs] @staticmethod def count_and_get_neighbours(x: int, y: int, trace_coordinates: list) -> tuple[int, list]: """ Count the number of neighbouring points for a coordinate and an array containing those points. Parameters ---------- x : int X coordinate. y : int Y coordinate. trace_coordinates : list Coordinates of the trace. Returns ------- tuple The number of neighbours and the coordinates of the neighbouring points. """ neighbour_array = [] number_of_neighbours = 0 if [x, y + 1] in trace_coordinates: neighbour_array.append([x, y + 1]) number_of_neighbours += 1 if [x + 1, y + 1] in trace_coordinates: neighbour_array.append([x + 1, y + 1]) number_of_neighbours += 1 if [x + 1, y] in trace_coordinates: neighbour_array.append([x + 1, y]) number_of_neighbours += 1 if [x + 1, y - 1] in trace_coordinates: neighbour_array.append([x + 1, y - 1]) number_of_neighbours += 1 if [x, y - 1] in trace_coordinates: neighbour_array.append([x, y - 1]) number_of_neighbours += 1 if [x - 1, y - 1] in trace_coordinates: neighbour_array.append([x - 1, y - 1]) number_of_neighbours += 1 if [x - 1, y] in trace_coordinates: neighbour_array.append([x - 1, y]) number_of_neighbours += 1 if [x - 1, y + 1] in trace_coordinates: neighbour_array.append([x - 1, y + 1]) number_of_neighbours += 1 return number_of_neighbours, neighbour_array
[docs] @staticmethod def return_points_in_array(points_array: list | npt.NDArray, trace_coordinates: list | npt.NDArray) -> list: """ Return a subset co ordinates for the given set of points. Parameters ---------- points_array : list | npt.NDArray The subset of points for which coordinates are required. trace_coordinates : list | npt.NDArray Coordinates of all points. Returns ------- list Coordinates for the subset of points. """ for x, y in points_array: if [x, y] in trace_coordinates: try: points_in_trace_coordinates.append([x, y]) except NameError: points_in_trace_coordinates = [[x, y]] # for x, y in points_array: # print([x,y]) # try: # trace_coordinates.index([x,y]) # print(trace_coordinates.index([x,y])) # except ValueError: # continue # else: # try: # points_in_trace_coordinates.append([x,y]) # except NameError: # points_in_trace_coordinates = [[x,y]] try: return points_in_trace_coordinates except UnboundLocalError: return None
[docs] @staticmethod def make_grid(x: int, y: int, size: int) -> list: """ Make a Grid of coordinates around the points x and y. Parameters ---------- x : int The x coordinate. y : int They y coordinate. size : int Size of surrounding grid. Returns ------- list List of coordinates that form a grid around x and y of size. """ for x_n in range(-size, size + 1): x_2 = x + x_n for y_n in range(-size, size + 1): y_2 = y + y_n try: grid.append([x_2, y_2]) except NameError: grid = [[x_2, y_2]] return grid
[docs] @staticmethod def find_best_next_point( x: int, y: int, ordered_points: list | npt.NDArray, candidate_points: list | npt.NDArray ) -> list | None: """ Find the next best point. Parameters ---------- x : int The x coordinate. y : int They y coordinate. ordered_points : list | npt.NDArray Ordered points. candidate_points : list | npt.NDArray Points to be checked. Returns ------- list Coordinates of the neighbouring pixel with the smallest angular change. """ ordered_points = np.array(ordered_points) candidate_points = np.array(candidate_points) ordered_points = ordered_points.tolist() candidate_points = candidate_points.tolist() for i in range(1, 8): # build array of coordinates from which to check coords_to_check = genTracingFuncs.make_grid(x, y, i) # check for potential points in the larger search area points_in_array = genTracingFuncs.return_points_in_array(coords_to_check, candidate_points) # Make a decision depending on how many points are found if not points_in_array: continue elif len(points_in_array) == 1: best_next_point = points_in_array[0] return best_next_point else: best_next_point = genTracingFuncs.check_vectors_candidate_points(ordered_points, points_in_array) return best_next_point return None
[docs] @staticmethod def check_vectors_candidate_points( ordered_points: list | npt.NDArray, candidate_points: list | npt.NDArray, ) -> list: """ Find which neighbouring pixels incur the smallest angular change. This is done with reference to a previous pixel in the ordered trace, and chooses that as the next point. Parameters ---------- ordered_points : list | npt.NDArray Ordered points. candidate_points : list | npt.NDArray Points to be checked. Returns ------- list Coordinates of the neighbouring pixel with the smallest angular change. """ x_test = ordered_points[-1][0] y_test = ordered_points[-1][1] if len(ordered_points) > 4: x_ref = ordered_points[-3][0] y_ref = ordered_points[-3][1] x_ref_2 = ordered_points[-2][0] y_ref_2 = ordered_points[-2][1] elif len(ordered_points) > 3: x_ref = ordered_points[-2][0] y_ref = ordered_points[-2][1] x_ref_2 = ordered_points[0][0] y_ref_2 = ordered_points[0][1] else: x_ref = ordered_points[0][0] y_ref = ordered_points[0][1] x_ref_2 = ordered_points[0][0] y_ref_2 = ordered_points[0][1] dx = x_test - x_ref dy = y_test - y_ref ref_theta = math.atan2(dx, dy) x_y_theta = [] for x_n, y_n in candidate_points: x = x_n - x_ref_2 y = y_n - y_ref_2 theta = math.atan2(x, y) x_y_theta.append([x_n, y_n, abs(theta - ref_theta)]) ordered_x_y_theta = sorted(x_y_theta, key=lambda x: x[2]) return [ordered_x_y_theta[0][0], ordered_x_y_theta[0][1]]
[docs] def order_branch(binary_image: npt.NDArray, anchor: list): """ Order a linear branch by identifying an endpoint, and looking at the local area of the point to find the next. Parameters ---------- binary_image : npt.NDArray A binary image of a skeleton segment to order it's points. anchor : list A list of 2 integers representing the coordinate to order the branch from the endpoint closest to this. Returns ------- npt.NDArray An array of ordered coordinates. """ skel = binary_image.copy() if len(np.argwhere(skel == 1)) < 3: # if < 3 coords just return them return np.argwhere(skel == 1) # get branch starts endpoints_highlight = convolve_skeleton(skel) endpoints = np.argwhere(endpoints_highlight == 2) if len(endpoints) != 0: # if any endpoints, start closest to anchor dist_vals = abs(endpoints - anchor).sum(axis=1) start = endpoints[np.argmin(dist_vals)] else: # will be circular so pick the first coord (is this always the case?) start = np.argwhere(skel == 1)[0] # order the points according to what is nearby ordered = order_branch_from_start(skel, start) return np.array(ordered)
[docs] def order_branch_from_start( nodeless: npt.NDArray, start: npt.NDArray, max_length: float | np.inf = np.inf ) -> npt.NDArray: """ Order an unbranching skeleton from an end (startpoint) along a specified length. Parameters ---------- nodeless : npt.NDArray A 2D array of a binary unbranching skeleton. start : npt.NDArray 2x1 coordinate that must exist in 'nodeless'. max_length : float | np.inf, optional Maximum length to traverse along while ordering, by default np.inf. Returns ------- npt.NDArray Ordered coordinates. """ dist = 0 # add starting point to ordered array ordered = [] ordered.append(start) nodeless[start[0], start[1]] = 0 # remove from array # iterate to order the rest of the points current_point = ordered[-1] # get last point area, _ = local_area_sum(nodeless, current_point) # look at local area local_next_point = np.argwhere( area.reshape( ( 3, 3, ) ) == 1 ) - (1, 1) dist += np.sqrt(2) if abs(local_next_point).sum() > 1 else 1 while len(local_next_point) != 0 and dist <= max_length: next_point = (current_point + local_next_point)[0] # find where to go next ordered.append(next_point) nodeless[next_point[0], next_point[1]] = 0 # set value to zero current_point = ordered[-1] # get last point area, _ = local_area_sum(nodeless, current_point) # look at local area local_next_point = np.argwhere( area.reshape( ( 3, 3, ) ) == 1 ) - (1, 1) dist += np.sqrt(2) if abs(local_next_point).sum() > 1 else 1 return np.array(ordered)
[docs] def local_area_sum(binary_map: npt.NDArray, point: list | tuple | npt.NDArray) -> npt.NDArray: """ Evaluate the local area around a point in a binary map. Parameters ---------- binary_map : npt.NDArray A binary array of an image. point : Union[list, tuple, npt.NDArray] A single object containing 2 integers relating to a point within the binary_map. Returns ------- npt.NDArray An array values of the local coordinates around the point. int A value corresponding to the number of neighbours around the point in the binary_map. """ x, y = point local_pixels = binary_map[x - 1 : x + 2, y - 1 : y + 2].flatten() local_pixels[4] = 0 # ensure centre is 0 return local_pixels, local_pixels.sum()
[docs] def coord_dist(coords: npt.NDArray, pixel_to_nm_scaling: float = 1) -> npt.NDArray: """ Accumulate a real distance traversing from pixel to pixel from a list of coordinates. Parameters ---------- coords : npt.NDArray A Nx2 integer array corresponding to the ordered coordinates of a binary trace. pixel_to_nm_scaling : float The pixel to nanometer scaling factor. Returns ------- npt.NDArray An array of length N containing thcumulative sum of the distances. """ dist_list = [0] dist = 0 for i in range(len(coords) - 1): if abs(coords[i] - coords[i + 1]).sum() == 2: dist += 2**0.5 else: dist += 1 dist_list.append(dist) return np.asarray(dist_list) * pixel_to_nm_scaling