Networkx:如何将边与条件结合在一起?

Networkx: how to combine edges together with condition?

我正在使用 Open Street Data,我已经通过 NetworkX 对其进行了丰富和建模。 然而,有大量节点仅用于设计边的曲线,因为它们只有两条边。

因此,我需要通过聚合将这些节点彼此连接的边来简化图形,同时保持聚合边的属性。

这是一个可重现的例子:

import networkx as nx
from shapely.geometry import LineString, Point

G = nx.Graph()
G.add_nodes_from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
G.add_edges_from([(1, 2, {'highway': 'primary', 'speed_kph': 90, 'slope': 1.2,
                          'geometry': LineString([Point(3.013832,42.785837), Point(3.010505, 42.787605)])}),
                  (2, 3, {'highway': 'primary', 'speed_kph': 70, 'slope': 0.8,
                          'geometry': LineString([Point(3.010505, 42.787605), Point(3.006227, 42.789605)])}),
                  (3, 4, {'highway': 'primary', 'speed_kph': 50, 'slope': -0.1,
                          'geometry': LineString([Point(3.006227, 42.789605), Point(3.001030, 42.789721)])}),
                  (4, 5, {'highway': 'secondary', 'speed_kph': 50, 'slope': 3.1,
                          'geometry': LineString([Point(3.001030, 42.789721), Point(3.000998, 42.79321)])}),
                  (5, 6, {'highway': 'primary', 'speed_kph': 70, 'slope': -1.3,
                          'geometry': LineString([Point(3.000998, 42.79321), Point(2.995483, 42.795675)])}),
                  (4, 10, {'highway': 'tertiary', 'speed_kph': 50, 'slope': 3.7,
                          'geometry': LineString([Point(3.001030, 42.789721), Point(2.998273, 42.787303)])}),
                  (10, 11, {'highway': 'tertiary', 'speed_kph': 30, 'slope': 2.9,
                          'geometry': LineString([Point(2.998273, 42.787303), Point(2.995231, 42.784279)])}),
                  (3, 7, {'highway': 'secondary', 'speed_kph': 50, 'slope': 5.6,
                          'geometry': LineString([Point(3.006227, 42.789605), Point(3.009407, 42.791873)])}),
                  (7, 8, {'highway': 'secondary', 'speed_kph': 50, 'slope': 6.4,
                          'geometry': LineString([Point(3.009407, 42.791873), Point(3.009217, 42.794710)])}),
                  (8, 9, {'highway': 'secondary', 'speed_kph': 50, 'slope': -4.3,
                          'geometry': LineString([Point(3.009217, 42.794710), Point(3.005858, 42.796036)])}),
                  (9, 5, {'highway': 'secondary', 'speed_kph': 50, 'slope': -7.2,
                          'geometry': LineString([Point(3.005858, 42.796036), Point(3.000998, 42.79321)])})])

nx.draw(G, with_labels=True)
plt.show()

如您所见,边 (1,2) 和 (2,3) 可以聚合在一起,以及 (4,10) 和 (10,11),甚至 (3,7), (7,8), (8,9), (9,5) 可以概括为一条边。

然而,在聚合时,我不想丢失 speed_kphslope 属性,并选择保持合并边的平均值。 至于几何,我想结合线串。所以得到的网络应该如下

import numpy as np
from shapely.ops import linemerge

