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.
# 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.
[
{ "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
"""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.
# 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.
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.
# Related
- Power Line Detection in LiDAR Point Clouds — the full detection workflow
- Fitting Catenary Curves to Conductor Points — what happens to the detected spans
- Measuring Vegetation Clearance to Power Lines — the clearance deliverable
- Detecting Planar Roofs with Covariance Features — the same threshold-sweep method for planarity
- Computing Height Above Ground with filters.hag_nn — the height band’s foundation