Skip to content

feat: add transform_crs() to reproject point clouds#331

Open
agrigoriev wants to merge 6 commits into
develfrom
transform-crs
Open

feat: add transform_crs() to reproject point clouds#331
agrigoriev wants to merge 6 commits into
develfrom
transform-crs

Conversation

@agrigoriev

@agrigoriev agrigoriev commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a new pipeline stage transform_crs() that reprojects the point cloud from its current CRS to a target CRS (EPSG code or WKT string). This complements set_crs(): where set_crs() only assigns a CRS without touching the coordinates, transform_crs() actually reprojects the data with PROJ/GDAL.

# reproject to WGS 84 (lon/lat) and write
reader_las() + transform_crs(4326) + write_las()

# reproject then rasterize — output raster is in the target CRS
reader_las() + transform_crs(32619) + rasterize(10, "max")

Behaviour

  • Reprojects X/Y with a batched OGRCoordinateTransformation; Z is preserved as-is, matching gdaltransform/sf/terra for 2D targets (avoids surprising sub-metre datum-driven height shifts).
  • Adapts scale factors and offsets to the target CRS to avoid precision loss / 32-bit integer overflow when units change (metres ⇄ degrees).
  • Propagates the target CRS to downstream stages and writers, and reprojects the coverage/chunk extents so rasterization and tiled runs are sized in the target CRS.
  • Thread-safe under the concurrent-files strategy: each cloned stage builds its own coordinate transformation; a point outside the transform domain is dropped.

Implementation

New stage LASRtransformcrs (src/LASRstages/transformcrs.{cpp,h}), wired through the C++ core (parser factory + first-pass CRS propagation), the api layer, the Rcpp module, the R wrapper (transform_crs()), and the Python bindings. Adds CRS::is_geographic().

Tests

tests/testthat/test-transform_crs.R (7 tests) — verified output against gdaltransform ground truth to storage precision, Z preservation, EPSG + WKT inputs, distinction from set_crs(), invalid-target errors, identity transform, and the reproject→rasterize extent path. Existing CRS/rasterize/transform_with/pipeline suites still pass.

@agrigoriev agrigoriev force-pushed the transform-crs branch 2 times, most recently from 608ae60 to 3fb4f25 Compare June 1, 2026 06:23
agrigoriev and others added 6 commits June 2, 2026 11:49
Add a new pipeline stage transform_crs() that reprojects the point cloud
from its current CRS to a target CRS (EPSG code or WKT). Unlike set_crs(),
which only assigns a CRS without moving the coordinates, transform_crs()
actually reprojects the data with PROJ/GDAL.

- Reproject X/Y with a batched OGRCoordinateTransformation; Z is preserved
  (matches gdaltransform/sf/terra behaviour for 2D targets).
- Adapt the scale factors and offsets to the target CRS to avoid precision
  loss or 32-bit integer overflow when units change (metres <-> degrees).
- Propagate the target CRS to downstream stages and writers, and reproject
  the coverage/chunk extents so rasterization and tiled/parallel runs are
  sized in the target CRS.
- Thread-safe under concurrent-files: each cloned stage builds its own
  coordinate transformation.
- Wired through the C++ core, parser, api layer, R and Python bindings.
- Add tests (tests/testthat/test-transform_crs.R) and documentation.
…nates

- Handle non-INT32 X/Y storage (PCD float/double) without corrupting
  coordinates; write the coordinate directly for float/double, keep the
  scaled-integer path for LAS, and reject unsupported types.
- set_chunk: scale the tile buffer to the target CRS units so a downstream
  halo isn't a source-unit buffer against a target-unit resolution; warn and
  continue instead of aborting the run when a chunk extent can't be reprojected.
- Robust reference-offset selection (centre -> corners -> 0) so the stage no
  longer fails when the bbox centroid falls outside the transform domain.
- Drop and warn on points outside the transform domain or beyond the int32
  range instead of silently wrapping to a garbage location.
- Reset the bounding box when all points are dropped (no inverted extent).
- write_las: reproject the catalog bbox to the output CRS before sizing the
  merged COPC octree (z preserved); no-op when the CRS is unchanged.
- Delete copy-assignment (owns a raw OGRCoordinateTransformation).
- Docs: Z is preserved, not recomputed; vertical transforms out of scope.
- Extract a shared reproject_bbox() helper in CRS.

Adds tests for PCD float reprojection, buffer-unit scaling and the
reproject -> COPC octree path.
…rrectly to LAS

PCD (and other non-LAS) sources store X/Y as raw float/double. The in-memory
schema is kept at identity scale/offset so every in-memory accessor (get_x,
AttributeAccessor, the kd-tree) reads the reprojected coordinate directly. But
write_las() quantizes to LAS int32 using the schema scale/offset, which for a
float/double source is the default 1.0 -- collapsing sub-unit (e.g. lon/lat)
coordinates to 0 on write.

transform_crs now records the target-appropriate scale/offset on the header for
every storage type (the schema is still updated only for INT32, to keep
in-memory float/double decoding consistent). The reference offset is computed
for all types. The LAS writer reads the scale/offset from the header for
float/double coordinate axes, and from the schema for INT32 (preserving the
existing LAS and transform_with paths).

Adds a PCD -> reproject -> write_las round-trip test; the previous PCD test
stopped at an in-memory callback and missed the writer path.
For a projected->projected transform, the previous code reused the source
schema's X/Y scale factor. That is correct for an INT32 (LAS) source, where the
schema scale is the real quantization step, but wrong for a float/double source
(e.g. PCD) whose schema scale is a placeholder (typically 1.0). Since the LAS
writer now reads the header scale/offset for float/double coordinate axes, that
1.0 reached the writer and quantized projected coordinates to whole units (~0.3 m
error).

Reuse the schema scale only when both X and Y are INT32 and the source is
projected; otherwise choose a target-appropriate scale (1e-7 for a geographic
target, 1 cm for a projected target). Adds a projected->projected PCD -> write_las
round-trip test.
The merged-COPC octree-sizing path (write_las(experimental_writer=TRUE))
lives in the pre-devel COPC writer rework, which is not present on devel.
This test was added alongside the writelas integration in the original
PR; that integration was dropped when rebasing onto devel, so drop its
test too. The full test (and implementation) remain on the pre-devel
integration branch.
@agrigoriev agrigoriev changed the base branch from pre-devel to devel June 2, 2026 08:53
@agrigoriev agrigoriev requested a review from Jean-Romain June 2, 2026 10:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant