Saturday, May 10, 2025

skeleton

skeleton

Shape Analysis: Skeleton of Point Cloud with Python

Source code: Github

Understanding the shape of data is a foundational task in geometry processing and topological data analysis. In this post, we demonstrate how to generate a tree structure, simulate a point cloud around it, extract its boundary via an alpha shape, and then compute its medial axis (or skeleton) using Python.

We’ll walk through four stages:

  1. Create a custom tree graph.

  2. Generate a noisy point cloud around the tree.

  3. Compute the alpha shape (concave hull).

  4. Extract the skeleton from the shape.


1. Generate a Custom Tree

We begin by creating a graph that has tree structure—specifically, one with a few branching hubs and several leaves.

import networkx as nx def  create_custom_tree():
    G = nx.Graph() for i in  range(4):
        G.add_node(i)
    leaf_id = 4  for hub in  range(4): for _ in  range(1  if hub == 3  else  2):
            G.add_edge(hub, leaf_id)
            leaf_id += 1 G.add_edge(0, 1)
    G.add_edge(1, 2)
    G.add_edge(2, 3) return G

G = create_custom_tree()

We then layout the graph using a spring layout:

pos = nx.spring_layout(G, seed=42)

2. Add a Noisy Point Cloud Around the Graph

To simulate real-world noisy observations, we generate random points around nodes and edges of the tree.

import numpy as np def  add_random_points_around_graph(G, pos, n_points=300, noise_scale=0.05, seed=None):
    rng = np.random.default_rng(seed)
    cloud_points = [] for p in pos.values():
        local_noise = p + rng.normal(scale=noise_scale, size=(n_points // 2 // len(pos), 2))
        cloud_points.append(local_noise) for u, v in G.edges():
        p1, p2 = pos[u], pos[v]
        t_vals = rng.uniform(0, 1, size=n_points // 2 // G.number_of_edges())
        samples = np.outer(1 - t_vals, p1) + np.outer(t_vals, p2)
        samples += rng.normal(scale=noise_scale, size=samples.shape)
        cloud_points.append(samples) return np.vstack(cloud_points)

cloud = add_random_points_around_graph(G, pos, seed=1990)

3. Compute the Alpha Shape

The alpha shape is a generalization of the convex hull, allowing concavities by filtering triangles based on their circumradius.

from shapely.geometry import Polygon, MultiLineString from shapely.ops import unary_union, polygonize from scipy.spatial import Delaunay import math def  alpha_shape(points, alpha): if  len(points) < 4: return Polygon(points).convex_hull

    tri = Delaunay(points)
    edges = set()
    edge_points = [] for ia, ib, ic in tri.simplices:
        pa, pb, pc = points[ia], points[ib], points[ic]
        a = np.linalg.norm(pb - pa)
        b = np.linalg.norm(pc - pb)
        c = np.linalg.norm(pa - pc)
        s = (a + b + c) / 2.0 area = math.sqrt(s * (s - a) * (s - b) * (s - c)) if area == 0: continue circum_r = a * b * c / (4.0 * area) if circum_r < 1.0 / alpha: for i, j in [(ia, ib), (ib, ic), (ic, ia)]: if (i, j) not  in edges and (j, i) not  in edges:
                    edges.add((i, j))
                    edge_points.append((tuple(points[i]), tuple(points[j])))

    m = MultiLineString(edge_points)
    triangles = list(polygonize(m)) return unary_union(triangles)

shape = alpha_shape(cloud, alpha=10)

Note: Since shape may be a MultiPolygon, we’ll adapt our plotting accordingly.


4. Compute the Medial Axis (Skeleton)

We approximate the medial axis using a Voronoi diagram of the alpha shape’s boundary.

from shapely.geometry import LineString from scipy.spatial import Voronoi def  compute_medial_axis_from_polygon(shape, sample_density=1): if shape.geom_type == "MultiPolygon":
        polygons = shape.geoms else:
        polygons = [shape]

    all_medial_edges = [] for poly in polygons:
        boundary = poly.exterior
        length = boundary.length
        num_samples = max(500, int(length * sample_density))
        distances = np.linspace(0, length, num_samples)
        sampled_points = [boundary.interpolate(d) for d in distances]
        coords = np.array([[p.x, p.y] for p in sampled_points])

        vor = Voronoi(coords) for start, end in vor.ridge_vertices: if start == -1  or end == -1: continue p1 = vor.vertices[start]
            p2 = vor.vertices[end]
            segment = LineString([p1, p2]) if poly.contains(segment):
                all_medial_edges.append(segment) return unary_union(all_medial_edges)

medial_axis = compute_medial_axis_from_polygon(shape)

5. Visualization

Finally, we plot everything together using matplotlib.

import matplotlib.pyplot as plt

plt.figure(figsize=(8, 8)) # Tree structure nx.draw_networkx_edges(G, pos, edge_color='blue', width=4, alpha=0.7)
nx.draw_networkx_nodes(G, pos, node_size=50, node_color='blue', alpha=0.7) # Point cloud plt.scatter(cloud[:, 0], cloud[:, 1], color='gray', s=10, alpha=0.5) # Alpha shape  if shape.geom_type == "Polygon":
    x, y = shape.exterior.xy
    plt.plot(x, y, color='black', linewidth=0.5) elif shape.geom_type == "MultiPolygon": for poly in shape.geoms:
        x, y = poly.exterior.xy
        plt.plot(x, y, color='black', linewidth=0.5) # Medial axis  from shapely.geometry import MultiLineString if  isinstance(medial_axis, LineString):
    plt.plot(*medial_axis.xy, color='red', linewidth=4) elif  isinstance(medial_axis, MultiLineString): for line in medial_axis.geoms:
        plt.plot(*line.xy, color='red', linewidth=4)

plt.title('Tree (blue), Random Points (gray), Alpha Shape (black), and Skeleton (red)')
plt.axis('equal')
plt.grid(True)
plt.show()

🔍 Summary

With just a few libraries—networkx, numpy, scipy, shapely, and matplotlib—we built a full shape-processing pipeline:

  • Simulate graph-based point clouds.

  • Reconstruct their outer shape.

  • Extract a compact skeleton for topological understanding.

You can adapt this for biological networks, roadmaps, or any spatial data where shape and connectivity matter.

Popular Posts