Power Line Detection in LiDAR Point Clouds
Utility corridor surveys are one of the few LiDAR products where the thing being measured is thinner than the point spacing. A conductor is a few centimetres across; at 30 pts/m² the scanner still catches it, but as a sparse dotted line hanging several metres above a canopy that returns thousands of points for every one on the wire. Power line detection is the task of pulling that dotted line out of the clutter, classifying it to ASPRS class 14 (wire conductor), and organizing it into spans so that engineers can measure sag and clearance. It belongs in the classification and feature extraction section because it is the purest example of a problem that per-point height cannot solve and neighbourhood geometry can.
# Prerequisites
- PDAL 2.5+ with
filters.covariancefeatures,filters.hag_nnandfilters.dbscan, plus the Python bindings. - Python 3.10+ with NumPy, SciPy (for
cKDTreeandcurve_fit) and pandas. - Dense corridor data. Conductor detection is dependable from about 20 pts/m² upward; helicopter or drone corridor surveys at 50 to 200 pts/m² are ideal. At 2 to 8 pts/m² (typical wide-area QL2) many spans have too few wire returns to detect reliably.
- Ground classified to class 2 and noise to classes 7 and 18. High-noise returns are the most common false conductors.
- A projected CRS in metres, e.g. EPSG:6340 (NAD83(2011) UTM 11N). Span lengths and sags are meaningless in degrees.
- Optional but valuable: a tower or pole layer from the utility’s GIS. Known structure positions turn span grouping from a clustering problem into a lookup.
# Core Workflow Architecture
- Normalize and band. Compute height above ground and keep points between roughly 5 m and 80 m, which removes ground, low vegetation, vehicles and most buildings.
- Describe. Compute
LinearityandVerticalityfor the banded points with a neighbourhood large enough to include several wire returns. - Candidate test. Keep points that are strongly linear and nearly horizontal — conductors sag, but over a one-metre neighbourhood they are close to level.
- Group into spans. Segment candidates with DBSCAN using a generous
eps, so that a wire with gaps between returns still forms one group. - Validate each group. Reject groups that are short, not elongated, or not fit well by a catenary; what survives is a span.
- Classify and hand off. Write class 14 to span points, then pass spans to catenary fitting and clearance measurement.
The funnel is steep, and that is the point: each phase discards most of what reaches it, so the expensive neighbourhood work only ever touches a small fraction of the tile.
# Distribution Lines Versus Transmission Lines
The workflow above is tuned for transmission corridors: tall lattice towers, long spans of 250 to 400 metres, conductors 15 to 40 metres above ground, and wide cleared rights-of-way. Distribution networks break most of those assumptions and deserve their own settings rather than a compromise.
Distribution conductors hang 6 to 12 metres up on wooden or concrete poles, spans are 30 to 80 metres, and the lines run along streets where they share airspace with street trees, building eaves, telephone and fibre cables, and street lights. Lower band_low to 4 m, reduce min_span_length to 15 m, and expect communication cables to pass every geometric test — they are linear, horizontal and elevated too. Distinguishing them needs either the utility’s asset data, the height ordering on shared poles (power is conventionally mounted above communications), or intensity, which differs between bare aluminium conductor and jacketed cable on some sensors.
Transmission lines have the opposite problem: towers are tall enough that band_high must clear the top attachment, and the shield wires above the phase conductors are thin enough that they may return only a handful of points per span. If the specification requires class 13 for shield wires, separate them per span by height — they are the topmost linear group — after the conductors have been found.
# Full Implementation
"""Detect power-line conductor points and group them into spans."""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from pathlib import Path
import numpy as np
import pandas as pd
import pdal
log = logging.getLogger("powerline")
@dataclass(frozen=True)
class WireRules:
band_low: float = 5.0 # m above ground
band_high: float = 80.0
knn: int = 20 # neighbourhood for linearity
min_linearity: float = 0.85
max_verticality: float = 0.2 # near-horizontal lines only
eps: float = 2.5 # DBSCAN link distance along a wire, m
min_samples: int = 8
min_span_length: float = 25.0 # m
min_elongation: float = 12.0 # length over width
def candidate_points(src: Path, rules: WireRules) -> np.ndarray:
band = (f"HeightAboveGround >= {rules.band_low} && HeightAboveGround <= {rules.band_high}"
" && Classification != 7 && Classification != 18 && Classification != 2")
stages = [
{"type": "readers.las", "filename": str(src)},
{"type": "filters.hag_nn", "count": 1},
{"type": "filters.range",
"limits": f"HeightAboveGround[{rules.band_low}:{rules.band_high}]"},
{"type": "filters.covariancefeatures", "knn": rules.knn, "threads": 4,
"feature_set": "Dimensionality", "where": band},
{"type": "filters.expression",
"expression": f"Linearity >= {rules.min_linearity} && "
f"Verticality <= {rules.max_verticality}"},
{"type": "filters.dbscan", "eps": rules.eps, "min_points": rules.min_samples,
"dimensions": "X,Y,Z"},
]
p = pdal.Pipeline(json.dumps({"pipeline": stages}))
n = p.execute()
log.info("%s: %d linear candidates", src.name, n)
return p.arrays[0]
def span_table(pts: np.ndarray, rules: WireRules) -> pd.DataFrame:
df = pd.DataFrame({k: pts[k] for k in ("X", "Y", "Z", "ClusterID")})
df = df[df.ClusterID >= 0] # DBSCAN labels noise as -1
rows = []
for cid, g in df.groupby("ClusterID"):
xy = g[["X", "Y"]].to_numpy() - g[["X", "Y"]].mean().to_numpy()
# Principal axis of the group in plan view.
_, s, vt = np.linalg.svd(xy, full_matrices=False)
along = xy @ vt[0]
across = xy @ vt[1]
length = float(along.max() - along.min())
width = float(np.percentile(across, 95) - np.percentile(across, 5)) or 0.01
rows.append({"ClusterID": int(cid), "n": len(g), "length_m": length,
"width_m": width, "elongation": length / width,
"z_min": float(g.Z.min()), "z_max": float(g.Z.max())})
spans = pd.DataFrame(rows).set_index("ClusterID")
spans["is_span"] = ((spans.length_m >= rules.min_span_length)
& (spans.elongation >= rules.min_elongation))
return spans
def classify(src: Path, dst: Path, rules: WireRules = WireRules()) -> pd.DataFrame:
pts = candidate_points(src, rules)
spans = span_table(pts, rules)
keep = spans.index[spans.is_span].to_numpy()
wire = pts[np.isin(pts["ClusterID"], keep)]
log.info("%d groups, %d spans, %d conductor points", len(spans), len(keep), len(wire))
# Mark conductors in the full tile by matching on GpsTime within each span.
full = pdal.Pipeline(json.dumps({"pipeline": [str(src)]}))
full.execute()
arr = full.arrays[0]
hit = np.isin(arr["GpsTime"], wire["GpsTime"]) & np.isin(arr["X"], wire["X"])
arr["Classification"][hit] = 14
pdal.Writer.las(filename=str(dst), minor_version=4, dataformat_id=6,
forward="all").pipeline(arr).execute()
return spans[spans.is_span]
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
print(classify(Path("corridor_0082.laz"), Path("corridor_0082_wire.laz")))# Code Breakdown
count: 1 for height above ground. Corridors often cross valleys; a single nearest ground neighbour is precise enough for a 5 m band and cheaper than averaging.
filters.range then a where clause. The range filter removes everything outside the band, which shrinks the neighbour search dramatically. The where clause on the covariance stage additionally skips ground and noise that happen to sit in the band on steep slopes.
knn: 20. A conductor contributes perhaps one return per metre per wire. Twenty neighbours reach far enough along the wire that the neighbourhood is a line, not a blob. On a three-phase circuit with close phase spacing, the neighbourhood may span two wires; linearity still stays high because the wires are parallel.
Verticality <= 0.2. Poles, tower legs and tree trunks are linear too. Verticality near 1 means the principal direction is vertical, so the cap removes them while keeping sagging wires, whose local slope rarely exceeds 20 degrees except right at the attachment point.
DBSCAN rather than Euclidean clustering. Wire returns are gappy. DBSCAN with eps: 2.5 bridges the gaps along a wire, and its noise label (-1) discards isolated linear points — a branch tip, an edge of a roof — that Euclidean clustering would keep as tiny segments. DBSCAN segmentation explains the parameters in depth.
Elongation from an SVD. The principal axis of the group in plan view gives its length; the spread perpendicular to that axis gives its width. A span of three parallel conductors is still very elongated; a linear-looking branch cluster is not.
Matching by GpsTime and X. The candidate array has lost most points, so the write-back step re-reads the tile. Pairing time with a coordinate is enough for single-pass corridor data. The robust alternative is the where-clause pattern from building extraction, which keeps every point in the array throughout.
# Parameter Reference Table
| Parameter | Type | Default here | Valid range | Effect |
|---|---|---|---|---|
band_low |
float, m | 5.0 | 3–10 | Lower catches distribution lines; too low admits hedges and fences |
band_high |
float, m | 80 | 40–120 | Must clear the highest attachment on transmission towers |
knn |
int | 20 | 10–40 | Larger is steadier along a wire but blurs close parallel conductors |
min_linearity |
float | 0.85 | 0.7–0.95 | Main separator from canopy; lower on sparse data |
max_verticality |
float | 0.2 | 0.1–0.4 | Excludes poles and trunks; raise near attachment points |
eps (DBSCAN) |
float, m | 2.5 | 1–5 | Gap along a wire that still links; about 2–3 × wire point spacing |
min_points (DBSCAN) |
int | 8 | 4–20 | Core-point density; higher discards short linear clutter |
min_span_length |
float, m | 25 | 10–100 | Shortest span accepted; distribution spans can be 30 m |
min_elongation |
float | 12 | 5–40 | Length over width; rejects roof edges and branch clusters |
# Validation and Integrity Checks
Span count against the asset register. If the utility provides structure locations, every consecutive pair of structures on a circuit should have at least one detected span between them. Missing spans are the first thing a utility engineer will notice.
Continuity along the corridor. Plot span end points: gaps longer than one span usually mean the wire points fell below min_linearity in a stretch of sparse coverage. Lower the threshold locally rather than globally.
No conductor points near the ground. Class 14 points with height above ground under 3 m are almost always misclassified fence tops or vehicles:
check = pdal.Pipeline(json.dumps({"pipeline": [
"corridor_0082_wire.laz",
{"type": "filters.hag_nn", "count": 1},
{"type": "filters.expression",
"expression": "Classification == 14 && HeightAboveGround < 3.0"},
]}))
low = check.execute()
assert low == 0, f"{low} conductor points within 3 m of ground"Catenary residuals. A real span fits a catenary with a residual RMS of a few centimetres. Residuals above 0.3 m indicate two spans merged, or canopy points included; the catenary page shows how to compute them.
# Performance Tuning
Corridor tiles are unusually dense, which makes the neighbourhood stage expensive, but the height band usually removes 85 to 95 percent of points first. Two further savings matter.
- Clip to the corridor. If the right-of-way polygon is known, crop to it with a 30 m buffer before anything else. On a 1 km² tile crossed by a 60 m corridor, that alone removes over 90 percent of the work.
- Voxel-thin the canopy. Dense canopy dominates the candidate stage yet contributes nothing.
filters.voxelcenternearestneighborwith a 0.25 m cell, restricted with awhereclause to low-linearity points in a first pass, cuts canopy volume without touching the sparse wire returns. - Thread the covariance stage. As elsewhere,
threads: 4roughly halves its time on an 8-core worker.
# Common Errors and Troubleshooting
Tree edges classified as wire. Tall, narrow crowns such as poplars produce linear neighbourhoods along their outline. The span tests — minimum length and elongation — remove most; if some remain, add a requirement that a span’s lowest point is at least 2 m above the canopy height model beneath it.
Spans broken at every tower. The attachment region is vertical and cluttered, so linearity drops there. That is expected; merge spans whose end points are within a few metres and whose directions agree, or snap span ends to known structure positions.
Three conductors come out as one group. Phase spacing on distribution lines can be under a metre, well inside eps. For clearance work that rarely matters — the lowest conductor governs — but for sag per phase, re-segment each span with a smaller eps in the plane perpendicular to the span direction.
filters.dbscan returns everything as -1. min_points is too high for the wire density, or eps is smaller than the typical gap between returns. Measure the median nearest-neighbour distance among candidates and set eps to about three times it.
Empty candidate set on older PDAL. feature_set: "Dimensionality" and the Verticality output appeared in the 2.x series; older releases name or compute features differently. Check with pdal --options filters.covariancefeatures and pin the version in your container.
# Frequently Asked Questions
Which ASPRS class should conductors use?
Class 14, wire conductor. Shield or guard wires above the conductors use class 13, towers use class 15, and insulators and other wire-structure connectors use class 16. Many specifications only require class 14; check before spending time separating the others.
What point density is needed to detect power lines?
Detection is reliable from roughly 20 points per square metre, and corridor-specific surveys are usually flown far denser. At 2 to 8 points per square metre transmission lines are often visible but distribution lines frequently are not, so report the density alongside the results.
Can I detect wires without height above ground?
You can, using absolute elevation above a local minimum, but it fails on sloping terrain where the canopy on the uphill side is higher than the wire on the downhill side. Normalizing first is cheap and removes that failure mode entirely.
Why use DBSCAN instead of filters.cluster for spans?
Wire returns are sparse and gappy, and isolated linear points appear throughout canopy edges. DBSCAN bridges regular gaps along a wire while labelling isolated points as noise; Euclidean clustering keeps every isolated point as its own tiny segment.
# Related
- Detecting Power Line Conductors with Linearity — tuning the candidate test on your own data
- Fitting Catenary Curves to Conductor Points — sag, low point and residuals per span
- Measuring Vegetation Clearance to Power Lines — the deliverable utilities actually ask for
- Point Cloud Segmentation — Euclidean and DBSCAN grouping compared
- Building Extraction from LiDAR — the planar counterpart to this linear problem