Source code for rivgraph.ln_utils

# -*- coding: utf-8 -*-
"""
Network Utilities (ln_utils.py)
===============================

Created on Mon Sep 10 09:59:52 2018

@author: Jon
"""
from loguru import logger
import shapely
import numpy as np
from scipy.stats import mode
import geopandas as gpd
import networkx as nx
from copy import deepcopy
from matplotlib import pyplot as plt
from matplotlib import colors
import matplotlib.collections as mcoll
from pyproj.crs import CRS
import rivgraph.geo_utils as gu
from rivgraph.ordered_set import OrderedSet


[docs]def add_node(nodes, idx, linkconn): """ Add a new node to the network. Adds a new node to the network. Connectivity is updated in links to account for the added node. No node properties (e.g. juncation angle) are updated. Parameters ---------- nodes : dict Network nodes and associated properties. idx : int Pixel index within the original mask of the node to add. linkconn : list or int ID(s) of the link that the node is connected to. Returns ------- nodes : dict Network nodes with the node added. """ if type(linkconn) is not list: linkconn = [linkconn] if idx in nodes['idx']: logger.info('Node already in set; returning unchanged.') return nodes # Find new node ID new_id = max(nodes['id']) + 1 # Append new node nodes['id'].append(new_id) nodes['idx'].append(idx) nodes['conn'].append(linkconn) return nodes
[docs]def node_updater(nodes, idx, conn): """ Update the node dictionary. Updates node dictionary by adding connectivity and idx information to an existing node. The function cannot add a node to the network. Parameters ---------- nodes : dict Network nodes and associated properties Should contain 'id', 'idx', and 'conn' keys at a minimum. idx : int The index of the node, as found in nodes['idx']. conn : int Link id (as found in links['id']) connected to the node. Returns ------- nodes : dict Network nodes with node updated. """ if idx not in nodes['idx']: nodes['idx'].append(idx) if len(nodes['conn']) < len(nodes['idx']): nodes['conn'].append([]) nodeid = nodes['idx'].index(idx) nodes['conn'][nodeid] = nodes['conn'][nodeid] + [conn] return nodes
[docs]def delete_node(nodes, nodeid, warn=True): """ Delete a node from the network. Deletes a node from the network. Assumes that the node's connected links have already been deleted, and hence does not update the links dictionary. Parameters ---------- nodes : dict Network nodes and associated properties. nodeid : int ID of the node (as found in nodes['id']) to delete. warn : bool, optional If True, will print a warning if a node is being deleted that is still connected to links in the network. The default is True. Returns ------- nodes : dict Network nodes with the node deleted. """ # Get keys that have removable elements nodekeys = [nk for nk in nodes.keys() if type(nodes[nk]) is not int and len(nodes[nk]) == len(nodes['id'])] # Check that the node has no connectivity nodeidx = nodes['id'].index(nodeid) if len(nodes['conn'][nodeidx]) != 0 and warn == True: logger.info('You are deleting node {} which still has connections to links.'.format(nodeid)) # Remove the node and its properties for nk in nodekeys: if nk == 'id': # have to treat orderedset differently nodes[nk].remove(nodeid) elif nk == 'idx': nodes[nk].remove(nodes[nk][nodeidx]) else: nodes[nk].pop(nodeidx) return nodes
[docs]def junction_angles(links, nodes, imshape, pixlen, weight=None): """ Compute junction angles. Computes junction angles between links in a network with directions set. Only nodes of degree three are considered for simplicity. Angles are only computed between the two links that share a common flow directions. The acute-most angle between these two links is returned. The direction of an individual link is poorly constrained. The number of pixels along the link to consider for computing its direction can result in vastly different directions depending on the link's morphology. The weight argument above allows a degree of control over how to weight the contribution of pixels to a link's direction vector. Also computes the ratio of link widths (max/min) at junctions, and determines whether the junction is a confluence (joining) or a bifurcation (diverging). The following attributes are appended to the nodes dictionary: - **int_ang** : the interior angle between the two links in the - **width_ratio** : the ratio of the maximum/minimum link widths for the two links whose directions agree - **jtype** : the junction type, either 'b'ifurcation or 'c'onfluence Parameters ---------- links : dict Network links and associated properties. nodes : dict Network nodes and associated properties. imshape : tuple (nrows, ncols) of the original mask. pixlen : numeric Resolution of the image; assumes same resolution in the horizontal and vertical. weight : str, optional How to weight pixel contributions to a link's direction vector as we move away from the joining node. Choose from - **None** : all pixels are weighted equally - **lin** : the contribution of pixels decreases linearly as we move away from the joining node. - **exp** : the contribution of pixels decreases exponentially as we move away from the joining node. The default is None. Raises ------ KeyError Ensures that flow directions have been computed before running this function. RuntimeError Catches any strange cases where a junction is neither a bifurcation nor junction. Have not encountered this yet, please report if triggered. Returns ------- nodes : dict Network nodes and associated properties. """ # Enusre directions have been computed if 'certain' not in links.keys(): raise KeyError('Cannot compute junction angles until link flow directions have been computed.') width_mult = 1.1 # minimum length of link required to use trimmed link for computing angle min_linklen = 5 # minimum number of pixels required in a link to compute its angle link_vector_length = 1 # (units of link widths) when determining the link's direction, how far along it should we consider? int_angs = [-1 for i in range(len(nodes['id']))] jtype = int_angs.copy() # for storing the junction type widratio = int_angs.copy() for nidx, (nid, nconn) in enumerate(zip(nodes['id'], nodes['conn'])): if len(nconn) > 2: nconn = nodes['conn'][nodes['id'].index(nid)] # Get indices of links connected at this node lidx = [links['id'].index(lid) for lid in nconn] # Only consider cases where three links join at a node if len(lidx) != 3: continue # Determine links leaving node leaving = [lid for lid in lidx if links['conn'][lid][0] == nid] entering = [lid for lid in lidx if lid not in leaving] # Check for source/sink if len(leaving) == 0 or len(entering) == 0: continue # Determine if we have a confluence or a bifurcation if len(leaving) == 2: jtype[nidx] ='b' # bifurcation use_links = leaving flip = 0 elif len(entering) == 2: jtype[nidx] = 'c' # confluence use_links = entering flip = 1 # note that links should be flipped for origin to be at node of interest else: raise RuntimeError # Compute width ratio bothwids = [links['wid_adj'][ul] for ul in use_links] widratio[nidx] = max(bothwids)/min(bothwids) # Compute the angles of each link's vector # Links are trimmed by the half-width value of their two endpoints if possible before vectors are computed angs = [] for ulidx in use_links: idcs = links['idx'][ulidx] halfwids = links['wid_pix'][ulidx] / pixlen / 2 # Flip if necessary if flip == 1: idcs = idcs[::-1] halfwids = halfwids[::-1] # Trim the links by the half-width values at their endpoints, if possible xy = np.unravel_index(idcs, imshape) # Compute distances along link dists = np.cumsum(np.sqrt(np.diff(xy[0])**2 + np.diff(xy[1])**2)) dists = np.insert(dists, 0, 0) # Compute distances along link in opposite direction revdists = np.cumsum(np.flipud(np.sqrt(np.diff(xy[0])**2 + np.diff(xy[1])**2))) revdists = np.insert(revdists, 0, 0) # Find the first and last pixel along the link that is at least a half-width's distance away startidx = np.argmin(np.abs(dists - halfwids[0]/2*width_mult)) endidx = len(dists) - np.argmin(np.abs(revdists - halfwids[-1]/2*width_mult)) - 1 # This is where the trimming is actually done (if possible) # Ensures that each link will have at least min_linklen pixels if endidx - startidx > min_linklen: idcs = idcs[startidx:endidx] halfwids = halfwids[startidx:endidx] # Compute link vector direction xy = np.unravel_index(idcs, imshape) # Compute distances along link dists = np.cumsum(np.sqrt(np.diff(xy[0])**2 + np.diff(xy[1])**2)) dists = np.insert(dists, 0, 0) stopidx = np.max((np.argmax(dists > link_vector_length*links['wid_adj'][ulidx]/pixlen), min_linklen)) rows = xy[0][:stopidx] cols = xy[1][:stopidx] # Shift the pixel links to the origin rows = np.max(rows) - rows # Flip the y-coordinates so that up is positive rows = rows[1:] - rows[0] cols = cols[1:] - cols[0] # Instead of averaging angles, we average the unit vectors from the origin to each pixel uv_norm = np.sqrt(rows**2 + cols**2) rows = rows / uv_norm cols = cols / uv_norm # Account for weighting if specified if weight is not None: if weight == 'exp': weights = np.exp(-np.arange(0, len(cols), 1)) elif weight == 'linear': weights = np.arange(len(cols), 0, -1) avg_row = np.sum(weights * rows) / np.sum(weights) avg_col = np.sum(weights * cols) / np.sum(weights) else: avg_row = np.mean(rows) avg_col = np.mean(cols) ang = np.arctan2(avg_row, avg_col) * 180 / np.pi ang = (ang + 360) % 360 # convert to more intuitive angle (1,0) -> 0 degrees, (0,1) -> 90 degrees angs.append(ang) # save # Interior angle between the two links int_angs[nidx] = np.min([np.abs(angs[0]-angs[1]), 360 - angs[0] + angs[1], 360 - angs[1] + angs[0]]) nodes['int_ang'] = int_angs nodes['jtype'] = jtype nodes['width_ratio'] = widratio return nodes
[docs]def adjust_for_padding(links, nodes, npad, dims, initial_dims): """ Adjust mask for any padding. In some cases, a mask is padded before extracting the network. In order to ensure the extracted network maps to the original mask, the padding must be stripped away from all the coordinates of the network. The function achieves this by adjusting the ['idx'] attributes of the links and nodes. Parameters ---------- links : dict Network links and associated properties. nodes : dict Network nodes and associated properties. npad : int Pad width to remove. Assumes padding was performed on all four edges of image. dims : tuple (nrows, ncols) of the padded image. initial_dims : tuple (nrows, ncols) of the original (unpadded) image. Returns ------- links : dict Network links adjusted to remove padding. nodes : dict Network nodes adjusted to remove padding. """ # Adjust the link indices adjusted_lidx = [] for lidx in links['idx']: rc = np.unravel_index(lidx, dims) rc = (rc[0]-npad, rc[1]-npad) lidx_adj = np.ravel_multi_index(rc, initial_dims) adjusted_lidx.append(lidx_adj.tolist()) links['idx'] = adjusted_lidx # Adjust the node idx adjusted_nidx = [] for nidx in nodes['idx']: rc = np.unravel_index(nidx, dims) rc = (rc[0]-npad, rc[1]-npad) nidx_adj = np.ravel_multi_index(rc, initial_dims) adjusted_nidx.append(nidx_adj) nodes['idx'] = OrderedSet(adjusted_nidx) return links, nodes
[docs]def remove_all_spurs(links, nodes, dontremove=[]): """ Remove spurs. Removes all links who have a node of degree one. This is performed iteratively until all spurs are removed. Also removes redundant nodes (i.e. nodes with degree two.) Parameters ---------- links : dict Network link and associated properties. nodes : dict Network nodes and associated properties. dontremove : list, optional Node IDs not to remove (e.g. inlet and/or outlet nodes). The default is []. Returns ------- links : dict Network links with spurs removed. nodes : dict Network nodes with spurs removed. """ stopflag = 0 while stopflag == 0: ct = 0 # Remove spurs for nid, con in zip(nodes['id'], nodes['conn']): if len(con) == 1 and nid not in dontremove: ct = ct + 1 links, nodes = delete_link(links, nodes, con[0]) # Remove self-looping links (a link that starts and ends at the same node) for nid, con in zip(nodes['id'], nodes['conn']): m = mode(con) if m.count[0] > 1: # Get link looplink = m.mode[0] # Delete link links, nodes = delete_link(links, nodes, looplink) ct = ct + 1 # Remove all the nodes with only two links attached links, nodes = remove_two_link_nodes(links, nodes, dontremove) if ct == 0: stopflag = 1 return links, nodes
[docs]def add_artificial_nodes(links, nodes, gd_obj): """ Add artificial nodes. Some topologic metrics fail for graphs that contain parallel links, or links that share the same end nodes. This function alleviates that issue by inserting artificial nodes into all but one of the parallel links. The shortest link of the parallel set will not receive an artificial node. For simplicity of coding, when a node is added, the old link is deleted and two new links are put in its place. A new attribute is appended to the nodes dictionary called 'arts' that contains the node IDs of artifical nodes. If flow directions are needed, this function should be run after setting them. If flow directions have been set for links, the properties of the link that is broken into two are appended to the two parts. This may result in some properties that are no longer correct, e.g. slope. Additionally, the 'guess' attribute of the links where artificial nodes are added will be incorrect as it doesn't account for the artificial node, but references the original end nodes. Parameters ---------- links : dict Network links and associated properties. nodes : dict Network nodes and associated properties. gdobj : osgeo.gdal.Dataset GDAL dataset of the original mask, created via gdal.Open(). Returns ------- links : dict Network links with aritifical nodes added. nodes : dict Network nodes with artificial nodes added as a new 'arts' attribute. """ # Links dictionary keys to copy to new links keys_to_copy = ['certain', 'certain_order', 'certain_alg', 'guess', 'guess_alg', 'slope'] # Find parallel edge pairs/triplets/etc that require aritifical nodes if 'parallels' not in links.keys(): links, nodes = find_parallel_links(links, nodes) pairs = links['parallels'] # Append lengths if not already if 'len' not in links.keys(): links = append_link_lengths(links, gd_obj) arts = [] # Add the aritifical node to the proper links for p in pairs: # Note that pairs can be triplets etc. as well # Choose the longest link(s) to add the artificial node lens = [links['len'][links['id'].index(l)] for l in p] minlenidx = np.argmin(lens) links_to_break = [l for il, l in enumerate(p) if il != minlenidx] # Break each link and add a node for l2b in links_to_break: lidx = links['id'].index(l2b) idx = links['idx'][lidx] # Break link halfway; must find halfway first coords = gu.idx_to_coords(idx, gd_obj) dists = np.cumsum(np.sqrt(np.diff(coords[0])**2 + np.diff(coords[1])**2)) dists = np.insert(dists, 0, 0) halfdist = dists[-1]/2 halfidx = np.argmin(np.abs(dists-halfdist)) # Create two new links newlink1_idcs = idx[:halfidx+1] newlink2_idcs = idx[halfidx:] # Adding links will also add the required artificial node links, nodes = add_link(links, nodes, newlink1_idcs) links, nodes = add_link(links, nodes, newlink2_idcs) # Append the properties to the new links for k in keys_to_copy: if k in links.keys(): if type(links[k]) is list: links[k].append(links[k][lidx]) links[k].append(links[k][lidx]) elif type(links[k]) is np.ndarray: links[k] = np.append(links[k], links[k][lidx]) links[k] = np.append(links[k], links[k][lidx]) # Ensure the connectivity remains the same us_node = links['conn'][lidx][0] ds_node = links['conn'][lidx][1] newlinkids = links['id'][-2:] for nl in newlinkids: lconn = links['conn'][links['id'].index(nl)] if us_node in lconn: if lconn[0] != us_node: lconn = lconn[::-1] if ds_node in lconn: if lconn[1] != ds_node: lconn = lconn[::-1] # Delete the old link links, nodes = delete_link(links, nodes, l2b) # Keep track of the added aritifical nodes arts.append(nodes['id'][nodes['idx'].index(idx[halfidx])]) # Remove lengths from links _ = links.pop('len', None) # Store artificial nodes in nodes dict nodes['arts'] = arts # Get links corresponding to aritifical nodes; there should three links # for every added artificial node. links = find_art_links(links, nodes) # Remove the length and width keys from the links dictionary--these need # to be recomputed after adding aritifical nodes if len(arts) > 0 and 'len_adj' in links.keys(): to_rem = ['wid', 'wid_pix', 'wid_adj', 'len_adj', 'len'] for tr in to_rem: if tr in links.keys(): del links[tr] logger.info('{} artificial nodes added. Link lengths and widths should be recomputed via the link_widths_and_lengths() function in ln_utils.'.format(len(arts))) return links, nodes
[docs]def plot_network(links, nodes, Imask, name=None, label_links=True, label_nodes=True, axis=None): """ Plots the network with labeled link and node IDs. Parameters ---------- links : dict Network links and associated properties. nodes : dict Network nodes and associated properties. Imask : np.array The original binary mask. name : str, optional Name of the channel network for labeling the plot. label_links : bool, optional If True, will label all links with their ids. label_nodes : bool, optional If True, will label all nodes with their ids. axis : matplotlib.axex._subplots.AxesSubplot, optional If provided, plotting will occur within the provided axis. Can be created with matplotlib.pyplot.subplots(). The default is None. Returns ------- None. """ imshape = Imask.shape # Colormap for binary mask cmap = colors.ListedColormap(['white', 'lightblue']) # Create figure if axis == None: f, axis = plt.subplots() # Plot binary image axis.imshow(Imask, cmap=cmap) # Plot and label links for lid, lidcs in zip(links['id'], links['idx']): mididx = round(len(lidcs)/2) rc = np.unravel_index(lidcs, imshape) axis.plot(rc[1], rc[0], 'darkgrey') if label_links is True: axis.text(rc[1][mididx], rc[0][mididx], lid, color='black') # Plot and label nodes for nid, nidx in zip(nodes['id'], nodes['idx']): rc = np.unravel_index(nidx, imshape) axis.plot(rc[1], rc[0], '.', color='r') if label_nodes is True: axis.text(rc[1], rc[0], nid, color='r') plt.axis('equal') if name is not None: axis.set_title(name) plt.show(block=False) return