Take the convex hull of a set of points and remove its vertices. Take the hull of what remains and remove those vertices too. Repeat until nothing is left. The sequence of hulls is the set's convex layers, also called onion peeling, and the index of the layer a point falls on is its peeling depth. Points on the outside get depth 0; the innermost core gets the largest depth.

Layers answer questions a single hull cannot. Which points are the extremes, which are the next most extreme, and where is the 'middle' of a 2D cloud? Can you report every point on one side of a line without scanning the whole set? This page builds peeling from first principles. It shows why the collinear-point rule changes the answer by a factor of four, gives tested code, measures how many layers random data produces, and explains the optimal algorithm and the two classic applications.

Definition and the collinear-point rule

Formally, L0 is the set of points on the boundary of the convex hull of P, and Lk is the boundary of the hull of P minus the union of L0 to Lk-1. Every point lands on exactly one layer. The number of layers is between 1 (all points in convex position) and about n/3 for points in general position (nested triangles, each needing three points). Degenerate inputs under the strict rule below can reach about n/2: nine points on one line peel two at a time into five layers.

The definition hides a choice. A point that lies on a hull edge, between two vertices, is on the boundary but is not a vertex. An inclusive rule puts it on that layer. A strict rule keeps only true vertices and leaves the edge point for the next round. Both are used in the literature and in libraries, and they often disagree on inputs with integer coordinates.

The figure shows 13 points: four corners of a 6 by 6 square, an inner ring, and a small core. Under the inclusive rule (3, 0) joins the outer layer and (3, 3) joins the triangle around it, giving 3 layers. Under the strict rule both are deferred, giving 4 layers. On a 40 by 40 grid the gap is larger: 20 layers inclusive, 80 strict. Choose the rule deliberately, write it into the function's name or signature, and test it.

Strict convex layers of 13 points (collinear boundary points are peeled later)layer 0: 4 cornerslayer 1: (1,1) (3,0) (5,1) (5,5) (1,5)layer 2: (2,3) (3,2) (4,3)layer 3: (3,3)(3,0) sits on the outer edge;the strict rule pushes it inward.(3,3) sits on layer 2's edge,so it becomes its own layer.Inclusive rule: 3 layers.Strict rule: 4 layers.
Convex layers of the 13-point example under the strict rule. Point colours give peeling depth.

Peeling code that sorts once

The direct algorithm repeats a hull computation. Andrew's monotone chain is the right inner routine because its only expensive step is sorting, and peeling never changes the order of the surviving points. Sort once; each later round is a linear scan.

def cross(o, a, b):
    return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])

def hull_indices(pts, idx):
    """Monotone chain over pts[i] for i in idx (sorted by x, then y).
    Returns strict hull vertices counter-clockwise; collinear points dropped."""
    if len(idx) <= 2:
        return list(idx)
    lower, upper = [], []
    for i in idx:
        while len(lower) >= 2 and cross(pts[lower[-2]], pts[lower[-1]], pts[i]) <= 0:
            lower.pop()
        lower.append(i)
    for i in reversed(idx):
        while len(upper) >= 2 and cross(pts[upper[-2]], pts[upper[-1]], pts[i]) <= 0:
            upper.pop()
        upper.append(i)
    return lower[:-1] + upper[:-1]

def on_segment(p, a, b):
    return cross(a, b, p) == 0 and min(a[0], b[0]) <= p[0] <= max(a[0], b[0]) \
        and min(a[1], b[1]) <= p[1] <= max(a[1], b[1])

def convex_layers(pts, boundary=True):
    """boundary=True: points on a hull edge join that layer (inclusive rule)."""
    pts = [tuple(p) for p in pts]
    alive = sorted(range(len(pts)), key=lambda i: pts[i])      # sort once
    layers = []
    while alive:
        h = hull_indices(pts, alive)
        layer = set(h)
        if boundary and len(h) >= 2:
            ring = h + [h[0]]
            for i in alive:
                if i not in layer and any(on_segment(pts[i], pts[a], pts[b])
                                          for a, b in zip(ring, ring[1:])):
                    layer.add(i)
        layers.append(h + sorted(layer - set(h)))
        alive = [i for i in alive if i not in layer]           # still sorted
    return layers

The <= 0 in the pop condition is what makes the hull strict: a point collinear with the last two is popped. The inclusive pass is written for clarity and costs O(n h) per layer; a production version walks the hull edges and the sorted points together. With integer coordinates every predicate is exact. With floats, cross can return a tiny wrong-signed value near collinear triples, and points then drift between layers from run to run. Use integer or rational coordinates, or an exact orientation predicate, whenever layer numbers feed a decision.

Testing is cheap and catches real bugs. On 300 random point sets drawn from a 6 by 6 integer grid (so collinear points are common), the test checks that every point appears on exactly one layer and that each layer contains the strict hull of the points left at that round, under both rules.

Cost and how many layers to expect

Each round costs O(n) after the initial sort, so the direct algorithm takes O(n log n + nL) for L layers. That is O(n^2) in the worst case, the nested triangles. Peeling with gift wrapping (Jarvis march) is also O(n^2) in total, because finding each hull vertex scans the remaining points once. A dynamic hull structure that supports deletions brings the bound under quadratic. Chazelle showed in 1985 that all the convex layers of n points in the plane can be computed in O(n log n) time and O(n) space, which is optimal because even one hull needs sorting-level work. The algorithm is intricate, and few libraries ship it.

