Detecting Power Line Conductors with Linearity

TL;DR: Restrict to points 5–80 m above ground, compute filters.covariancefeatures with a knn large enough to reach several wire returns (15–25), keep points with Linearity >= 0.85 and Verticality <= 0.2, and validate the choice by measuring recall on a few hand-labelled spans rather than by eye.

# Context and Motivation

This guide is part of Power Line Detection in LiDAR Point Clouds. The candidate test is the step that decides everything downstream: a conductor point it misses is never recovered by span grouping, and a canopy point it admits has to be removed later by shape tests. Linearity — the share of a neighbourhood’s spread that lies along one direction — is the natural signal, because a wire is the only common object that is one-dimensional at the scale of a metre.

The complication is that wires are sparse. Along a conductor the returns may be half a metre to two metres apart, while the canopy beneath is dense. A neighbourhood defined by the k nearest points reaches along the wire only if k is large enough, and if the wire passes close to a crown, some of those k neighbours will be leaves.

How far k reaches along a wire A row of evenly spaced wire returns about a metre apart. Around one of them, a small ellipse shows k equal to 4 reaching only the two nearest returns on each side, too few for a stable direction. A larger ellipse shows k equal to 20 reaching ten returns along the wire, giving a clean linear neighbourhood with linearity near 0.95. k = 4: two returns each side, direction unstable k = 20: reaches along the wire, Linearity ≈ 0.95 return spacing along a conductor is often wider than point spacing on the ground

# Prerequisites and Assumptions

  • Corridor data at 20 pts/m² or denser, ground classified, noise removed or classified 7/18.
  • PDAL 2.4+ and Python bindings; NumPy and GeoPandas.
  • A few labelled spans: digitize line strings along three to five conductors in a GIS and buffer them by 0.3 m. Those polygons are your ground truth.

# Step-by-Step Implementation

# Step 1 — Band by height above ground

Everything below about 5 m and above the highest structure is irrelevant and expensive.

json
[
  { "type": "filters.hag_nn", "count": 1 },
  { "type": "filters.range", "limits": "HeightAboveGround[5:80]" }
]

# Step 2 — Label your sample spans

Burn the buffered conductor polygons into a Truth dimension with filters.overlay so each banded point knows whether it is a known wire point.

# Step 3 — Compute features over a range of k

Run filters.covariancefeatures with feature_set: "Dimensionality" at several knn values on the same banded points.

# Step 4 — Measure recall and precision for each setting

For each combination of knn, linearity threshold and verticality cap, count labelled wire points that pass (recall) and the share of passing points inside the labelled corridor that are wire (precision within the sample area).

# Step 5 — Pick the setting on the plateau

Choose the smallest knn whose recall is within a point or two of the best, because larger neighbourhoods cost time and blur parallel conductors.

# Complete Working Example

python
"""Sweep knn and thresholds for conductor detection against labelled spans."""
from __future__ import annotations

import itertools
import json
from pathlib import Path

import numpy as np
import pdal


def banded_with_truth(tile: Path, truth_gpkg: Path) -> np.ndarray:
    p = pdal.Pipeline(json.dumps({"pipeline": [
        {"type": "readers.las", "filename": str(tile)},
        {"type": "filters.range", "limits": "Classification![7:7],Classification![18:18]"},
        {"type": "filters.hag_nn", "count": 1},
        {"type": "filters.range", "limits": "HeightAboveGround[5:80]"},
        {"type": "filters.ferry", "dimensions": "=>Truth"},
        {"type": "filters.overlay", "dimension": "Truth", "datasource": str(truth_gpkg),
         "layer": "wires", "column": "is_wire"},
    ]}))
    p.execute()
    return p.arrays[0]


def features(points: np.ndarray, knn: int) -> np.ndarray:
    p = pdal.Pipeline(json.dumps({"pipeline": [
        {"type": "filters.covariancefeatures", "knn": knn, "threads": 4,
         "feature_set": "Dimensionality"}]}), arrays=[points])
    p.execute()
    return p.arrays[0]


def score(a: np.ndarray, lin: float, vert: float) -> tuple[float, int]:
    passed = (a["Linearity"] >= lin) & (a["Verticality"] <= vert)
    truth = a["Truth"] == 1
    recall = float((passed & truth).sum() / max(truth.sum(), 1))
    false_pos = int((passed & ~truth).sum())
    return recall, false_pos


