Context
The USGS 3DEP workflow that produces a Hydro-Flattened DEM goes: raw LAS/LAZ point cloud → classification
(ground, water, buildings) → DEM gridding → hydroflattening (#36) → breakline burning (#38) → publish. Today,
vendors stitch this together with PDAL pipelines + custom Python. Phase 4 wraps PDAL and exposes the full
pipeline as a single declarative config.
Problem / Current Behaviour
Individual steps are available (grid_lidar_points, read_las/write_las, classify_ground,
detect_trees, clip, merge, filter_classes), but composing them into the canonical 3DEP pipeline still
requires custom orchestration. There is no declarative entry point that hands a lidar_to_dem(...) config to
the user and chains hydroflatten + breakline burn + stream burn.
Affected locations
| File |
Symbol |
Notes |
src/digitalrivers/lidar.py |
grid_lidar_points(...) |
shipped — point-cloud → DEM grid. |
src/digitalrivers/lidar.py |
read_las, write_las, classify_ground, detect_trees, clip, merge, filter_classes, LasPoints |
shipped. |
digitalrivers.point_cloud.lidar_to_dem(...) |
(top-level dispatcher) |
pending. |
digitalrivers.point_cloud.LidarPipeline |
(declarative pipeline) |
pending. |
digitalrivers lidar-to-dem CLI |
|
pending. |
Motivation Example
# Shipped today.
from digitalrivers.lidar import read_las, classify_ground, filter_classes, grid_lidar_points
pc = read_las("tile.laz")
classed = classify_ground(pc)
ground = filter_classes(classed, [2])
dem = grid_lidar_points(ground, cell_size_m=1.0, method="mean")
# Pending.
# from digitalrivers.point_cloud import lidar_to_dem, LidarPipeline
#
# dem = lidar_to_dem(
# las_paths=["tile1.laz", "tile2.laz"],
# cell_size=1.0,
# classifications=[2],
# method="mean",
# )
#
# pipeline = LidarPipeline(
# las_paths=[...],
# steps=[
# {"type": "filters.range", "limits": "Classification[2:2]"},
# {"type": "writers.gdal", "resolution": 1.0, "output_type": "mean"},
# {"type": "digital-rivers.hydroflatten", "waterbodies_from": "Classification[9:9]"},
# {"type": "digital-rivers.burn_streams", "streams": "streams.gpkg", "method": "fill_burn"},
# {"type": "digital-rivers.raise_walls", "lines": "levees.gpkg", "height_attr": "crest_m"},
# ],
# )
# dem = pipeline.execute()
Proposed Solution
from digitalrivers.point_cloud import lidar_to_dem, LidarPipeline
def lidar_to_dem(
las_paths: list[str],
*,
cell_size: float,
classifications: list[int] | None = None,
method: "Literal['mean','idw','tin']" = "mean",
hydroflatten_from_class: int | None = 9,
streams: "gpd.GeoDataFrame | None" = None,
walls: "gpd.GeoDataFrame | None" = None,
) -> "DEM": ...
class LidarPipeline:
def __init__(self, las_paths: list[str], steps: list[dict]): ...
def execute(self) -> "DEM": ...
def to_json(self) -> str: ...
@classmethod
def from_json(cls, j: str) -> "LidarPipeline": ...
Built-in pipelines:
CLI: digitalrivers lidar-to-dem --config pipeline.json --out conditioned.tif.
References
- PDAL: https://pdal.io/
- USGS 3DEP Lidar Base Specification 2025.
- ASPRS LAS 1.4 specification.
- PDAL Python bindings —
pdal.Pipeline, pdal.Reader.las(...), filters.smrf / filters.range /
filters.dbscan, writers.gdal.
Out of Scope
- LAS 1.5 / E57 readers — handled via PDAL when those formats land in PDAL itself.
- GPU-accelerated point-cloud processing — Phase 5+.
Effort Estimate
Size: L (6 days)
Rationale: PDAL's Python API is mature; bulk is wiring the declarative pipeline + writing
digital-rivers.* extension stages + the CLI.
Definition of Done
Status: Partial on branch feat/phase-4. Shipped: point-cloud gridding + classification + IO. Pending:
declarative pipeline runner + CLI. See tests/test_lidar.py, tests/test_lidar_e2e.py.
Context
The USGS 3DEP workflow that produces a Hydro-Flattened DEM goes: raw LAS/LAZ point cloud → classification
(ground, water, buildings) → DEM gridding → hydroflattening (#36) → breakline burning (#38) → publish. Today,
vendors stitch this together with
PDALpipelines + custom Python. Phase 4 wraps PDAL and exposes the fullpipeline as a single declarative config.
Problem / Current Behaviour
Individual steps are available (
grid_lidar_points,read_las/write_las,classify_ground,detect_trees,clip,merge,filter_classes), but composing them into the canonical 3DEP pipeline stillrequires custom orchestration. There is no declarative entry point that hands a
lidar_to_dem(...)config tothe user and chains hydroflatten + breakline burn + stream burn.
Affected locations
src/digitalrivers/lidar.pygrid_lidar_points(...)src/digitalrivers/lidar.pyread_las,write_las,classify_ground,detect_trees,clip,merge,filter_classes,LasPointsdigitalrivers.point_cloud.lidar_to_dem(...)digitalrivers.point_cloud.LidarPipelinedigitalrivers lidar-to-demCLIMotivation Example
Proposed Solution
Built-in pipelines:
writers.gdal(mean / IDW / TIN).the point cloud via concave hull) → P24 (feat(dem): breaklines / levees / walls / kerbs (P24) #38) breakline burn.
CLI:
digitalrivers lidar-to-dem --config pipeline.json --out conditioned.tif.References
pdal.Pipeline,pdal.Reader.las(...),filters.smrf/filters.range/filters.dbscan,writers.gdal.Out of Scope
Effort Estimate
Size:
L(6 days)Rationale: PDAL's Python API is mature; bulk is wiring the declarative pipeline + writing
digital-rivers.*extension stages + the CLI.Definition of Done
grid_lidar_points(...)ships.read_las,write_las) ships.classify_ground,detect_trees,filter_classes) ship.clip,merge) ship.lidar_to_dem(...)top-level dispatcher.LidarPipelinedeclarative pipeline runner + JSON round-trip.lidar_to_dem(...)produces a DEM within RMSE 0.5 m of thepublished 3DEP DEM.
published reference tile within IoU >= 0.85.
digitalrivers lidar-to-dem --config pipeline.json --out conditioned.tif.Status: Partial on branch
feat/phase-4. Shipped: point-cloud gridding + classification + IO. Pending:declarative pipeline runner + CLI. See
tests/test_lidar.py,tests/test_lidar_e2e.py.