Source code for cereeberus.draw.draw

import matplotlib.pyplot as plt
import matplotlib as mpl
import numpy as np
import networkx as nx


def _format_node_label(node):
    """Format node labels for cleaner plotting output.

    Internal mapper subdivision nodes are stored as tuples like
    ("mapper_subd", k). Render these compactly as "s{k}".
    """
    if isinstance(node, tuple) and len(node) == 2 and isinstance(node[1], int):
        if node[0] == "mapper_subd":
            return f"s{node[1]}"
        if node[0] == "reeb_auto":
            return f"r{node[1]}"
    return node


[docs] def dict_to_list(d): return list(d.values())
[docs] def line_loop_index(R): """Determine if edges between two nodes should be lines or loops Args: R (Reeb Graph): Reeb Graph Returns: 2-element tuple containing - **line_index (list)** : list of indices for edges to be drawn as lines - **loop_index (list)** : list of indices for edges to be drawn as loops """ edge_list = list(R.edges(keys=True)) n = len(R.edges) loop_index = [] line_index = [] for i in range(0, n): if edge_list[i][2] == 1: loop_index.append(edge_list.index(edge_list[i][0:2] + (0,))) loop_index.append(i) line_index.remove(edge_list.index(edge_list[i][0:2] + (0,))) else: line_index.append(i) return (line_index, loop_index)
[docs] def slope_intercept(pt0, pt1): """Compute slope and intercept to be used in the bezier curve function Args: pt0 (ordered pair): first point pt1 (ordered pair): second point Returns: 2-element tuple containing - **m (float)** : slope - **b (float)** : intercept """ m = (pt0[1] - pt1[1]) / (pt0[0] - pt1[0]) b = pt0[1] - m * pt0[0] return (m, b)
[docs] def bezier_curve(pt0, midpt, pt1): """Compute bezier curves for plotting two edges between a single set of nodes Args: pt0 (ordered pair): first point midpt (ordered pair): midpoint for bezier curve to pass through pt1 (ordered pair): second point Returns: points (np array): array of points to be used in plotting """ x1, y1, x2, y2 = (pt0[0], pt0[1], midpt[0], midpt[1]) a1, b1 = slope_intercept(pt0, midpt) a2, b2 = slope_intercept(midpt, pt1) points = [] for i in range(0, 100): if x1 == x2: continue else: a, b = slope_intercept((x1, y1), (x2, y2)) x = i * (x2 - x1) / 100 + x1 y = a * x + b points.append((x, y)) x1 += (midpt[0] - pt0[0]) / 100 y1 = a1 * x1 + b1 x2 += (pt1[0] - midpt[0]) / 100 y2 = a2 * x2 + b2 return points
def _draw_edges(R, ax, cpx=0.1, cpy=0.1): """Draw the edges of a Reeb-like graph onto ``ax``. Simple edges are drawn as straight lines; parallel (multi-)edges are drawn as a pair of bezier loops. This is shared between ``reeb_plot`` and ``pie_plot`` so both draw edges identically. Parameters: R (Reeb Graph): object of Reeb Graph class ax (matplotlib.axes.Axes): axis to draw on cpx, cpy (float): curvature parameters for the loop edges """ edge_list = list(R.edges) line_index, loop_index = line_loop_index(R) for i in line_index: node0 = edge_list[i][0] node1 = edge_list[i][1] x_pos = (R.pos_f[node0][0], R.pos_f[node1][0]) y_pos = (R.pos_f[node0][1], R.pos_f[node1][1]) ax.plot(x_pos, y_pos, color="grey", zorder=0) for i in loop_index: node0 = edge_list[i][0] node1 = edge_list[i][1] xmid = (R.pos_f[node0][0] + R.pos_f[node1][0]) / 2 xmid0 = xmid - cpx * xmid xmid1 = xmid + cpx * xmid ymid = (R.pos_f[node0][1] + R.pos_f[node1][1]) / 2 ymid0 = ymid - cpy * ymid ymid1 = ymid + cpy * ymid curve = bezier_curve(R.pos_f[node0], (xmid0, ymid0), R.pos_f[node1]) c = np.array(curve) ax.plot(c[:, 0], c[:, 1], color="grey", zorder=0) curve = bezier_curve(R.pos_f[node0], (xmid1, ymid1), R.pos_f[node1]) c = np.array(curve) ax.plot(c[:, 0], c[:, 1], color="grey", zorder=0)
[docs] def reeb_plot( R, with_labels=True, with_colorbar=False, cpx=0.1, cpy=0.1, ax=None, **kwargs ): """Main plotting function for the Reeb Graph Class Parameters: R (Reeb Graph): object of Reeb Graph class with_labels (bool): parameter to control whether or not to plot labels with_colorbar (bool): parameter to control whether or not to plot colorbar cp (float): parameter to control curvature of loops in the plotting function. For vertical Reeb graph, only mess with cpx. """ if ax is None: fig, ax = plt.subplots() viridis = mpl.colormaps["viridis"].resampled(16) n = len(R.nodes) # Some weird plotting to make the colored and labeled nodes work. # Taking the list of function values from the pos_f dicationary since the infinite node should already have a position set. color_map = [R.pos_f[v][1] for v in R.nodes] pathcollection = nx.draw_networkx_nodes( R, R.pos_f, node_color=color_map, ax=ax, **kwargs ) if with_labels: label_map = {node: _format_node_label(node) for node in R.nodes} nx.draw_networkx_labels( R, pos=R.pos_f, labels=label_map, font_color="black", ax=ax ) if with_colorbar: plt.colorbar(pathcollection) _draw_edges(R, ax, cpx=cpx, cpy=cpy) ax.tick_params(left=True, bottom=False, labelleft=True, labelbottom=False)
[docs] def node_label_counts(R, labels, categories=None): """Count, per vertex, how many of its assigned data points fall into each category of ``labels``. Requires ``R`` to have been built with :func:`cereeberus.computeMapper`, which records a ``node_points`` attribute on the graph mapping each vertex to the list of original point indices assigned to it. Parameters: R (MapperGraph): a mapper graph built via ``computeMapper``. labels (sequence): a category label for every point in the point cloud (or distance matrix) that was used to build ``R``. Indexed the same way as that point cloud (or, if ``pointcloud`` was ``None``, the same way as the rows/columns of the ``distance_matrix`` you built the graph with). categories (list, optional): the ordered list of category values to report counts for. Defaults to ``sorted(set(labels))``. Returns: dict: node -> {category: count} """ if not hasattr(R, "node_points"): raise ValueError( "This graph has no per-node point membership to compute label " "percentages from. Build it with cereeberus.computeMapper() to " "use node_label_counts/pie_plot." ) if categories is None: categories = sorted(set(labels)) counts = {} for v in R.nodes: pts = R.node_points.get(v, []) counts[v] = {c: 0 for c in categories} for p in pts: if p < 0 or p >= len(labels): raise ValueError("labels must contain one entry per original point index referenced by R.node_points") lbl = labels[p] if lbl in counts[v]: counts[v][lbl] += 1 return counts
def _pie_image(sizes, colors, edgecolor="black", linewidth=0.6, dpi=100, px=120): """Render a single pie chart to an RGBA image array, for use as a small 'picture' glyph placed at a node's position. Parameters: sizes (list): wedge sizes (counts or percentages) for this node. colors (list): a color per entry in ``sizes``, same order. Returns: np.ndarray: an (px, px, 4) RGBA image array. """ fig = plt.figure(figsize=(px / dpi, px / dpi), dpi=dpi) pie_ax = fig.add_axes([0, 0, 1, 1]) pie_ax.set_aspect("equal") if sum(sizes) == 0: # No points landed on this node; draw a neutral placeholder circle # rather than letting matplotlib.pie raise on an all-zero input. pie_ax.pie( [1], colors=["lightgrey"], wedgeprops={"edgecolor": edgecolor, "linewidth": linewidth}, ) else: pie_ax.pie( sizes, colors=colors, wedgeprops={"edgecolor": edgecolor, "linewidth": linewidth}, ) fig.canvas.draw() img = np.asarray(fig.canvas.buffer_rgba()).copy() plt.close(fig) return img
[docs] def pie_plot(R,labels,categories=None,colors=None,zoom=0.15,size_by_points=False,min_zoom=0.08,max_zoom=0.3,with_edges=True,with_legend=True,with_labels=True,cpx=0.1,cpy=0.1,ax=None): """Plot a mapper graph with each vertex drawn as a small pie-chart picture showing the percentage breakdown of ``labels`` among the original data points assigned to that vertex. Requires ``R`` to have been built with :func:`cereeberus.computeMapper` (which records per-node point membership); see ``node_label_counts``. Parameters: R (MapperGraph): a mapper graph built via ``computeMapper``. labels (sequence): a category label for every point in the point cloud (or distance matrix) used to build ``R``. categories (list, optional): ordered category values to plot. Defaults to ``sorted(set(labels))``. colors (dict, optional): mapping from category value to a matplotlib color. Defaults to the "tab10" colormap. zoom (float): size of each pie-chart glyph, as a fraction of its rendered base size. The right value depends on how tightly packed your graph's node positions are: dense graphs need a smaller zoom to avoid neighboring pies overlapping (which can create odd-looking visual artifacts where circles overlap). Start around 0.1-0.2 and adjust to taste. size_by_points (bool): if True, scale each pie's size by the number of points assigned to that node. This can be useful for emphasizing nodes that represent more data, but can also make the graph harder to read if the size differences are extreme. If True, ``min_zoom`` and ``max_zoom`` control the range of zoom values. min_zoom, max_zoom (float): when ``size_by_points`` is True, the minimum and maximum zoom values to use for the smallest and largest nodes, respectively. with_edges (bool): whether to draw the underlying graph edges. with_legend (bool): whether to add a legend mapping colors to category values. with_labels (bool): whether to draw each node's index/name as a text label on top of its pie glyph, matching reeb_plot. cpx, cpy (float): curvature parameters for multi-edges, as in ``reeb_plot``. ax (matplotlib.axes.Axes, optional) Returns: matplotlib.axes.Axes """ from matplotlib.offsetbox import OffsetImage, AnnotationBbox if ax is None: fig, ax = plt.subplots() if categories is None: categories = sorted(set(labels)) if colors is None: cmap = mpl.colormaps["tab10"] colors = {c: cmap(i % 10) for i, c in enumerate(categories)} counts = node_label_counts(R, labels, categories=categories) if size_by_points and len(R.nodes) > 0: totals = {v: len(R.node_points.get(v, [])) for v in R.nodes} t_min = min(totals.values()) t_max = max(totals.values()) def _node_zoom(v): if t_max == t_min: return zoom # sqrt scaling to make the pie area scale with the number of points frac = (np.sqrt(totals[v]) - np.sqrt(t_min)) / ( np.sqrt(t_max) - np.sqrt(t_min) ) return min_zoom + frac * (max_zoom - min_zoom) else: def _node_zoom(v): return zoom if with_edges: _draw_edges(R, ax, cpx=cpx, cpy=cpy) for v in R.nodes: sizes = [counts[v][c] for c in categories] img = _pie_image(sizes, colors=[colors[c] for c in categories]) imagebox = OffsetImage(img, zoom=_node_zoom(v)) ab = AnnotationBbox(imagebox, R.pos_f[v], frameon=False, pad=0) ax.add_artist(ab) if with_labels: label_map = {node: _format_node_label(node) for node in R.nodes} nx.draw_networkx_labels( R, pos=R.pos_f, labels=label_map, font_color="black", ax=ax ) if with_legend: handles = [ plt.Line2D( [0], [0], marker="o", linestyle="", markersize=8, markerfacecolor=colors[c], markeredgecolor="black", label=str(c), ) for c in categories ] ax.legend(handles=handles, title="Category", loc="best") ax.relim() ax.update_datalim(np.asarray(list(R.pos_f.values()))) ax.autoscale_view() ax.tick_params(left=True, bottom=False, labelleft=True, labelbottom=False) return ax