Source code for ClearMap.Analysis.graphs.graph_processing

# -*- coding: utf-8 -*-
"""
GraphProcessing
===============

Module providing tools to create and manipulate graphs. Functions include graph
construction from skeletons, simplification, reduction and clean-up of graphs.
"""
__author__ = 'Christoph Kirst <christoph.kirst.ck@gmail.com>'
__license__ = 'GPLv3 - GNU General Public License v3 (see LICENSE)'
__copyright__ = 'Copyright © 2020 by Christoph Kirst'
__webpage__ = 'https://idisco.info'
__download__ = 'https://www.github.com/ChristophKirst/ClearMap2'

import functools
import multiprocessing
import warnings
from typing import Dict, Callable, List, Sequence, runtime_checkable, Protocol

import numpy as np

import ClearMap.IO.IO as io

import ClearMap.ParallelProcessing.DataProcessing.ArrayProcessing as ap
import ClearMap.ImageProcessing.Topology.Topology3d as t3d
import ClearMap.Utils.Timer as tmr

from ClearMap.Analysis.graphs import graph_gt
from ClearMap.Analysis.graphs.fast_graph_reduce import find_degree2_branches, cy_reduce
from ClearMap.Utils.utilities import sanitize_n_processes

SENTINEL = -1


[docs] def mean_vertex_coordinates(coordinates): return np.mean(coordinates, axis=0)
[docs] def medoid_vertex_coordinates(coordinates): """ Compute the vertex coordinates as the median of the coordinates. This gives a vertex **on the grid** that is closest to the median of the coordinates. .. note:: The vertex may not be one of the original coordinates, but it is guaranteed to be a valid vertex coordinate in the graph. Parameters ---------- coordinates Returns ------- """ k = coordinates.shape[0] // 2 median = np.array([ np.partition(coordinates[:, i], k)[k] for i in range(3) ]) return median
[docs] @runtime_checkable class TwoPhaseReducer(Protocol): """ Protocol for reducers that require an intermediate dtype different from the output dtype — e.g. computing float means before thresholding to a binary output. Implementors must define: __name__ : str — maps to Cython reducer enum tmp_dtype : np.dtype — dtype for intermediate Cython accumulation finalise() : method — converts intermediate result to final output """ __name__: str tmp_dtype: np.dtype def __call__(self, x: np.ndarray) -> any: ...
[docs] def finalise(self, intermediate: np.ndarray, offsets: np.ndarray, arr_dtype: np.dtype) -> np.ndarray: ...
[docs] def is_compatible(self, prop_name: str, arr: np.ndarray) -> None: ...
[docs] class Percentile: """ Reducer that returns True if the p-th percentile of a **binary** array is 1. For binary arrays this is equivalent to mean >= (1 - p/100), implemented as a parallel Cython mean + single vectorised threshold. .. warning:: Only valid for binary (0/1) arrays. For quantitative properties use np.percentile directly. Parameters ---------- p : float Percentile threshold in [0, 100]. p=33 means at least 67% of values must be 1 (consensus vote). p=50 means at least 50% of values must be 1 (simple majority). """ __name__ = 'vote' # maps to RED_MEAN in get_reducer_enum tmp_dtype = np.float64 # Cython accumulates means in float def __init__(self, p: float = 33.33): self.p = p self.threshold = (100.0 - p) / 100.0 def __call__(self, x): # Python fallback only return float(np.mean(x)) >= self.threshold # TODO: check if we want to improve this check
[docs] def is_compatible(self, prop_name, arr): if not (np.issubdtype(arr.dtype, np.integer) and arr.max() <= 1 and arr.min() >= 0): raise ValueError( f'Percentile reducer applied to non-binary property "{prop_name}". ' f'Use np.percentile for quantitative properties.')
[docs] def finalise(self, means: np.ndarray, offsets: np.ndarray, arr_dtype: np.dtype) -> np.ndarray: """ Convert Cython-computed means to a binary result. Parameters ---------- means : np.ndarray Float means computed by cy_reduce (one per chain). offsets : np.ndarray Chain boundary offsets (length n_chains + 1). arr_dtype : np.dtype dtype of the source binary array — used for output dtype. """ return (means >= self.threshold).astype(arr_dtype)
DEFAULT_EDGE_TO_EDGE = { 'length': np.sum, 'chain_id': np.mean # We use mean as a sanity check (float outputs would be a tell-tale sign of a bug) } DEFAULT_VERTEX_TO_EDGE = { 'radii': np.max, 'radius_units': np.max } DEFAULT_VERTEX_TO_VERTEX = { 'coordinates': medoid_vertex_coordinates, 'coordinates_units': mean_vertex_coordinates, 'length': np.sum, 'radii': np.max, 'radius_units': np.max, 'radii_axial': np.max, # (k,3) → (3,) max per axis ✓ 'radius_units_axial': np.max, # (k,3) → (3,) max per axis ✓ 'chain_id': np.mean, # 'chain_id': functools.partial(np.quantile, 0.5, method='nearest', axis=0), '_vertex_id_': np.min } ############################################################################### # ## Graphs from skeletons ###############################################################################
[docs] def neighbours(indices: np.ndarray, offset: int) -> np.ndarray: """ indices – sorted 1-D array of flat voxel indices (int64) offset – neighbour offset in *elements* (signed) returns – (m,2) array with vertex-index pairs """ shifted = indices + offset # still sorted pos = np.searchsorted(indices, shifted) # batched binary search # pos is where the shifted indices would be inserted in the original array valid = pos < indices.size # step 1: in_bounds mask mask = np.zeros_like(valid, dtype=bool) # mask = indices[pos] == shifted i.e. there is an element at the shifted position mask[valid] = indices[pos[valid]] == shifted[valid] src = np.nonzero(mask)[0].astype(np.int64) dst = pos[mask].astype(np.int64) return np.vstack((src, dst)).T
[docs] def annotate_edge_lengths(graph: graph_gt.Graph, coord_prop=None, edges=None, spacing=None): """ Compute the length property for edges in a graph based on the voxel coordinates and the resolution of the voxels. Parameters ---------- graph: graph_tool.Graph The source graph to annotate. coord_prop: VertexPropertyMap | np.ndarray Array of shape (N,3) voxel indices (⟨x,y,z⟩) spacing: tuple(float, float, float) | None Voxel pitch along each axis. If None, the value is retrieved from the graph's `spacing` property. .. warning:: Because this uses adjacent connectivity, it can only be used for graphs before reduction, i.e. before `reduce_graph` is called. Also, coord_prop must be integer-valued, i.e. voxel indices. Notes ----- * Works because every edge links *adjacent* voxels → only 26 displacement vectors. * Complexity: **O(|E|)** but with *only* cheap integer arithmetics + one final lookup. """ spacing = spacing if spacing is not None else graph.graph_property('spacing') if edges is None: edges = graph.edge_connectivity(order='eid') # get edges as (src, target) pairs ordered by edge insertion src_vertices, target_vertices = np.hsplit(edges, 2) # TODO: check indexing src_vertices = src_vertices.squeeze() target_vertices = target_vertices.squeeze() if coord_prop is None: coord_prop = graph.vertex_coordinates() if isinstance(coord_prop, np.ndarray): coords = coord_prop else: coords = coord_prop.a.reshape((-1, 3)).astype(int) # coord_prop.a flattens, so reshape: TODO: test # convert -1, 0, +1 displacement to lookup index diff = coords[target_vertices] - coords[src_vertices] # triplets of values -1, 0, +1 (in each dimension) if diff.max() > 1 or diff.min() < -1: raise ValueError(f'Coordinates must be integer-valued voxel indices in the range [-1, 1] for each axis.' f'Found values outside this range: {diff.min()} to {diff.max()}. ' f'This graph may have been reduced or the coordinates are not voxel indices.' f'Please use `annotate_edge_lengths` before reducing the graph.') # Use the trick below if speed is a constraint # Convert to index in [0,26] for the distance lookup table using base 3 # idx = (diff[:, 0] + 1) * 9 + (diff[:, 1] + 1) * 3 + (diff[:, 2] + 1) # return distance_table[idx] res = np.asarray(spacing, dtype=np.float64) distance_table = get_distance_map_27(res).reshape(3, 3, 3) # shifted by 1 to get [0-2] indices for each axis xs = diff[:, 0] + 1 ys = diff[:, 1] + 1 zs = diff[:, 2] + 1 edge_lengths = distance_table[xs, ys, zs] return edge_lengths
[docs] def get_distance_map_27(resolution): """ Create a distance lookup table for the 26 possible displacements in 3D. Uses the resolution to convert voxel displacements to physical distances. Parameters ---------- resolution: tuple(float, float, float) Size of one voxel along each axis (x, y, z). Returns ------- distance_table: np.ndarray A 1D array of shape (27,) containing the distances for each of the 26 possible displacements in 3D, plus one entry for the zero displacement. """ # index mapping: idx = (dx+1)*9 + (dy+1)*3 + (dz+1) # produces 3-digit base-3 number in ⟦0,26⟧ distance_table = np.empty(27, dtype=np.float64) # Float64 to avoid overflow when summing k = 0 for dx in (-1, 0, 1): for dy in (-1, 0, 1): for dz in (-1, 0, 1): # physical displacement d_xyz = resolution * np.array([dx, dy, dz], dtype=np.float64) distance_table[k] = np.linalg.norm(d_xyz) k += 1 return distance_table
[docs] def graph_from_skeleton(skeleton, points=None, radii=None, compute_vertex_coordinates=True, compute_edge_length=True, check_border=True, delete_border=False, spacing=None, physical_units='', n_processes=-2, verbose=False): """ Converts a binary skeleton image to a graph-tool graph. .. note:: Edges are detected between neighbouring foreground pixels using 26-connectivity. .. warning:: If ``delete_border`` is True, the input skeleton will be mutated in-place. Otherwise the behaviour is RO Arguments --------- skeleton: array Source with 2d/3d binary skeleton. points: array List of skeleton points as 1d indices of flat skeleton array (optional to save processing time). radii: array List of radii associated with each vertex. compute_vertex_coordinates: bool If True, store coordinates of the vertices / edges. compute_edge_length: bool If True, compute the length of each edge based on the vertex coordinates. If spacing is also provided, the length is computed in physical units. check_border: bool If True, check if the border is empty. The algorithm requires this. delete_border: bool If True, delete the border (`check_border` is ignored in this case). spacing: array Spacing of the voxels in the skeleton in physical units (e.g. micrometers) in each dimension. physical_units: str Description of the physical units of the spacing, e.g. 'micrometers', 'µm'... verbose: bool If True, print progress information. Returns ------- graph : Graph class The graph corresponding to the skeleton. """ if compute_edge_length and not compute_vertex_coordinates: raise ValueError('Activating `compute_edge_length` requires `compute_vertex_coordinates` to be True!') skeleton = io.edit(skeleton) # WARNING: ``edit`` here because delete_border (optional) is in-place if skeleton.dtype not in ('bool', np.uint8): raise TypeError('The skeleton array needs to be a boolean array!') if delete_border: skeleton = t3d.delete_border(skeleton) check_border = False if check_border: if not t3d.check_border(skeleton): raise ValueError('The skeleton array needs to have no points on the border!') if verbose: timer = tmr.Timer() timer_all = tmr.Timer() print('Graph from skeleton calculation initialized.!') n_processes = sanitize_n_processes(n_processes) if points is None: if n_processes > 1: # Get 3d coordinates of positive voxels coords_3d = ap.where(skeleton, processes=n_processes) # convert to linear indices using strides (efficient memory indexing) strides = np.array(skeleton.strides) // skeleton.dtype.itemsize # divide to get in units of array elelements points = (coords_3d[:, 0] * strides[0] + coords_3d[:, 1] * strides[1] + coords_3d[:, 2] * strides[2]) points.sort() # searchsorted in neighbours() requires sorted else: points = np.where(skeleton.reshape(-1, order='A'))[0] if verbose: timer.print_elapsed_time(f'Point list generation with {n_processes=}', reset=True) # create graph n_vertices = points.shape[0] g = graph_gt.Graph(n_vertices=n_vertices, directed=False) if spacing: spacing = np.asarray(spacing, dtype=np.float64) g.add_graph_property('spacing', spacing) if physical_units: g.add_graph_property('physical_units', physical_units) g.shape = skeleton.shape if verbose: timer.print_elapsed_time(f'Graph initialized with {n_vertices:,} vertices', reset=True) # ######################### detect edges ######################### edges = [] for i, o in enumerate(t3d.orientations()): offset = np.sum((np.hstack(np.where(o)) - [1, 1, 1]) * skeleton.strides) # edges = ap.neighbours(points, offset) tmp_edges = neighbours(points, offset) if len(tmp_edges) > 0: edges.append(tmp_edges) if verbose: timer.print_elapsed_time(f'{len(tmp_edges):,} edges with orientation {i + 1}/13 found', reset=True) if edges: edges_all = np.vstack(edges, dtype=int) g.add_edge(edges_all) else: edges_all = np.zeros((0, 2), dtype=int) if verbose: timer.print_elapsed_time(f'Added {edges_all.shape[0]:,} edges to graph', reset=True) # ################## Compute additional properties ################## if compute_vertex_coordinates: vertex_coordinates = np.array(np.unravel_index(points, skeleton.shape, order=skeleton.order)).T coords_dtype = np.int16 # unsigned ints not handled by graph-tool if vertex_coordinates.max() > np.iinfo(np.int16).max: coords_dtype = np.int32 vertex_coordinates = vertex_coordinates.astype(coords_dtype) g.set_vertex_coordinates(vertex_coordinates, dtype=coords_dtype) if verbose: timer.print_elapsed_time(f'Added {len(vertex_coordinates)} vertex coordinates', reset=True) if spacing is not None: # Upcast to float because result is in physical units coords_phys = vertex_coordinates.astype(np.float32) * spacing[None, :] # None broadcasts spacing to (1, 3) g.define_vertex_property('coordinates_units', coords_phys, dtype=np.float32) if verbose: timer.print_elapsed_time(f'Added physical units coordinates', reset=True) if radii is not None: g.set_vertex_radius(radii) if verbose: timer.print_elapsed_time(f'Added radii', reset=True) if compute_edge_length: edge_lengths = annotate_edge_lengths(g, spacing=spacing) g.define_edge_property('length', edge_lengths, dtype=np.float64) if verbose: timer.print_elapsed_time(f'Added lengths', reset=True) if verbose: timer_all.print_elapsed_time('Skeleton to Graph') return g
[docs] def graph_to_skeleton(graph, sink=None, dtype=bool, values=True): """Create a binary skeleton from a graph.""" if not graph.has_edge_geometry(): coordinates = np.asarray(graph.vertex_coordinates(), dtype=int) else: coordinates = graph.edge_geometry('coordinates') coordinates = np.asarray(np.vstack(coordinates), dtype=int) if sink is None: shape = graph.shape if shape is None: shape = tuple(np.max(coordinates, axis=0)) sink = np.zeros(shape, dtype=dtype) coordinates = tuple(coordinates.T) sink[coordinates] = values return sink
############################################################################### # ## Graph CleanUp ###############################################################################
[docs] def clean_graph(graph: graph_gt.Graph, remove_self_loops: bool = True, remove_isolated_vertices: bool = True, vertex_mappings: dict[str, Callable] | None = None, verbose: bool = False) -> graph_gt.Graph: """ Remove all cliques to get pure branch structure of a graph. A clique is a cluster of vertices, defined as a connected component of branch points .. warning:: This function is meant to be used on graphs **before** reduction, in which case clusters of branch points are **immediately adjacent** to each other. .. note:: cliques are replaced by a single vertex connecting to all non-clique neighbours The center coordinate is used as the coordinate for that vertex. The vertex properties are reduced using the provided `vertex_mappings` functions. The edge properties are not reduced. The length edge property is computed as the length of the edge connecting the clique center to its neighbours. Arguments --------- graph : Graph The graph to clean up. remove_self_loops: bool If True, remove self-loops from the graph. remove_isolated_vertices: bool If True, remove isolated vertices from the graph. vertex_mappings : dict[str, Callable] | None Dictionary of reduction functions to apply to vertex properties within cliques. verbose : bool If True, prin progress information. Returns ------- graph : Graph A graph removed of all cliques. """ if vertex_mappings is None: vertex_mappings = DEFAULT_VERTEX_TO_VERTEX vertex_mappings = {name: fn for name, fn in vertex_mappings.items() if name in graph.vertex_properties} if verbose: timer = tmr.Timer() timer_all = tmr.Timer() else: timer = None # find branch points n_vertices = graph.n_vertices branch_mask = graph.vertex_degrees() >= 3 n_branch_points = branch_mask.sum() if verbose: timer.print_elapsed_time(f'Graph cleaning: found {n_branch_points:,} branch points ' f'among {n_vertices:,} vertices', reset=True) if n_branch_points == 0: return graph.copy() # detect 'cliques', i.e. connected components of branch points # First we remove non-branch points from the graph, so that the graph # now becomes disjoint between clusters of branch points which we can now label. gb = graph.sub_graph(vertex_filter=branch_mask, view=True) component_ids, component_sizes = gb.label_components(return_vertex_counts=True) # note: graph_tools components of a view is a property map of the full graph component_ids[branch_mask] += 1 # shift so non-branch = 0 # group components # note: remove the indices of the non branch nodes (0 label) component_ids = _group_labels(component_ids)[1:] clique_ids = (ap.where(component_sizes > 1)).array # clusters (>1) of degree > 2 vertices n_cliques = len(clique_ids) if verbose: timer.print_elapsed_time(f'Graph cleaning: detected {n_cliques:,} cliques of branch points', reset=True) if n_cliques == 0: return graph.copy() # Create temp graph with clique vertices + new vertex for each clique g = graph.copy() g.add_vertex(n_cliques) vertex_filter = np.ones(n_vertices + n_cliques, dtype=bool) coords = g.vertex_coordinates() use_units = 'coordinates_units' in g.vertex_properties if use_units: coords_units = g.vertex_property('coordinates_units') spacing = np.array(g.graph_property('spacing'), dtype=np.float64) center_vertices = np.arange(n_vertices, n_vertices + n_cliques, dtype=np.intp) neighbours = {} for clk_id in clique_ids: clique_vertices = component_ids[clk_id] neighbours[clk_id] = get_clique_neighbours_set(graph, clique_vertices) def _prop_to_buffer(prop_map, n_rows): """return an empty (n_rows, …) array whose tail shape matches one value""" sample = prop_map[0] # any vertex will do return np.empty((n_rows, *(sample.shape)), dtype=(sample.dtype)) n_new_edges = sum(neighbours[clik_id].shape[0] for clik_id in clique_ids) new_edges = np.empty((n_new_edges, 2), dtype=np.intp) if 'length' in g.edge_properties: new_lengths = np.zeros((n_new_edges,), dtype=np.float64) new_vertices_props = {name: _prop_to_buffer(graph.vertex_property(name), n_cliques) for name in vertex_mappings} v_prop_arrays = {} # Cache since graph..vertex_property is not a simple lookup (recreates np.array) for prop_name in vertex_mappings: v_prop_arrays[prop_name] = graph.vertex_property(prop_name, as_array=True) # FIXME: test , is_scalar=False) edge_counter = 0 for i, clik_id in enumerate(clique_ids): center_vertex = center_vertices[i] # new vertex for the clique clique_vertices = component_ids[clik_id] current_neighbours = neighbours[clik_id] vertex_filter[clique_vertices] = False # Schedule clique vertices for removal if current_neighbours.size == 0: # unconnected clique: Remove and skip vertex_filter[center_vertex] = False continue # connect to new node edges_to_clique_neighbours = [[n, center_vertex] for n in current_neighbours] n_neighbour_edges = len(edges_to_clique_neighbours) new_edges[edge_counter: edge_counter + n_neighbour_edges] = edges_to_clique_neighbours center_coord = vertex_mappings['coordinates'](coords[clique_vertices]) if use_units: center_coord_units = center_coord * spacing # Faster than mapping (average) if 'length' in g.edge_properties: if use_units: edge_lengths = np.linalg.norm(coords_units[current_neighbours] - center_coord_units, axis=1) else: edge_lengths = np.linalg.norm(coords[current_neighbours] - center_coord, axis=1) new_lengths[edge_counter: edge_counter + n_neighbour_edges] = edge_lengths edge_counter += n_neighbour_edges # Update after last usage (new_length still uses previous cycle value) # map vertex properties for prop_name, reduce_fn in vertex_mappings.items(): if prop_name == 'coordinates': # Already calculated above val = center_coord elif prop_name == 'coordinates_units': val = center_coord_units else: prop = v_prop_arrays[prop_name] # Very fast because simple lookup val = reduce_fn(prop[clique_vertices]) # g.set_vertex_property(prop_name, val, vertex=center_vertex) new_vertices_props[prop_name][i] = val # Store for later if verbose and i+1 % 10000 == 0: timer.print_elapsed_time(f'Graph cleaning: reducing {i + 1:,} / {n_cliques:,}') # map vertex properties to new vertices for prop_name in vertex_mappings.keys(): prop = g.vertex_property(prop_name) # OPTIMISE: see if we could do in-place using vertex_property_map instead prop[n_vertices:] = new_vertices_props[prop_name] # Add for all the cliques at once g.set_vertex_property(prop_name, prop) # Add new edges and map e_props original_edge_count = g.n_edges g.add_edge(new_edges) if 'length' in g.edge_properties: length_prop = g.edge_property('length') length_prop[original_edge_count:] = new_lengths g.set_edge_property('length', length_prop) # Remove vertices belonging to cliques g = g.sub_graph(vertex_filter=vertex_filter) if remove_self_loops: g.remove_self_loops() if verbose: timer.print_elapsed_time( f'Graph cleaning: removed {n_cliques} cliques of branch points from {graph.n_vertices:,} vertices' f' to {g.n_vertices:,} nodes and {graph.n_edges:,} to {g.n_edges:,} edges', reset=True) # remove isolated vertices if remove_isolated_vertices: g = _remove_isolated_vertices(g, timer, verbose) # REFACTORING: part of graph_gt.Graph if verbose: timer_all.print_elapsed_time( f'Graph cleaning: cleaned graph has {g.n_vertices:,} nodes and {g.n_edges:,} edges') return g
[docs] def get_clique_neighbours(graph, clique_vertices): neighbours = np.hstack([graph.vertex_neighbours(v) for v in clique_vertices]) # Clique + neighbours neighbours = np.setdiff1d(np.unique(neighbours), clique_vertices, assume_unique=True) # Remove clique vertices (-> neighbours only) return neighbours
[docs] def get_clique_neighbours_set(g, clique_vertices): s_clique = set(clique_vertices) s_neigh = set(np.concatenate([g.vertex_neighbours(v) for v in clique_vertices]).flatten()) # Clique + neighbours # for v in clique_vertices: # s_neigh.update(g.vertex_neighbours(v)) s_neigh.difference_update(s_clique) return np.fromiter(s_neigh, dtype=int)
def _remove_isolated_vertices(g, timer, verbose): non_isolated = g.vertex_degrees() > 0 g = g.sub_graph(vertex_filter=non_isolated) if verbose: timer.print_elapsed_time(f'Graph cleaning: Removed {np.logical_not(non_isolated).sum()} isolated nodes', reset=True) return g def _group_labels(array): """Helper grouping labels in a 1d array.""" idx = np.argsort(array) arr = array[idx] dif = np.diff(arr) coords = (ap.where(dif > 0)).array groups = np.split(idx, coords + 1) return groups ############################################################################### # ## Graph reduction ###############################################################################
[docs] def drop_degree2_loops(graph): """ Drop all loops in the graph that consist only of vertices with degree 2. Parameters ---------- graph: GraphGt.Graph Returns ------- GraphGt.Graph The graph with all pure degree 2 loops removed. """ # 1) mask & view deg = graph.vertex_degrees() deg2_mask = (deg == 2) if not deg2_mask.any(): return graph view = graph.sub_graph(vertex_filter=deg2_mask, view=True) # 2) component labels + counts comp_full, comp_counts = view.label_components(return_vertex_counts=True) comp_full = np.asarray(comp_full) comp_counts = np.array(comp_counts) # 2) restrict to the deg-2 vertices only # comp_map = comp_full[deg2_mask] # 3) count edges per component from the view’s edge_connectivity ec = view.edge_connectivity() src = ec[:, 0] comp_edge_counts = np.bincount(comp_full[src], minlength=len(comp_counts)) # 4) cycles = components where #edges == #vertices cycle_ids = (ap.where(comp_edge_counts == comp_counts)).array # 5) boolean mask over view-vertices comp_view = comp_full[view._base.get_vertices()] # WARNING: implementation specific loop_mask = np.isin(comp_view, cycle_ids) # 6) map back to original graph indices orig_vids = view._base.get_vertices() # WARNING: implementation specific to_remove = orig_vids[loop_mask] # 7) build the keep mask for the original graph v_keep = np.ones(graph.n_vertices, dtype=bool) v_keep[to_remove] = False print(f"Dropping {len(to_remove)} pure degree-2 loop vertices from {graph.n_vertices} total.") return graph.sub_graph(vertex_filter=v_keep)
[docs] class PropertyAggregator: ALLOWED_KINDS = ('edge', 'vertex', 'edge_geometry') """ Collect & aggregate graph-tool properties while you walk chains. Parameters ---------- graph: GraphGt.Graph The working copy of the graph (`g` in reduce_graph). mapping: Dict[str, Callable] Keys are property names (e.g. "length"), values are aggregation functions (e.g. np.sum, np.max …). kind: str "edge" → aggregating edge properties "vertex"→ aggregating vertex properties """ def __init__(self, graph: graph_gt.Graph, mapping: Dict[str, Callable], kind: str = 'edge', n_processes: int | None= None): self.n_processes = n_processes self.offsets = None # offsets for the chains, (i.e., cumsum of chain lengths -> start/end indices) if kind not in self.ALLOWED_KINDS: raise ValueError(f"kind must be in {self.ALLOWED_KINDS}") self.src_graph = graph self.kind = kind self.chain_indices: list[np.ndarray] = [] # collects indices of chains self.chain_edges_direction: list[np.ndarray] = [] # collection of boolean arrays indicating the need to reverse an edge direction in the chain self.properties: Dict[str, np.ndarray] = {} self.aggregation_functions: Dict[str, Callable] = {} self.aggregated_properties: Dict[str, List] = {} base = list(graph.edge_properties if kind == 'edge' else graph.vertex_properties if kind == 'vertex' else graph.edge_geometry_property_names) for key, func in mapping.items(): if key in base: # Ensure the property exists in the graph if self.kind == "edge": prop = graph.edge_property_map(key).a elif self.kind == "vertex": prop = graph.vertex_property_map(key).a else: # edge_geometry prop = graph.edge_geometry_property(key) # FIXME: this one is not dynamic self.properties[key] = prop self.aggregation_functions[key] = func self.aggregated_properties[key] = [] else: warnings.warn(f'Property "{key}" not found in graph {self.kind} properties. Skipping aggregation.') def __str__(self): return f"PropertyAggregator(kind={self.kind}, properties={list(self.properties.keys())})" @property def property_names(self): return list(self.properties.keys()) @functools.cached_property def edge_connectivity(self) -> np.ndarray: return self.src_graph.edge_connectivity()
[docs] def accumulate(self, indices: Sequence[int]) -> None: # OPTIMISATION: this is the slowest part of reduce_graph """ Append the result of aggregating (applying the specified function) a chain of vertices or edges to the aggregator. indices : list/array of edge IDs (kind="edge") or vertex IDs (kind="vertex") that form one chain. """ self.chain_indices.append(np.asarray(indices, dtype=np.intp)) # if self.kind == 'edge': edge_orders = self.edge_connectivity[indices] sorted_vertices = np.sort(edge_orders, axis=1) diffs = edge_orders[:, 1] - edge_orders[:, 0] first_sign = np.sign(diffs[0]) # +, - (or 0 for self-loops) need_flip = np.sign(diffs) != first_sign is_not_loop = np.all(sorted_vertices[0, :] != sorted_vertices[-1, :]) need_flip = need_flip & is_not_loop # no flip for self-loops if self.kind == 'edge'and len(need_flip) > 0: # FIXME: dirty trick for edge/vertex geometry length (len()) mismatch need_flip = np.hstack([need_flip, need_flip[-1]]) # FIXME: could remove if split vertex/edge geometry self.chain_edges_direction.append(need_flip)
[docs] def aggregate(self) -> None: """ Aggregate the accumulated chains by applying the specified reduction functions .. note:: For *simple* reduction functions (np.sum, np.mean, np.min, np.max), this is implemented in parallel using Cython for performance. Otherwise, this will fall back to a pure Python/numpy implementation. """ if not self.chain_indices: raise ValueError("No chains have been accumulated. Call accumulate() first.") idx_stack = np.concatenate(self.chain_indices).astype(np.uint64) # 1-D offsets = np.cumsum([0] + [len(x) for x in self.chain_indices]).astype(np.uint64) self.offsets = offsets n_procs = max(1, self.n_processes if self.n_processes is not None else multiprocessing.cpu_count() - 2) for prop_name, arr in self.properties.items(): reduction_fn = self.aggregation_functions[prop_name] out_dtype = np.float64 if reduction_fn is np.sum else arr.dtype if hasattr(reduction_fn, 'is_compatible'): # TwoPhaseReducer protocol reduction_fn.is_compatible(prop_name, arr) # binary and threshold in (0,1) tmp_dtype = getattr(reduction_fn, 'tmp_dtype', out_dtype) # for percentile, we need the intermediate means in float mapped = np.zeros(len(self.chain_indices), dtype=tmp_dtype) # pre-allocate output array success = cy_reduce(arr, mapped, idx_stack=idx_stack, offsets=offsets, reducer_fn=reduction_fn, num_threads=n_procs) if not success: # pure Python fallback if Cython fails starts, ends = offsets[:-1], offsets[1:] mapped = np.array([reduction_fn(arr[idx_stack[s:e]]) for s, e in zip(starts, ends)], dtype=out_dtype) elif hasattr(reduction_fn, 'finalise'): mapped = reduction_fn.finalise(mapped, offsets, arr.dtype) self.aggregated_properties[prop_name] = mapped
[docs] def get_indices_and_ranges(self, reduced_edge_order) -> (np.ndarray, np.ndarray): """ Return a 1D array of all accumulated indices, concatenated from all chains. and the start and end indices for each chain as a 2D array. """ start_idx = self.offsets[:-1] end_idx = self.offsets[1:] edge_geom_ranges = np.array([start_idx, end_idx]).T # how to chunk edge_agg.chain_indices edge_geom_ranges = edge_geom_ranges[reduced_edge_order] # Reorder chains according to the new edge order return np.hstack(self.chain_indices), edge_geom_ranges # WARNING: reorder only slicing, not array itself
[docs] def apply(self, reduced_graph: graph_gt.Graph, reduced_edge_order: np.ndarray) -> None: """ Attach the aggregated values as edge properties on `graph`. In cases where self.src_graph has edge geometry, this will also reduce this geometry and the edge_geometry_indices. .. note:: This method is typically called once after `reduce_graph` has finished processing the chains and before returning the reduced graph. Parameters ---------- reduced_graph : GraphGt.Graph The graph to which the aggregated properties will be added. This is typically the reduced graph returned by `reduce_graph`. reduced_edge_order : np.ndarray The *new* edge order, i.e. the order in which edges are stored in the reduced graph. """ # Handle existing edge geometry properties # FIXME: only if self.kind == "edge" + should be part of other instance with != kind if self.src_graph.has_edge_geometry() and self.src_graph.edge_geometry_type == "graph": if self.kind == 'edge' and not reduced_graph.has_edge_geometry(): # FIXME: src_ranges from the new graph because we drop the reduced edges ???? src_ranges = self.src_graph.edge_geometry_indices() # (E_orig, 2) # Reorder the edges according to the reduced edge order chains = [self.chain_indices[i] for i in reduced_edge_order] flip_masks = [self.chain_edges_direction[i] for i in reduced_edge_order] # Build the take index (ordered list of *each* index in the chains concatenated) take_idx: List[int] = [] new_ranges = np.full((len(chains), 2), SENTINEL, dtype=np.intp) for i, (edges, flips) in enumerate(zip(chains, flip_masks)): current_start = len(take_idx) for j, (e_id, flip) in enumerate(zip(edges, flips)): start_idx, end_idx = src_ranges[e_id] # old [start, end] # Build the whole itemwise index for the chain idx = list(range(start_idx, end_idx, 1)) if flip: idx = idx[::-1] if j > 0: # deduplicate the joining vertices idx = idx[1:] #WARNING: remove last 2 if edge_geometry_edge not edge_geometry_vertex take_idx.extend(idx) new_ranges[i] = (current_start, len(take_idx)) reduced_graph.define_edge_geometry_indices_graph(new_ranges) take_idx = np.fromiter(take_idx, dtype=np.intp) # Reorder the edge_geometry properties according to the new edge order for name in self.src_graph.edge_geometry_property_names: arr = self.src_graph.graph_property(name) new_arr = arr.take(take_idx, axis=0) reduced_graph.define_graph_property(name, new_arr) else: # Handle the case where edge geometry is already defined (after edges have been reduced) pass # FIXME: implement case where edge_geometry_vertex_property are defined separately # new_ranges = reduced_graph.edge_geometry_indices() # take_idx = np.concatenate([np.arange(start, end) for start, end in new_ranges]) # Handle "regular" aggregated properties for prop_name, values in self.aggregated_properties.items(): reordered_values = values[reduced_edge_order] # Reorder according to the edge order reduced_graph.define_edge_property(prop_name, reordered_values)
def __drop_former_degree_2_nodes(graph, reduced_graph, non_degree_2_vertices_ids, return_maps): """remove former degree 2 nodes (now isolated vertices)""" v_to_v_map = None if return_maps: reduced_graph.add_vertex_property('_vertex_id_', graph.vertex_indices()) v_filter = np.zeros(graph.n_vertices, dtype=bool) v_filter[non_degree_2_vertices_ids] = True reduced_graph = reduced_graph.sub_graph(vertex_filter=v_filter) if return_maps: v_to_v_map = reduced_graph.vertex_property('_vertex_id_') reduced_graph.remove_vertex_property('_vertex_id_') return reduced_graph, v_to_v_map
[docs] def add_chain_id(graph, prop_kind): # Store int as float as a sentinel for mean if prop_kind == 'edge': graph.define_edge_property('chain_id', np.full(graph.n_edges, -1, dtype=np.int64)) prop = graph.edge_property_map('chain_id').a elif prop_kind == 'vertex': graph.define_vertex_property('chain_id', np.full(graph.n_vertices, -1, dtype=np.int64)) prop = graph.vertex_property_map('chain_id').a else: raise ValueError(f'Unknown property kind "{prop_kind}". Expected "edge" or "vertex".') return prop
[docs] def reduce_graph(graph, vertex_to_edge_mappings=None, edge_to_edge_mappings=None, compute_edge_length=False, compute_edge_geometry=True, edge_geometry_vertex_properties=('coordinates', 'radii', 'chain_id', '_vertex_id_'), edge_geometry_edge_properties=('chain_id', ), return_maps=False, drop_pure_degree_2_loops=True, n_processes=None, verbose=False, label_branches=False, save_modified_graph_path=''): """ Reduce graph by removing all vertices with degree two. Whenever this is done, the edges are merged and properties are aggregated The coordinates of the degree 2 vertices are stored in a shared graph property called edge_geometry_coordinates. The new edge will only hold the start and end indexing into that graph level array of coordinates for efficiency. .. warning:: Currently, existing degree 2 loops are dropped inplace from the source graph. Parameters ---------- graph: GraphGt.Graph The graph to reduce. WARNING: currently, existing degree 2 loops are dropped inplace vertex_to_edge_mappings: dict | None A dictionary mapping vertex properties to edge properties. The keys are the vertex property names, and the values are functions that aggregate the vertex properties to edge properties. Defaults to `DEFAULT_VERTEX_TO_EDGE` Supply an empty dictionary to disable vertex to edge mappings. edge_to_edge_mappings: dict | None A dictionary mapping edge properties to edge properties. The keys are the edge property names, and the values are functions that aggregate the edge properties. Defaults to `DEFAULT_EDGE_TO_EDGE` Supply an empty dictionary to disable edge to edge mappings. compute_edge_length: bool If True, compute the length of edges in the reduced graph. Mapping defaults to np.sum, i.e. the length of the edge is the sum of the lengths of the edges in the original graph. compute_edge_geometry: bool If True, compute edge geometry properties for the reduced graph, i.e. store the aggregated selected vertex (see `edge_geometry_vertex_properties`) and edge properties (see `edge_geometry_edge_properties`) in the edge geometry. edge_geometry_vertex_properties: tuple | list | None A tuple of vertex property names that will be aggregated and stored in the edge geometry. If None, no vertex properties are used for edge geometry. Ignored if `compute_edge_geometry` is False. edge_geometry_edge_properties: tuple | list | None A list of edge property names that will be aggregated and stored in the edge geometry. If None, no edge properties are used for edge geometry. Ignored if `compute_edge_geometry` is False. return_maps: bool If True, return mappings of vertex to vertex, edge to vertex, and edge to edge. These mappings are useful for further processing of the reduced graph. drop_pure_degree_2_loops: bool If True, drop all pure degree 2 loops from the graph before reducing it. This is useful to avoid issues with loops in the graph that would otherwise lead to incorrect results. save_modified_graph_path: str | PathLike If provided, save the original graph after modification (without degree 2 loops and after branch labelling) to this path. label_branches: bool If True, label the branches in the graph with a unique chain ID. verbose: bool If True, print progress information during the reduction process. Returns ------- reduced_graph : GraphGt.Graph The reduced graph with degree 2 vertices removed and properties aggregated. """ save_modified_graph_path = str(save_modified_graph_path) # So we can check if empty string if verbose: timer = tmr.Timer() timer_all = tmr.Timer() print('Graph reduction: initialized.') check_graph_is_reduce_compatible(graph) if drop_pure_degree_2_loops: graph = drop_degree2_loops(graph) # WARNING: modifies graph in place # Find potential chains starting points non_degree_2_vertices_ids = (ap.where(graph.vertex_degrees() != 2)).array if verbose: print_graph_info(graph, timer, non_degree_2_vertices_ids) # Setup mappings vertex_to_edge_mappings = vertex_to_edge_mappings if vertex_to_edge_mappings is not None else DEFAULT_VERTEX_TO_EDGE edge_to_edge_mappings = edge_to_edge_mappings if edge_to_edge_mappings is not None else DEFAULT_EDGE_TO_EDGE edge_geometry_vertex_properties = edge_geometry_vertex_properties or [] edge_geometry_edge_properties = edge_geometry_edge_properties or [] reduced_maps = {k: [] for k in ('vertex_to_vertex', 'edge_to_vertex', 'edge_to_edge')} # TODO: see if part of egeom aggregators if compute_edge_length or edge_to_edge_mappings['length']: if 'length' not in graph.edge_properties: warnings.warn(f'Reducing a graph without preexisting edge lengths is now deprecated. ' f'Please ensure you use "compute_edge_length=True" in skeleton_to_graph() ' f'before running clean_graph and reduce_graph.',) edge_to_edge_mappings['length'] = edge_to_edge_mappings.get('length', np.sum) if label_branches: chain_id_prop_arr = add_chain_id(graph, prop_kind='edge') chain_id_vertex_prop_arr = add_chain_id(graph, prop_kind='vertex') vertex_agg = PropertyAggregator(graph, vertex_to_edge_mappings, kind='vertex', n_processes=n_processes) edge_agg = PropertyAggregator(graph, edge_to_edge_mappings, kind='edge', n_processes=n_processes) # vertex_geometry_agg = # edge_geometry_agg = chains = find_chains(graph) #, return_endpoints_mask=True); check_chains(g, chains, degree_2_vertices_ids, non_degree_2_vertices_ids) direct_edges = [] # Only the direct edges between non-degree 2 vertices edge_old_to_new = np.full(graph.n_edges, -1, dtype=np.intp) for chain_id, (edges, vertices) in enumerate(chains): edge_old_to_new[edges] = chain_id # Map old edge id to new edge id if label_branches: chain_id_prop_arr[edges] = chain_id # Assign chain id to edges chain_id_vertex_prop_arr[vertices] = chain_id direct_edges.append([vertices[0], vertices[-1]]) # mappings vertex_agg.accumulate(vertices) # TODO: check if vertices or vertices_ids should be used edge_agg.accumulate(edges) if save_modified_graph_path: graph.save(save_modified_graph_path) # Reduce each property to (typically) a single value per chain vertex_agg.aggregate() edge_agg.aggregate() # Create a copy with all vertices and properties, but no edges reduced_graph = graph.sub_graph(edge_filter=np.zeros(graph.n_edges, dtype=bool)) # Delete all edges but keep vertices and properties reduced_graph.add_edge(direct_edges) # Add new direct edges # edge_order = edge_old_to_new[reduced_graph.edge_indices()] # Reorder edges according to the new edge ids edge_order = reduced_graph.edge_indices() # Reorder edges according to the new edge ids reduced_graph, v_to_v_map = __drop_former_degree_2_nodes(graph, reduced_graph, non_degree_2_vertices_ids, return_maps) if return_maps: reduced_maps['vertex_to_vertex'] = v_to_v_map # Add edge properties edge_agg.apply(reduced_graph, edge_order) # Will also apply the edge geometry properties if they exist vertex_agg.apply(reduced_graph, edge_order) # Concatenate edge_geometry_properties to a graph property and keep ranges as edge geometry indices (edge property) if compute_edge_geometry: # Remap each vertex or edge property to the reduced graph (i.e., save as f'edge_geometry_{prop}' ) branch_indices, edge_geom_ranges = vertex_agg.get_indices_and_ranges(edge_order) reduced_graph.set_edge_geometry_vertex_properties(graph, edge_geometry_vertex_properties, # REFACTOR: pass aggregator branch_indices, edge_geom_ranges) if edge_geometry_edge_properties: reduced_graph.set_edge_geometry_edge_properties(graph, edge_geometry_edge_properties, # REFACTOR: pass aggregator edge_geom_ranges, edge_agg.chain_indices) if return_maps: reduced_maps['edge_to_edge'] = edge_agg.chain_indices if verbose: timer_all.print_elapsed_time(f'Graph reduction: Graph reduced ' f'from {graph.n_vertices} to {reduced_graph.n_vertices} nodes ' f'and {graph.n_edges} to {reduced_graph.n_edges} edges') if return_maps: return reduced_graph, list(reduced_maps.values()) # We rely on insertion order of the dict else: return reduced_graph
[docs] def check_chains(graph, chains, degree_2_v_ids, non_degree_2_v_ids): """ Check if the chains computation is correct by verifying that all degree 2 vertices are included in the chains and that all non-degree 2 vertices are not included in the chains. Parameters ---------- chains: list of list of edges List of chains in the graph. degree_2_v_ids: array-like Array of vertex ids with degree 2. non_degree_2_v_ids: array-like Array of vertex ids with degree not equal to 2. Returns ------- bool True if the computation is correct, False otherwise. """ degree_2_v_ids = np.unique(degree_2_v_ids) non_degree_2_v_ids = np.unique(non_degree_2_v_ids) d2_lists, end_lists = [], [] for _, chain_vs, end_point_mask in chains: v_arr = np.asarray(chain_vs, dtype=int) d2_lists.append(v_arr[~end_point_mask]) # internal degree-2 vertices end_lists.append(v_arr[end_point_mask]) # real endpoints (deg ≠ 2) d2s_ids_from_chains = np.unique(np.hstack(d2_lists)) non_d2s_ids_from_chains = np.unique(np.hstack(end_lists)) # chains_vertices = [c[1] for c in chains] # non_d2s_ids_from_chains = (np.unique(np.hstack([(c[0], c[-1]) for c in chains_vertices]))).astype(int) # first and last vertex of each chain # d2s_ids_from_chains = (np.unique(np.hstack([c[1:-1] for c in chains_vertices]))).astype(int) # all vertices except first and last diff_d2s = set(degree_2_v_ids) ^ set(d2s_ids_from_chains) assert len(diff_d2s) == 0, f'Degree 2 vertices not found in chains: {diff_d2s}' assert np.all(d2s_ids_from_chains == degree_2_v_ids) assert np.all(np.isin(non_degree_2_v_ids, non_d2s_ids_from_chains)) # all non-degree 2 from chains are in non_degree_2_v_ids
[docs] def find_chains(graph, *, return_endpoints_mask=False, return_edge_descriptors: bool = False): """ Find chains (i.e. list of edges between vertices that are either branching points or end points) in a graph. This is done in three steps and the results are combined: - Compute chains between degree 2 vertices that are connected to non-degree 2 vertices. - Add direct edges between non-degree 2 vertices. - Add pure degree 2 loops that are not connected to any non-degree 2 vertices. (if the graph is not fully connected, this will find isolated loops) Parameters ---------- graph: Graph The graph to process. return_endpoints_mask: bool If True, return a mask indicating which vertices are endpoints of the chains. This is particularly useful for debugging or further processing, especially in large graphs and when there may be pure degree 2 loops. where `endpoint_mask` is a 1-D ``bool`` np.NdArray aligned with `vertex_ids`. A value *True* means “this vertex is a real endpoint (degree != 2)”, *False* means “internal degree-2 vertex”. Default is *False*. return_edge_descriptors: bool If True, also return a list of edge descriptors (e.g. edge objects or tuples) for each chain. This is particularly useful for debugging or further processing, however, it has a computational cost because it materialises python objects. Returns ------- chains : list *Without mask* (default): list of 2-tuples (edge_ids, vertex_ids) *With mask* (return_endpoint_mask=True): list of 3-tuples (edge_ids, vertex_ids, endpoint_mask) """ # compute proper non_d2 --- d2...d2 --- non_d2 chains connectivity_w_eid = graph._base.get_edges(eprops=[graph._base.edge_index]).astype(np.uint32) connectivity_w_eid = np.roll(connectivity_w_eid, 1, axis=1) # move edge id to the first column v1_degs = graph._base.get_out_degrees(connectivity_w_eid[:, 1]).astype(np.uint32) v2_degs = graph._base.get_out_degrees(connectivity_w_eid[:, 2]).astype(np.uint32) end_branches = np.logical_xor(v1_degs == 2, v2_degs == 2) end_branches_idx = (ap.where(end_branches)).array end_branch_ids = connectivity_w_eid[end_branches_idx, 0] vertex_degs = graph.vertex_degrees().astype(np.uint32) chains = find_degree2_branches( np.ascontiguousarray(connectivity_w_eid), # FIXME: Check if contiguous is necessary end_branch_ids.astype(np.uint32), vertex_degs, ) if return_edge_descriptors: edge_descriptors = np.array(list(graph._base.edges()), dtype=object) # Add direct edges to chains to process together direct_edges = connectivity_w_eid[(v1_degs != 2) & (v2_degs != 2)] chains.extend(([eid], np.array([v1, v2], dtype=int)) for eid, v1, v2 in direct_edges) # Add pure degree2 loops visited = np.zeros(graph.n_vertices, dtype=bool) visited_vs_idx = np.hstack([vs for _, vs in chains], dtype=int) visited[visited_vs_idx] = True unvisited_deg2_v_idx = (ap.where(~visited & (vertex_degs == 2))).array if unvisited_deg2_v_idx.size: print(f'Found {len(unvisited_deg2_v_idx)} degree 2 vertices not visited in chains. ' f'They will be processed as isolated loops.') for v0 in unvisited_deg2_v_idx: if visited[v0]: # in case marked within this very loop continue vs, es, ends, _isolated = _graph_branch(graph.vertex(v0)) vs_idx = np.array(vs, dtype=int) edges_idx = [graph.edge_index(e) for e in es] chains.append((edges_idx, vs_idx)) visited[vs_idx] = True if not visited.sum() == graph.n_vertices: unvisited_ids = np.where(~visited)[0] raise ValueError(f'Not all vertices were visited during chain finding!\n' f'{visited.sum()} / {graph.n_vertices} visited.\n' f'Unvisited vertices: {unvisited_ids}\n' f'Unvisited vertices degrees: {graph.vertex_degrees()[unvisited_ids]}') if return_endpoints_mask: chains_out = [] for eids, vids in chains: mask = (vertex_degs[vids] != 2) # True endpoint chains_out.append((eids, vids, mask)) out = chains_out else: out = chains if return_edge_descriptors: return out, edge_descriptors else: return out
[docs] def check_graph_is_reduce_compatible(graph): # ensure conditions for processing step are fulfilled v_idx = graph._base.get_vertices() if graph.is_view: raise ValueError('Cannot process on graph view, prune graph before graph reduction.') if v_idx[0] != 0 or not np.all(np.diff(v_idx) == 1): raise ValueError('Graph vertices not ordered!')
def _graph_branch(v0): """Helper to follow a branch in a graph consisting of a chain of vertices of degree 2.""" edges_v0 = [e for e in v0.out_edges()] assert len(edges_v0) == 2 vertices_1, edges_1, endpoint_1 = _follow_branch(edges_v0[0], v0) if endpoint_1 != v0: vertices_2, edges_2, endpoint_2 = _follow_branch(edges_v0[1], v0) vertices_1.reverse() vertices_1.append(v0) vertices_1.extend(vertices_2) edges_1.reverse() edges_1.extend(edges_2) isolated_loop = False else: endpoint_2 = v0 isolated_loop = True return vertices_1, edges_1, [int(endpoint_1), int(endpoint_2)], isolated_loop def _follow_branch(e, v0): """ Helper to follow branch from vertex v0 in direction of edge e. .. note:: v0 is used to prevent infinite loops for isolated closed loops. Arguments --------- e : Edge The edge to follow. v0 : Vertex The starting vertex. """ edges = [e] v = e.target() vertices = [v] while v.out_degree() == 2 and v != v0: for e_new in v.out_edges(): if e_new != e: break e = e_new edges.append(e) v = e.target() vertices.append(v) return vertices, edges, v
[docs] def expand_graph_length(graph, length='length', return_edge_mapping=False): """Expand a reduced graph to a full graph.""" # data ec = graph.edge_connectivity() vertex_lengths = graph.edge_property(length) edge_lengths = np.asarray(vertex_lengths, dtype=int) - 1 # construct graph new_edges = edge_lengths > 1 # edges to create n_vertices = graph.n_vertices + int(np.sum(vertex_lengths - 2)) g = graph_gt.Graph(n_vertices=n_vertices) # construct edges offset = graph.n_vertices new_starts = np.hstack([[0], np.cumsum(edge_lengths[new_edges] - 1)]) new_ends = new_starts[1:] - 1 new_starts = new_starts[:-1] new_to_new = np.array([np.arange(new_ends[-1]), np.arange(new_ends[-1])+1]).T split = np.ones(new_ends[-1], dtype=bool).T split[new_ends[:-1]] = False new_to_new = new_to_new[split] new_starts += offset new_ends += offset new_to_new += offset existing_to_existing = ec[np.logical_not(new_edges)] existing_to_new = np.array([ec[new_edges, 0], new_starts]).T new_to_existing = np.array([new_ends, ec[new_edges, 1]]).T edges = np.vstack([existing_to_existing, existing_to_new, new_to_new, new_to_existing]) g.add_edge(edges) if return_edge_mapping: # edge order in new graph reorder = g.edge_indices() # mapping[new_edge_id] = old_edge_id existing_edges_id = np.where(np.logical_not(new_edges))[0] new_edges_id = np.where(new_edges)[0] mapping = np.hstack( [existing_edges_id, # existing edges new_edges_id, # existing to new np.repeat(new_edges_id, new_ends - new_starts), # new to new new_edges_id]) # new to existing; mapping = mapping[reorder] return g, mapping else: return g
[docs] def expand_graph(graph): raise NotImplementedError()
# if not graph.has_edge_geometry(): # return graph.copy() # # # calculate nodes of expanded graph # lengths = graph.edge_geometry_lengths() - 2 # n_add_vertices = np.sum(lengths) # # g = graph.copy() # g.add_vertex(n_add_vertices) # g.remove_edge_geometry() # # # re link edges and add vertex properties # # current_vertex = # # for e in graph.edge_iterator(): # # # # for name in graph.edge_geometry_names: # # graph.edge_geometry('coordinates') ############################################################################### # ## Label Tracing ###############################################################################
[docs] def trace_vertex_label(graph, vertex_label, condition, dilation_steps=1, max_iterations=None, pass_label=False, **condition_args): """ Traces label within a graph. Arguments --------- vertex_label : array Start label. condition : function(graph, vertex) A function determining if the vertex should be added to the labels. steps : int Number edges to jump to find new neighbours. Default is 1. Returns ------- vertex_label : array The traced label. """ label = vertex_label.copy() not_checked = np.logical_not(label) if max_iterations is None: max_iterations = np.inf iteration = 0 while iteration < max_iterations: label_dilated = graph.vertex_dilate_binary(label, steps=dilation_steps) label_new = np.logical_xor(label, label_dilated) label_new = np.logical_and(label_new, not_checked) vertices_new = np.where(label_new)[0] # print(label.sum(), label_dilated.sum(), label_new.sum()) # print(vertices_new) if len(vertices_new) == 0: break else: if pass_label: current_label = label.copy() for v in vertices_new: if condition(graph, v, current_label, **condition_args): label[v] = True not_checked[v] = False else: for v in vertices_new: if condition(graph, v, **condition_args): label[v] = True not_checked[v] = False iteration += 1 return label
[docs] def trace_edge_label(graph, edge_label, condition, max_iterations = None, dilation_steps = 1, pass_label = False, **condition_args): """Traces label within a graph. Arguments --------- edge_label : array Start label. condition : function(graph, vertex) A function determining if the vertex should be added to the labels. steps : int Number edges to jump to find new neighbours. Default is 1. Returns ------- edge_label : array The traced label. """ edge_graph, edge_map = graph.edge_graph(return_edge_map=True) traced = trace_vertex_label(edge_graph, edge_label, condition, max_iterations=max_iterations, dilation_steps=dilation_steps, pass_label=pass_label, **condition_args) label = np.zeros(len(traced), dtype=bool) label[edge_map] = traced # TODO: check if initial label need to be reordered too ? return label
[docs] def propagate_labels(graph, *, label_property, score_properties, competing_pairs, radii_property=None, min_radii=None, surface_mask_properties=None, label_priority=None, max_iterations=20, initial_threshold=0.15, threshold_growth=1.15, threshold_ceiling=0.6, unlabelled_value=0): """ Simultaneously propagate competing labels outward from seeds on a graph. At each iteration, each label type dilates by one graph step. A newly reached vertex/edge is claimed if: - its score for that label exceeds the current threshold - it is not already labelled - it is not masked by a surface mask - it is not adjacent to a competing label (1-step buffer) All input arrays are read from **vertex properties** of ``graph`` by name. This is designed to work with the edge-graph (line graph). Confidence decreases with distance from seeds: the acceptance threshold increases each iteration. After propagation, direct boundaries between competing labels are detected and stored as vertex properties ``'label_conflict'`` (boolean) and ``'label_confidence'`` (float in [0, 1]). A warning is emitted if any conflicts are found. Parameters ---------- graph : Graph The graph to propagate on (meant to be a line graph). Vertex properties are read by name. label_property : str Name of the vertex property holding initial labels (int). Modified in place during propagation. ``unlabelled_value`` marks unclaimed elements. score_properties : dict of int to str Maps label value to vertex property name holding scores for that label (float in [0, 1]). Keys are *kind* label values competing_pairs : dict of int to int Maps each label to its competitor (e.g. ``{1: 2, 2: 1}``). Both directions must be present. radii_property : str or None Name of the vertex property holding radii (float). Used with ``min_radii`` for a hard radius floor. min_radii : dict of int to float, or None Per-label minimum radius. Vertices below this cannot receive that label. If None, no radius constraint. surface_mask_properties : dict of int to str or None Maps label value to vertex property name holding a boolean surface mask (``True`` = propagation forbidden), use np.zeros or ``None`` for labels with no surface constraint. label_priority : dict of int to int, or None Maps label to priority. Higher-priority labels can convert adjacent lower-priority boundary edges. Currently unused; reserved for future use. max_iterations : int Maximum propagation rounds. initial_threshold : float Starting score threshold (energy) to cross for accepting a label (starts low near seeds). threshold_growth : float Multiplicative factor per iteration (> 1.0 means increasing). threshold_ceiling : float Maximum threshold — propagation stops trying above this. unlabelled_value : int The label value for unclaimed elements (means "not yet classified"). Returns ------- labels : np.ndarray Updated label array (extracted from the vertex property). n_iterations : int Number of iterations actually performed. Warns ----- UserWarning If direct boundaries between competing labels are detected after propagation. Inspect the ``'label_conflict'`` and ``'label_confidence'`` vertex properties for details. See Also -------- detect_label_conflicts : Identifies direct artery-vein boundary vertices. compute_label_confidence : Assigns distance-based confidence scores around conflicts. """ # Read arrays from vertex properties labels = graph.vertex_property(label_property) scores = {kind: graph.vertex_property(prop_name) for kind, prop_name in score_properties.items()} radii = graph.vertex_property(radii_property) if radii_property else None min_radii = min_radii or {kind: 0.0 for kind in competing_pairs} # Resolve surface masks surface_mask_properties = surface_mask_properties or {kind: None for kind in competing_pairs} surface_masks = {} for kind, prop_name in surface_mask_properties.items(): surface_masks[kind] = graph.vertex_property(prop_name) if prop_name else np.zeros(graph.n_vertices, dtype=bool) label_values = list(score_properties.keys()) not_checked = labels == unlabelled_value iteration = 0 threshold = initial_threshold for iteration in range(max_iterations): changed = False # Compute masks and dilations once per iteration (when we flip dilated <-> competing_dilated) label_masks = {lv: labels == lv for lv in label_values} dilated_masks = {lv: graph.vertex_dilate_binary(label_masks[lv], steps=1) for lv in label_values} for label_value, competitor_value in competing_pairs.items(): # (for artery/vein) candidates = (ap.where(dilated_masks[label_value] & not_checked)).array if len(candidates) == 0: continue # Score threshold (increasing with distance from seed) accept = scores[label_value][candidates] >= threshold # Hard radius floor if radii is not None: accept &= radii[candidates] >= min_radii[label_value] # Per-label surface constraint accept &= ~surface_masks[label_value][candidates] # Buffer: do not propagate adjacent to competitor accept &= ~dilated_masks[competitor_value][candidates] accepted = candidates[accept] if len(accepted) > 0: labels[accepted] = label_value not_checked[accepted] = False changed = True threshold *= threshold_growth if threshold >= threshold_ceiling: # wandering too far from seeds, confidence dropped break if not changed: # plateau reached, no more propagation possible break conflict_mask, n_conflicts = detect_label_conflicts(graph, labels, competing_pairs, label_masks=label_masks, dilated_masks=dilated_masks) if n_conflicts > 0: warnings.warn(f"{n_conflicts} direct artery-vein boundary conflicts detected.") confidence = compute_label_confidence(graph, labels, conflict_mask) graph.define_vertex_property('label_confidence', confidence) graph.define_vertex_property('label_conflict', conflict_mask.astype(np.uint8)) return labels, iteration + 1
[docs] def detect_label_conflicts(graph, labels, competing_pairs, label_masks=None, dilated_masks=None): """ Detect vertices where competing labels are directly adjacent. Parameters ---------- graph : Graph The graph (typically edge-graph / line graph). labels : np.ndarray Current label array. competing_pairs : dict of int to int Maps each label to its competitor (symmetric, e.g. ``{1: 2, 2: 1}``). label_masks : dict of int to np.ndarray, or None Pre-computed boolean masks per label value (``labels == label_value``). If None, recomputed here. dilated_masks : dict of int to np.ndarray, or None Pre-computed dilated masks per label value. If None, recomputed here. Returns ------- conflict_mask : np.ndarray Boolean array, ``True`` at direct conflict boundaries. n_conflicts : int Total number of conflict vertices found. """ conflict_mask = np.zeros(len(labels), dtype=bool) seen_pairs = set() for label_value, competitor_value in competing_pairs.items(): pair = frozenset((label_value, competitor_value)) if pair in seen_pairs: continue seen_pairs.add(pair) lm = label_masks[label_value] if label_masks is not None else labels == label_value cm = label_masks[competitor_value] if label_masks is not None else labels == competitor_value ld = dilated_masks[label_value] if dilated_masks is not None else graph.vertex_dilate_binary(lm, steps=1) conflict_mask |= (ld & cm) return conflict_mask, int(conflict_mask.sum())
[docs] def compute_label_confidence(graph, labels, conflict_mask, max_decay_steps=5): """ Compute a per-vertex confidence score based on distance from conflict boundaries. Vertices at a conflict boundary get confidence 0. Vertices further away decay linearly back to 1 over ``max_decay_steps`` steps. Parameters ---------- graph : Graph The graph (typically edge-graph / line graph). labels : np.ndarray Current label array (used only for array length). conflict_mask : np.ndarray Boolean array from :func:`detect_label_conflicts`. max_decay_steps : int Number of graph steps over which confidence recovers from 0 to 1. Returns ------- confidence : np.ndarray Float array in [0, 1]. 0 = conflict boundary, 1 = at least ``max_decay_steps`` away from any conflict. """ confidence = np.ones(len(labels), dtype=float) confidence[conflict_mask] = 0.0 decay_mask = conflict_mask.copy() for step in range(1, max_decay_steps + 1): decay_mask = graph.vertex_dilate_binary(decay_mask, steps=1) newly_affected = decay_mask & (confidence == 1.0) confidence[newly_affected] = 1.0 - (step / max_decay_steps) return confidence
############################################################################### # ## Tests ############################################################################### def _test(): import numpy as np import ClearMap.Tests.Files as tf import ClearMap.Analysis.graphs.graph_processing as gp from ClearMap.Analysis.graphs import graph_gt # reload(gp) skeleton = tf.source('skeleton') # import ClearMap.Visualization.Plot3d as p3d # p3d.plot(skeleton) # reload(gp) g = gp.graph_from_skeleton(skeleton) g.vertex_coordinates() s = gp.graph_to_skeleton(g) assert np.all(s == skeleton) gc = gp.clean_graph(g, verbose=True) gr = gp.reduce_graph(gc, verbose=True) gr2 = gr.copy() gr2.set_edge_geometry_type('edge') l = gr.edge_geometry_lengths() print(l) g = graph_gt.Graph(n_vertices=10) g.add_edge(np.array([[7, 8], [7, 9], [1, 2], [2, 3], [3, 1], [1, 4], [4, 5], [2, 6], [6, 7]])) g.set_vertex_coordinates(np.array([ [10, 10, 10], [0, 0, 0], [1, 1, 1], [1, 1, 0], [5, 0, 0], [8, 0, 1], [0, 7, 1], [0, 10, 2], [0, 12, 3], [3, 7, 7]], dtype=float)) import ClearMap.Visualization.Plot3d as p3d p3d.plot_graph_line(g) gc = gp.clean_graph(g, verbose=True) p3d.plot_graph_line(gc) gr = gp.reduce_graph(gc, compute_edge_geometry=True, verbose=True) # gr.set_edge_geometry(0.1*np.ones(gr.edge_geometry(as_list=False).shape[0]), 'radii') import ClearMap.Visualization.Plot3d as p3d vertex_colors = np.random.rand(g.n_vertices, 4) vertex_colors[:, 3] = 1 p3d.plot_graph_mesh(gr, default_radius=1, vertex_colors=vertex_colors) eg = gr.edge_geometry(as_list=False) egs = 0.5 * eg gr.set_edge_geometry(name='coordinates', values=egs) # tracing import numpy as np import ClearMap.Visualization.Plot3d as p3d from ClearMap.Analysis.graphs import graph_gt import ClearMap.Analysis.graphs.graph_processing as gp g = graph_gt.Graph(n_vertices=10) g.add_edge(np.array([[0, 1], [1, 2], [2, 3], [3, 4], [4, 0], [0, 5], [5, 6], [6, 7], [0, 8], [8, 9], [9, 0]])) g.set_vertex_coordinates(np.array([ [0, 0, 0], [1, 0, 0], [2, 0, 0], [2, 2, 0], [0, 1, 0], [0, 0, 1], [0, 0, 2], [0, 0, 3], [0, -1, 0], [0, -1, -1]], dtype=float)) g.set_vertex_radii(np.array([10, 6, 4, 6, 7, 4, 2, 2, 5, 5]) * 0.02) vertex_colors = np.array([g.vertex_radii()]*4).T vertex_colors = vertex_colors / vertex_colors.max() vertex_colors[:, 3] = 1 p3d.plot_graph_mesh(g, default_radius=1, vertex_colors=vertex_colors) def condition(graph, vertex): r = graph.vertex_radii(vertex=vertex) print(f'condition, vertex={vertex}, radius={r}') return r >= 5 * 0.02 label = np.zeros(g.n_vertices, dtype=bool) label[0] = True traced = gp.trace_vertex_label(g, label, condition=condition, steps=1) print(traced) vertex_colors = np.array([[1, 0, 0, 1], [1, 1, 1, 1]])[np.asarray(traced, dtype=int)] p3d.plot_graph_mesh(g, default_radius=1, vertex_colors=vertex_colors) from importlib import reload reload(gp) # edge tracing from ClearMap.Analysis.graphs import graph_gt edges = [[0, 1], [1, 2], [2, 3], [4, 5], [5, 6], [1, 7]] g = graph_gt.Graph(edges=edges) _, _ = g.edge_graph(return_edge_map=True) import numpy as np label = np.zeros(len(edges), dtype=bool) label[1] = True import ClearMap.Analysis.graphs.graph_processing as gp def condition(graph, edge): print(f'condition, edge={edge}') return True traced = gp.trace_edge_label(g, label, condition=condition) print(traced) # expansion of edge lengths import numpy as np import ClearMap.Tests.Files as tf import ClearMap.Analysis.graphs.graph_processing as gp graph = graph_gt.Graph(n_vertices=5) graph.add_edge([[0, 1], [0, 2], [0, 3], [2, 3], [2, 1], [0, 4]]) graph.add_edge_property('length', np.array([0, 1, 2, 3, 4, 5]) + 2) e, m = gp.expand_graph_length(graph, 'length', True) import graph_tool.draw as gd from ClearMap.Analysis.graphs import graph_gt pos = graph_gt.vertex_property_map_to_python(gd.sfdp_layout(e.base)) import matplotlib.pyplot as plt plt.figure(1) plt.clf() import matplotlib.collections as mc import ClearMap.Visualization.Color as col colors = np.array([col.color(c) for c in ['red', 'blue', 'green', 'black', 'purple', 'orange']]) ec = e.edge_connectivity() lines = pos[ec] cols = colors[m] lc = mc.LineCollection(lines, linewidths=1, color=cols) ax = plt.gca() ax.add_collection(lc) ax.autoscale() ax.margins(0.1) plt.scatter(pos[:, 0], pos[:, 1]) p = pos[:graph.n_vertices] plt.scatter(p[:, 0], p[:, 1], color='red')