new_G = nx.Graph()
new_G.add_nodes_from([1, 3, 4, 5, 6, 11])
new_G.add_edges_from([(1, 3, {'highway': 'primary', 'speed_kph': np.mean([90, 70]), 'slope': np.mean([1.2, 0.8]),
                              'geometry': linemerge([LineString([Point(3.013832,42.785837), Point(3.010505, 42.787605)]),
                                                    LineString([Point(3.010505, 42.787605), Point(3.006227, 42.789605)])])}),
                      (3, 4, {'highway': 'primary', 'speed_kph': 50, 'slope': -0.1,
                              'geometry': LineString([Point(3.006227, 42.789605), Point(3.001030, 42.789721)])}),
                      (4, 5, {'highway': 'secondary', 'speed_kph': 50, 'slope': 3.1,
                              'geometry': LineString([Point(3.001030, 42.789721), Point(3.000998, 42.79321)])}),
                      (5, 6, {'highway': 'primary', 'speed_kph': 70, 'slope': -1.3,
                              'geometry': LineString([Point(3.000998, 42.79321), Point(2.995483, 42.795675)])}),
                      (4, 11, {'highway': 'primary', 'speed_kph': np.mean([50, 30]), 'slope': np.mean([3.7, 2.9]),
                              'geometry': linemerge([LineString([Point(3.001030, 42.789721), Point(2.998273, 42.787303)]),
                                                    LineString([Point(2.998273, 42.787303), Point(2.995231, 42.784279)])])}),
                      (3, 5, {'highway': 'primary', 'speed_kph': np.mean([50, 50, 50, 50]), 'slope': np.mean([5.6, 6.4, -4.3, -7.2]),
                              'geometry': linemerge([LineString([Point(3.006227, 42.789605), Point(3.009407, 42.791873)]),
                                                    LineString([Point(3.009407, 42.791873), Point(3.009217, 42.794710)]),
                                                    LineString([Point(3.009217, 42.794710), Point(3.005858, 42.796036)]),
                                                    LineString([Point(3.005858, 42.796036), Point(3.000998, 42.79321)])])}),
                      ])

nx.draw(new_G, with_labels=True)
plt.show()

有人知道我该怎么做吗?

当然,我的想法是自动检测可组合的边缘(可能使用 degree 上的阈值),因为我正在使用比那个玩具示例高得多的边缘并且不能手动完成。

我注意到这是在 osmnx 中实现的(参见 here),但仅此而已,它仅适用于 MultiDiGraph

我还没有找到将 networkx 图变成 osmnx 图的方法,所以我调整了 osmnx 代码并借此机会根据我的需要对其进行了调整:

def _is_endpoint(G, node, strict=True):
    """
    Is node a true endpoint of an edge.
    Return True if the node is a "real" endpoint of an edge in the network,
    otherwise False. OSM data includes lots of nodes that exist only as points
    to help streets bend around curves. An end point is a node that either:
    1) is its own neighbor, ie, it self-loops.
    2) or, has no incoming edges or no outgoing edges, ie, all its incident
    edges point inward or all its incident edges point outward.
    3) or, it does not have exactly two neighbors and degree of 2 or 4.
    4) or, if strict mode is false, if its edges have different OSM IDs.
    Parameters
    ----------
    G : networkx.MultiDiGraph
        input graph
    node : int
        the node to examine
    strict : bool
        if False, allow nodes to be end points even if they fail all other rules
        but have edges with different OSM IDs
    Returns
    -------
    bool
    """
    neighbors = set(list(G.predecessors(node)) + list(G.successors(node)))
    n = len(neighbors)
    d = G.degree(node)

    # rule 1
    if node in neighbors:
        # if the node appears in its list of neighbors, it self-loops
        # this is always an endpoint.
        return True

    # rule 2
    elif G.out_degree(node) == 0 or G.in_degree(node) == 0:
        # if node has no incoming edges or no outgoing edges, it is an endpoint
        return True

    # rule 3
    elif not (n == 2 and (d == 2 or d == 4)):
        # else, if it does NOT have 2 neighbors AND either 2 or 4 directed
        # edges, it is an endpoint. either it has 1 or 3+ neighbors, in which
        # case it is a dead-end or an intersection of multiple streets or it has
        # 2 neighbors but 3 degree (indicating a change from oneway to twoway)
        # or more than 4 degree (indicating a parallel edge) and thus is an
        # endpoint
        return True

    # rule 4
    elif not strict:
        # non-strict mode: do its incident edges have different OSM IDs?
        osmids = []

        # add all the edge OSM IDs for incoming edges
        for u in G.predecessors(node):
            for key in G[u][node]:
                osmids.append(G.edges[u, node, key]["osmid"])

        # add all the edge OSM IDs for outgoing edges
        for v in G.successors(node):
            for key in G[node][v]:
                osmids.append(G.edges[node, v, key]["osmid"])

        # if there is more than 1 OSM ID in the list of edge OSM IDs then it is
        # an endpoint, if not, it isn't
        return len(set(osmids)) > 1

    # if none of the preceding rules returned true, then it is not an endpoint
    else:
        return False