if __name__ == "__main__":
    base = banded_with_truth(Path("corridor_0082.laz"), Path("wires_truth.gpkg"))
    print(f"{int((base['Truth'] == 1).sum())} labelled wire points in the band")
    for knn in (8, 12, 16, 20, 28):
        a = features(base, knn)
        for lin, vert in itertools.product((0.75, 0.85, 0.9), (0.2, 0.3)):
            r, fp = score(a, lin, vert)
            print(f"knn={knn:>2} lin>={lin:.2f} vert<={vert:.1f}  "
                  f"recall={r:.3f}  non-wire passing={fp}")

The false-positive count is taken over the whole band, not only near labelled wires, so it tells you how much clutter the span tests downstream will have to reject.

Recall plateaus; clutter keeps falling Two lines against knn from 8 to 28. Recall of labelled wire points rises from 0.71 at knn 8 to 0.93 at knn 16 and stays near 0.94 at 20 and 28. Non-wire points passing the test fall from about 9,000 at knn 8 to 2,100 at knn 20, then rise slightly at 28 as neighbourhoods start to include both wire and canopy. The chosen setting at knn 20 is highlighted. 8 12 16 20 28 knn recall non-wire passing chosen

# Key Parameter Table

Parameter Type Default Guidance
height band m 5–80 Lower bound 4 m for distribution lines; upper above the tallest tower
knn int 20 About 4–6 returns along the wire each side; larger on sparse wires
Linearity threshold float 0.85 0.75 for sparse data or low-contrast wires
Verticality cap float 0.2 Raise to 0.3 near attachment points if spans break there
threads int 4 Speeds the covariance stage roughly linearly up to 4–8
radius alternative float, m unset 1.5–2.5 m keeps scale fixed where wire density varies

# Verification

  • Recall above 0.9 on labelled spans is realistic on dense corridor data. Lower recall concentrated near towers is expected and is handled by span merging.
  • Visual inspection by linearity. Colour the banded points by Linearity; conductors should appear as continuous bright lines, canopy as a dark mottle, crown edges as occasional bright specks.
  • Stable across tiles. Run the chosen setting on two tiles not used for tuning and check that the passing count per kilometre of corridor is similar.

# Gotchas and Edge Cases

Crown edges look linear. The outer edge of a narrow crown, sampled by a single scan line, can be highly linear over a metre. It fails the span tests later — short, not elongated — so do not raise the linearity threshold to kill it here at the cost of wire recall.

Wires close to canopy lose linearity. Where a conductor passes within a metre of a crown, some neighbours are leaves, and linearity drops. These are exactly the spans where clearance matters most, so check recall in the labelled spans that pass close to vegetation specifically.

Where the canopy touches the neighbourhood A conductor drawn as a row of points crosses above a tree crown. Points far from the crown are drawn dark, meaning high linearity around 0.95. Directly above the crown, where neighbourhoods include leaves, points are drawn lighter with linearity around 0.6, below the threshold. A bracket marks this weakened stretch. Linearity ≈ 0.6 — leaves in the neighbourhood ≈ 0.95 ≈ 0.95

Parallel conductors merge. Two phases 0.8 m apart share neighbourhoods; linearity stays high because they are parallel, so detection is fine, but per-phase analysis needs re-segmentation later.

Density stripes from overlap. Where two flightlines overlap, wire returns double and a fixed knn covers half the length. The radius option avoids this; so does running each flightline separately using PointSourceId in a where clause.

# Frequently Asked Questions

Why is linearity better than height for finding wires?

Height separates wires from the ground but not from tall trees, which occupy the same band. Linearity measures shape, and conductors are the only common object that is one-dimensional at the scale of a metre, so it isolates them even above dense canopy.

What linearity threshold should I use?

Around 0.85 on dense corridor data, lowered toward 0.75 on sparse data. Confirm it by measuring recall on a few labelled spans; the right value is the one that keeps recall above about 0.9 without flooding the next step with canopy points.

Do I need to remove poles before span grouping?

The verticality cap removes most pole and trunk points at the candidate stage. Any that remain form short vertical groups that the span length and elongation tests reject.

Can I use this on mobile or terrestrial scans?

Yes, with larger knn because density is far higher, and with care near the scanner where wires are seen from below at steep angles. The height band may also need adjusting for lines along streets.