Source code for flyqma.annotation.spatial.triangulation

import numpy as np
from matplotlib.tri import Triangulation
from scipy.spatial import ConvexHull


[docs]class LocalTriangulation(Triangulation): """ Triangulation with edge distance filter. Attributes: edge_list (np.ndarray[int]) - (from, to) node pairs edge_lengths (np.ndarray[float]) - euclidean length of each edge """ def __init__(self, *args, **kwargs): """ Instantiate triangulation. Args: x, y (np.ndarray[float]) - spatial coordinates """ # call matplotlib.tri.Triangulation instantiation super().__init__(*args, **kwargs) # compile edges edge_list = self.compile_edge_list() edge_lengths = self.evaluate_edge_lengths(edge_list, self.x, self.y) # store edges self.edge_list = edge_list self.edge_lengths = edge_lengths @property def size(self): """ Number of points. """ return self.x.size @property def radii(self): """ Radius. """ return np.sqrt(self.x**2 + self.y**2) @property def angles(self): """ Angle on [0, 2p] interval. """ return np.arctan2(self.y, self.x) + np.pi @property def hull(self): """ Convex hull. """ return ConvexHull(np.vstack((self.x, self.y)).T) @property def num_triangles(self): """ Number of triangles. """ return self.triangles.shape[0] @property def nodes(self): """ All nodes. """ return np.unique(self.triangles) @property def edges(self): """ Filtered edges. """ return self.filter_outliers(self.nodes, self.edge_list, self.edge_lengths) #return self.filter_edges(self.nodes, self.edge_list, self.edge_lengths) #return self.filter_hull(self.edge_list) #return self.filter_longest_edge(self.edge_list, self.edge_lengths)
[docs] def compile_edge_list(self): """ Returns list of (node_from, node_to) tuples. """ edges = [] for i in range(3): edges += list(zip(self.triangles[:, i], self.triangles[:,(i+1)%3])) return np.array(edges)
[docs] @staticmethod def evaluate_edge_lengths(edge_list, x, y): """ Returns 1D array of edge lengths. """ dx, dy = x[edge_list], y[edge_list] return np.sqrt(np.diff(dx, axis=1)**2 + np.diff(dy, axis=1)**2).reshape(-1)
[docs] @staticmethod def find_disconnected_nodes(nodes, edges): """ Returns boolean array of nodes not included in edges. """ return nodes[~np.isin(nodes, np.unique(edges))]
[docs] @staticmethod def find_first_edge(edges, node): """ Returns index of first edge containing <node>. """ return (edges==node).any(axis=1).nonzero()[0][0]
[docs] @classmethod def filter_edges(cls, nodes, edges, lengths, max_length=0.1): """ Returns all edges less than <max_length>, with at least one edge containing each node. """ # sort edges sort_indices = np.argsort(lengths) edges = edges[sort_indices] lengths = lengths[sort_indices] mask = (lengths <= max_length) rejected, accepted = edges[~mask], edges[mask] # find disconnected nodes disconnected = cls.find_disconnected_nodes(nodes, accepted) # add shortest edge for each disconnected node if disconnected.size > 0: f = np.vectorize(lambda node: cls.find_first_edge(rejected, node)) connecting = rejected[f(disconnected)] accepted = np.vstack((accepted, connecting)) return accepted
[docs] @classmethod def filter_outliers(cls, nodes, edges, lengths): """ Returns all edges whose lengths are not outliers, with at least one edge containing each node. """ # sort edges sort_indices = np.argsort(lengths) edges = edges[sort_indices] lengths = lengths[sort_indices] mask = ~cls.is_outlier(lengths) rejected, accepted = edges[~mask], edges[mask] # find disconnected nodes disconnected = cls.find_disconnected_nodes(nodes, accepted) # add shortest edge for each disconnected node if disconnected.size > 0: f = np.vectorize(lambda node: cls.find_first_edge(rejected, node)) connecting = rejected[f(disconnected)] accepted = np.vstack((accepted, connecting)) return accepted
[docs] def filter_hull(self, edges): """ Returns all edges not on the convex hull. """ hull_edges = np.sort(self.hull.simplices, axis=1) on_hull = np.isin(np.sort(edges, axis=1), hull_edges).all(axis=1) return edges[~on_hull]
[docs] def filter_longest_edge(self, edges, edge_lengths): """ Returns all edges except the longest edge in each triangle. """ accepted_edges = [] for tri in range(self.num_triangles): ind = np.argsort(edge_lengths[tri::self.num_triangles])[:-1] accepted_edges.append(edges[tri::self.num_triangles][ind]) return np.vstack(accepted_edges)
[docs] @staticmethod def is_outlier(points, threshold=3.): """ Returns a boolean array with True if points are outliers and False otherwise. Args: points (np.ndarray[float]) - 1-D array of observations threshold (float) - Maximum modified z-score. Observations with a modified z-score (based on the median absolute deviation) greater are classified as outliers. Returns: mask (np.ndarray[bool]) References: Boris Iglewicz and David Hoaglin (1993), "Volume 16: How to Detect and Handle Outliers", The ASQC Basic References in Quality Control: Statistical Techniques, Edward F. Mykytka, Ph.D., Editor. """ if len(points.shape) == 1: points = points[:,None] median = np.median(points, axis=0) diff = np.sum((points - median)**2, axis=-1) diff = np.sqrt(diff) med_abs_deviation = np.median(diff) modified_z_score = 0.6745 * diff / med_abs_deviation # exclude lower bound modified_z_score[points.ravel()<median] = 0 return modified_z_score > threshold
@property def edge_angles(self): """ Angular distance of each edge about origin. """ angles = np.abs(np.diff(self.angles[self.edge_list], axis=1)).ravel() angles[angles>np.pi] = 2*np.pi-angles[angles>np.pi] return angles @property def edge_radii(self): """ Minimum node radius in each edge. """ return self.radii[self.edge_list].min(axis=1) @property def angle_threshold(self): """ Predicted upper bound on edge angles. """ num_sigma = 1. return (3+2.5*num_sigma) * (self.size**(-0.5)) def filter_by_angle(self): # exclude outer edges that span too wide an angle edge_radii = self.edge_radii edge_angles = self.edge_angles outer_edge_mask = edge_radii > np.percentile(edge_radii, 50) wide_angle_mask = edge_angles >= self.angle_threshold excluded_edge_mask = np.logical_and(outer_edge_mask, wide_angle_mask) # determine accepted/rejected edges rejected = self.edge_list[excluded_edge_mask] accepted = self.edge_list[~excluded_edge_mask] # find disconnected nodes disconnected = self.find_disconnected_nodes(self.nodes, accepted) # sort rejected edges by length lengths = self.edge_lengths[excluded_edge_mask] sort_indices = np.argsort(lengths) rejected = rejected[sort_indices] # add shortest edge for each disconnected node if disconnected.size > 0: f = np.vectorize(lambda node: self.find_first_edge(rejected, node)) connecting = rejected[f(disconnected)] accepted = np.vstack((accepted, connecting, connecting[::-1])) return accepted