【问题标题】:Networkx: how to combine edges together with condition?Networkx:如何将边缘与条件结合在一起?
【发布时间】:2021-12-04 01:02:06
【问题描述】:

我正在使用我已经丰富的 Open Street Data,并且正在通过 NetworkX 进行建模。 但是,存在大量节点来设计边的曲线,因为它们只有两条边。

因此,我需要通过聚合连接这些节点的边来简化 grah,同时保留聚合边的属性。

这是一个可重现的例子:

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 完全符合您的要求osmnx.readthedocs.io/en/stable
  • 对。因此我的回答如下。唯一的问题是 1. 它本质上只适用于 OSMnx 图和 2. 它暗示该图是一个 MultiDiGraph。

标签: python graph aggregate networkx


【解决方案1】:

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

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()

输出:

【讨论】:

  • 等等,d 是什么?
  • @Laurent 您最初用于构建图表的数据:[(1, 2, {'highway': 'primary', 'speed_kph': 90, 'slope': 1.2, 'geometry': LineString([Point(3.013832,42.785837), Point(3.010505, 42.787605)])}),....}
  • 好的,但是如何自动提取呢? (我在玩具示例中手动创建了图表,但数据通常来自数据库)。最重要的是,在您的new_G.add_nodes_from([1, 3, 4, 5, 6, 11]) 线上,这也是手动的。这意味着我需要手动指定要在新图中创建的节点?
  • @Laurent 请看我最近的编辑,我更新了使用new_G.add_nodes_from的行
  • 您可以尝试在您的代码中添加一些 cmets 吗?它有点密集,充满了单字母名称的变量,很难猜出它们的用途。
【解决方案2】:

我注意到这是在 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)

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

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2020-08-03
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2023-02-25
    • 1970-01-01
    相关资源
    最近更新 更多