def _build_path(G, endpoint, endpoint_successor, endpoints):
    """
    Build a path of nodes from one endpoint node to next endpoint node.
    Parameters
    ----------
    G : networkx.MultiDiGraph
        input graph
    endpoint : int
        the endpoint node from which to start the path
    endpoint_successor : int
        the successor of endpoint through which the path to the next endpoint
        will be built
    endpoints : set
        the set of all nodes in the graph that are endpoints
    Returns
    -------
    path : list
        the first and last items in the resulting path list are endpoint
        nodes, and all other items are interstitial nodes that can be removed
        subsequently
    """
    # start building path from endpoint node through its successor
    path = [endpoint, endpoint_successor]

    # for each successor of the endpoint's successor
    for successor in G.successors(endpoint_successor):
        if successor not in path:
            # if this successor is already in the path, ignore it, otherwise add
            # it to the path
            path.append(successor)
            while successor not in endpoints:
                # find successors (of current successor) not in path
                successors = [n for n in G.successors(successor) if n not in path]

                # 99%+ of the time there will be only 1 successor: add to path
                if len(successors) == 1:
                    successor = successors[0]
                    path.append(successor)

                # handle relatively rare cases or OSM digitization quirks
                elif len(successors) == 0:
                    if endpoint in G.successors(successor):
                        # we have come to the end of a self-looping edge, so
                        # add first node to end of path to close it and return
                        return path + [endpoint]
                    else:  # pragma: no cover
                        # this can happen due to OSM digitization error where
                        # a one-way street turns into a two-way here, but
                        # duplicate incoming one-way edges are present
                        print(
                            f"Unexpected simplify pattern handled near {successor}")
                        return path
                else:  # pragma: no cover
                    # if successor has >1 successors, then successor must have
                    # been an endpoint because you can go in 2 new directions.
                    # this should never occur in practice
                    raise Exception(f"Unexpected simplify pattern failed near {successor}")

            # if this successor is an endpoint, we've completed the path
            return path

    # if endpoint_successor has no successors not already in the path, return
    # the current path: this is usually due to a digitization quirk on OSM
    return path


def _get_paths_to_simplify(G, strict=True):
    """
    Generate all the paths to be simplified between endpoint nodes.
    The path is ordered from the first endpoint, through the interstitial nodes,
    to the second endpoint.
    Parameters
    ----------
    G : networkx.MultiDiGraph
        input graph
    strict : bool
        if False, allow nodes to be end points even if they fail all other rules
        but have edges with different OSM IDs
    Yields
    ------
    path_to_simplify : list
    """
    # first identify all the nodes that are endpoints
    endpoints = set([n for n in G.nodes if _is_endpoint(G, n, strict=strict)])
    print(f"Identified {len(endpoints)} edge endpoints")

    # for each endpoint node, look at each of its successor nodes
    for endpoint in endpoints:
        for successor in G.successors(endpoint):
            if successor not in endpoints:
                # if endpoint node's successor is not an endpoint, build path
                # from the endpoint node, through the successor, and on to the
                # next endpoint node
                yield _build_path(G, endpoint, successor, endpoints)