On realistic data the number of layers is far below n/3. For n points uniform in a disk, Dalal proved the expected number of layers grows as Θ(n^(2/3)). Running the code above with the strict rule on uniform points in the unit square (seed 7) shows the same growth empirically:

nLayersLayers / n^(2/3)Points on outer layer
1,000490.49018
4,0001250.49619
16,0003090.48724
64,0007780.48629

Uniform points in a disk gave 47 layers at n = 1,000 and 295 at n = 16,000, a ratio of about 0.47. Integer grids behave differently. Har-Peled and Lidický showed that a k by k grid has Θ(k^(4/3)) strict layers, and the measured counts of 12, 31 and 80 for k = 10, 20 and 40 track that curve. The practical reading: on random data the simple O(nL) method is roughly O(n^(5/3)). It is fine for tens of thousands of points and not for millions, which is when Chazelle's algorithm or an approximation earns its complexity.

What layers are good for

Halfplane range reporting. Given a line, report every point on one side of it. Walk the layers from the outside in. On each layer, find the hull vertices on the query side with a binary search on the convex polygon, and report them. If a layer has no vertex on the query side, the whole layer polygon lies on the other side, so everything inside it does too, and you can stop. Every layer you visit except the last reports at least one point, so the work is about k binary searches for k reported points. Linking adjacent layers so that each search reuses the previous one gives O(log n + k) query time in linear space (Chazelle, Guibas and Lee, 1985), a bound a k-d tree does not give for halfplane queries.

The query loop, with a linear scan standing in for the binary search:

def halfplane_report(layers, pts, a, b, c):
    """Indices of points with a*x + b*y > c; layers are ordered outermost first."""
    out = []
    for layer in layers:
        hits = [i for i in layer if a * pts[i][0] + b * pts[i][1] > c]
        if not hits:          # the layer polygon lies on the far side,
            break             # so every deeper layer does too
        out += hits
    return out

The early exit is what makes the structure pay off: a query that touches only the outer rim of the data costs a few layers, not a full scan. The stop rule is safe under either collinear rule, because every deeper point lies inside or on the polygon of the layer that came up empty.

Robust location and trimming. Peeling generalises trimming to two dimensions: drop the outer few layers and average the rest, or take the centroid of the deepest layer as a 2D median. The convex hull peeling median is affine equivariant, so it does not depend on the units of either axis. Peeling is not a guarantee against outliers, though. The worked example shows why.

Worked example: peeling away outliers

Draw 200 points from a standard 2D normal distribution and add 5 outliers clustered near (9, 9). The true centre is (0, 0). The strict-rule code produces 19 layers. Three outliers land on layer 0, but the other two land on layer 1, sheltered behind their neighbours. One peel is not enough to remove a cluster.

Points keptCountMean xMean y
All2050.2260.228
Depth 1 and deeper1960.1230.154
Depth 2 and deeper1850.0790.101

The plain mean is pulled to (0.23, 0.23). Each peel moves it back toward the centre, and two peels remove all five outliers. The deepest layer has four points with centroid near (0.21, 0.30), while the coordinate-wise median is (0.12, 0.18): the deepest layer is a small sample and is noisy. Three lessons generalise. Peel by depth, not by a fixed count of points. Expect clustered contamination to survive a shallow peel. Average the deepest few layers rather than trusting the innermost one alone.

Peeling depth is also not Tukey (halfspace) depth. Tukey depth counts how many points a halfplane through the point must contain at minimum. The two orderings often agree near the centre and differ in the tails, and the robustness guarantees proved for the Tukey median do not carry over to the peeling median. If you need a statistic with a published breakdown point, use the one it was proved for.

Failure modes

What goes wrong in practice:

  • An undeclared collinear rule. Two services peel the same data and disagree on layer counts by a factor of four on gridded data. Make the rule a parameter and log it with every result.
  • Duplicate points. Monotone chain with <= 0 drops duplicates from the hull, and they are peeled in later rounds one at a time. Deduplicate first and carry multiplicities, or decide that duplicates share a layer.
  • Re-sorting every round. The O(n log n) sort inside the loop turns an O(nL) algorithm into O(nL log n). Filter the sorted list instead.
  • Floating-point predicates. Near-collinear triples flip sign under rounding, so layer membership becomes non-deterministic. Snap to integers or use exact predicates.
  • Expecting balanced layers. Uniform data puts most points on the inner layers; the outer hull has only about 20 to 30 points even at 64,000. Any budget that assumes 'peel 10% per layer' is wrong.
  • Using peeling in high dimensions. In d dimensions, a large share of random points are hull vertices, so the first layers swallow most of the data. Peeling is a low-dimensional tool.

What to do next

  1. Implement the code above with both collinear rules, and reproduce the 3-layer and 4-layer answers on the 13-point example.
  2. Run the randomised test on a small integer grid, where degeneracies are common.
  3. Measure layers against n^(2/3) on your own data; a large deviation tells you the data is clustered or gridded.
  4. If you need halfplane queries, build the layer structure once and answer queries by walking layers from the outside, stopping at the first empty one.
  5. For robust centres, peel by depth, average the deepest few layers, and compare with the coordinate-wise median before trusting the result.
  6. Review Graham scan and Quickhull to see which hull routine suits your input distribution.
Key takeaway: Convex layers repeat the hull until nothing is left, and each point's layer is its peeling depth. Decide whether edge points join a layer, because that choice can change the count fourfold. Sort once and peel with monotone chain for O(nL) time; random data has about n^(2/3) layers, and Chazelle's algorithm reaches O(n log n). Use layers for halfplane reporting and robust centres, but peel by depth and expect clustered outliers to survive one round.