def simplify_graph(G, strict=True, remove_rings=True, aggregation={}):
    """
    Simplify a graph's topology by removing interstitial nodes.
    Simplifies graph topology by removing all nodes that are not intersections
    or dead-ends. Create an edge directly between the end points that
    encapsulate them, but retain the geometry of the original edges, saved as
    a new `geometry` attribute on the new edge. Note that only simplified
    edges receive a `geometry` attribute. Some of the resulting consolidated
    edges may comprise multiple OSM ways, and if so, their multiple attribute
    values are stored as a list.
    Parameters
    ----------
    G : networkx.MultiDiGraph
        input graph
    strict : bool
        if False, allow nodes to be end points even if they fail all other
        rules but have incident edges with different OSM IDs. Lets you keep
        nodes at elbow two-way intersections, but sometimes individual blocks
        have multiple OSM IDs within them too.
    remove_rings : bool
        if True, remove isolated self-contained rings that have no endpoints
    Returns
    -------
    G : networkx.MultiDiGraph
        topologically simplified graph, with a new `geometry` attribute on
        each simplified edge
    """
    if "simplified" in G.graph and G.graph["simplified"]:  # pragma: no cover
        raise Exception("This graph has already been simplified, cannot simplify it again.")

    print("Begin topologically simplifying the graph...")

    # make a copy to not mutate original graph object caller passed in
    G = G.copy()
    initial_node_count = len(G)
    initial_edge_count = len(G.edges)
    all_nodes_to_remove = []
    all_edges_to_add = []

    # generate each path that needs to be simplified
    for path in _get_paths_to_simplify(G, strict=strict):

        # add the interstitial edges we're removing to a list so we can retain
        # their spatial geometry
        path_attributes = dict()
        for u, v in zip(path[:-1], path[1:]):

            # there should rarely be multiple edges between interstitial nodes
            # usually happens if OSM has duplicate ways digitized for just one
            # street... we will keep only one of the edges (see below)
            edge_count = G.number_of_edges(u, v)
            if edge_count != 1:
                print(f"Found {edge_count} edges between {u} and {v} when simplifying")

            # get edge between these nodes: if multiple edges exist between
            # them (see above), we retain only one in the simplified graph
            edge_data = G.edges[u, v, 0]
            for attr in edge_data:
                if attr in path_attributes:
                    # if this key already exists in the dict, append it to the
                    # value list
                    path_attributes[attr].append(edge_data[attr])
                else:
                    # if this key doesn't already exist, set the value to a list
                    # containing the one value
                    path_attributes[attr] = [edge_data[attr]]

        # consolidate the path's edge segments' attribute values
        for attr in path_attributes:
            if attr in aggregation.keys():
                # if this is an aggregation attribute, aggregate the values
                path_attributes[attr] = aggregation.get(attr)(path_attributes[attr])

        # # construct the new consolidated edge's geometry for this path
        # path_attributes["geometry"] = LineString(
        #     [Point((G.nodes[node]["x"], G.nodes[node]["y"])) for node in path]
        # )

        # add the nodes and edge to their lists for processing at the end
        all_nodes_to_remove.extend(path[1:-1])
        all_edges_to_add.append(
            {"origin": path[0], "destination": path[-1], "attr_dict": path_attributes}
        )

    # for each edge to add in the list we assembled, create a new edge between
    # the origin and destination
    for edge in all_edges_to_add:
        G.add_edge(edge["origin"], edge["destination"], **edge["attr_dict"])

    # finally remove all the interstitial nodes between the new edges
    G.remove_nodes_from(set(all_nodes_to_remove))

    if remove_rings:
        # remove any connected components that form a self-contained ring
        # without any endpoints
        wccs = nx.weakly_connected_components(G)
        nodes_in_rings = set()
        for wcc in wccs:
            if not any(_is_endpoint(G, n) for n in wcc):
                nodes_in_rings.update(wcc)
        G.remove_nodes_from(nodes_in_rings)

    # mark graph as having been simplified
    G.graph["simplified"] = True

    msg = (
        f"Simplified graph: {initial_node_count} to {len(G)} nodes, "
        f"{initial_edge_count} to {len(G.edges)} edges"
    )
    print(msg)
    return G

现在,我可以运行用字典指定如何聚合组合边的属性。

simplified_G = simplify_graph(nx.MultiDiGraph(G),
                              aggregation = {'speed_kph': np.mean, 
                                             'slope': np.mean,
                                             'highway': mode,
                                             'geometry': linemerge}
nx.draw(simplified_G, with_labels=True)
plt.show()

这给了我预期的结果,但作为 MultiDiGraph

所以我需要将它转换回 Graph

simplified_G = nx.Graph(simplified_G)

然后我回到我的原始数据,按预期进行了简化。

您可以使用递归来组合边:

from collections import defaultdict
def merge(n = 1, p = [], c = []):
   if len(b:=[(y, v) for x, y, v in d if x == n and x and y not in c]) == 1 and sum(y == n for _, y, _ in d) < 2:
      yield from merge(n=b[0][0], p=p+[(n,b[0][-1])], c = c+[n])
   else:
      yield (p[0][0], n, [x for _, x in p])
      for y, v in b:
         yield from merge(n=y, p=[(n, v)], c=c+[n, y])

r, agg = [], {'speed_kph': np.mean, 'slope': np.mean, 'highway': mode, 'geometry': linemerge}
for a, b, c in merge():
   _d = defaultdict(list)
   for h in c:
      for j, k in h.items():
         _d[j].append(k)
   r.append((a, b, {j:agg[j](k) for j, k in _d.items()}))

现在,r 相当于您所需输出中的合并数据。

import networkx as nx
import matplotlib.pyplot as plt
new_G = nx.Graph()
new_G.add_nodes_from([*{j for *b, _ in r for j in b}])
new_G.add_edges_from(r)
nx.draw(new_G, with_labels=True)
plt.show()

输出: