Coordinates¶
TLS data changes frame several times on its way to a result: from the
scanner's own frame to the project frame (the SOP), sometimes to a projected
map grid, and often back to a local origin so that coordinates in the
millions of metres keep their precision. sylva.geo.coords and three
PointCloud methods cover these steps:
| Task | Use |
|---|---|
| Shift, rotate, move to a local origin | cloud.translate, cloud.rotate, cloud.recentre |
| Apply a 4x4 matrix | cloud.transform (unchanged) |
| Change map projection or datum | coords.reproject |
| Register a whole survey from matrix files | coords.apply_transforms |
| The same from the shell | sylva reproject, sylva transform (Command line) |
A worked example¶
A plot in GDA2020 / MGA zone 55, moved to a local origin for processing, turned to face north-east, and returned to map coordinates and to latitude and longitude:
import numpy as np
import sylva
from sylva.geo import coords
rng = np.random.default_rng(0)
xyz = rng.uniform([512_000, 5_412_000, 100], [512_050, 5_412_050, 130], (10_000, 3))
plot = sylva.PointCloud(xyz, crs="EPSG:7855") # or sylva.read("plot.laz")
local, offset = plot.recentre() # offset = [512000, 5412000, 100]
turned = local.rotate(45, about=(25, 25, 0))
back = turned.rotate(-45, about=(25, 25, 0)).translate(*offset)
np.abs(back.xyz - plot.xyz).max() # 0.0 here; at most float rounding
lonlat = coords.reproject(plot, "EPSG:7844") # GDA2020 longitude, latitude
lonlat.crs # 'EPSG:7844'
coords.transformation("EPSG:7855", "EPSG:7844") # kind='conversion', exact=True
sylva.write(lonlat, "plot_lonlat.laz") # the CRS goes into the header
CRS on a point cloud¶
PointCloud.crs holds the coordinate reference system of xyz, or None
when it is unknown (the default, so clouds built without one behave as
before). It is a string: an EPSG code ("EPSG:7855", or "EPSG:7855+5711"
for MGA zone 55 with AHD heights), a PROJ string or WKT. An integer is
turned into "EPSG:n".
- Reading.
sylva.readsets it for LAS/LAZ files from the header's OGC WKT record, or else from its GeoTIFF keys (as"EPSG:n"). PLY and text files carry no CRS, so set it yourself:cloud.crs = "EPSG:7855". - Writing.
sylva.writestores it in LAS/LAZ files as an OGC WKT record (LAS 1.4), which PDAL, LAStools, CloudCompare and QGIS read. A CRS given as a PROJ string has no WKT form here and is left out with a warning. Clouds whose CRS is geographic are written with a coordinate scale of 1e-7 degrees (about 1 cm) instead of 1 mm. - Carrying. Subsets, copies,
with_attrs,transform,translate,rotate,recentre, thinning andconcatenatekeep it unchanged.concatenaterefuses clouds in different CRSs. - Meaning after a shift. Because the CRS is kept through
translateandrecentre, a recentred cloud still names its map CRS: it describes the frame the offset returns to. Add the offset back before writing a file other software will read as map coordinates, or before reprojecting.
coords.crs_info shows what Sylva makes of a definition:
info = coords.crs_info(cloud.crs)
info.label, info.name # ('EPSG:7855', 'GDA2020 / MGA zone 55')
info.proj4 # '+proj=utm +zone=55 +south +ellps=GRS80 +units=m +no_defs'
coords.same_crs(cloud.crs, 7855)
Shifting, rotating and recentring¶
moved = cloud.translate(10.0, -5.0) # dz defaults to 0
turned = cloud.rotate(30) # about z, through the origin
tilted = cloud.rotate(-2.5, axis="x", about=cloud.xyz.mean(axis=0))
leaned = cloud.rotate(10, axis=(1, 1, 0)) # any axis direction
local, offset = cloud.recentre() # and local.translate(*offset) undoes it
- Angles are degrees, right-handed: positive is counter-clockwise when
looking down the axis towards the origin, so
rotate(90)turns +x into +y. This is the convention ofregistration.rotation_z. - About a point.
aboutputs the axis through that point. Rotating projected coordinates about the origin swings the plot around (0, 0), thousands of kilometres away; rotate about the plot centre, or recentre first. - Exactness. A translation adds the offset once to each coordinate, so shifting back returns the original to within one rounding, and exactly for whole-metre shifts of millimetre data. Rotations by multiples of 90 degrees are exact. Repeated round trips do not drift beyond float rounding (about 1e-9 m for coordinates of 5e6 m).
- Recentring. The default origin is the minimum corner of the cloud rounded down to whole metres, so the offset is short and exact. Float32 holds about seven significant digits: 5412345.678 m is stored to the nearest 0.5 m in float32, while the local 45.678 m is kept to 4 micrometres. Recentre before writing formats or using tools that work in float32.
- Matrices.
coords.translation_matrixandcoords.rotation_matrixgive the same operations as 4x4 matrices, to compose with registration results:cloud.transform(coords.rotation_matrix(30, about=c) @ T).
Attributes are carried over unchanged, so direction-like attributes (nx,
ny, nz of ray clouds) are not rotated.
Reprojecting¶
mga = coords.reproject(cloud, "EPSG:7855") # source from cloud.crs
utm = coords.reproject(cloud, 32755, src_crs="EPSG:7855") # or given explicitly
xy = coords.reproject(np.array([[147.5, -42.9]]), "EPSG:7855", "EPSG:7844") # arrays too
Definitions of every horizontal CRS in the EPSG registry (codes up to 65535)
are built in, and the transformation runs in the Rust core with proj4rs, a
Rust port of PROJ.4, so neither PROJ nor GDAL is needed. WKT without an EPSG
code (ESRI .prj files, for example) is converted for transverse Mercator,
Lambert conformal conic, Albers, Mercator, Lambert azimuthal equal area,
polar and oblique stereographic and equirectangular projections.
Geographic coordinates are x = longitude and y = latitude, in degrees.
How exact the result is depends on the datums:
| Transformation | Example | Result |
|---|---|---|
| Same datum, other projection | MGA zone 55 to GDA2020 latitude and longitude; UTM to WGS 84 | exact to float rounding; z unchanged |
| Datums tied to WGS 84 by zero parameters | GDA94, NAD83, ETRS89 or NZGD2000 to WGS 84 | treated as the same datum, as PROJ's default does |
Helmert (towgs84) datum change |
OSGB36 or DHDN to WGS 84 | exact to the published parameters; z changes |
| Datums without parameters between them | GDA2020 to WGS 84 or to GDA94 | approximate: the datum shift is not applied |
| Grid-based datum change | NAD27 (NADCON), NTv2 grids | refused with a ValueError |
| Vertical datum change | AHD to ellipsoidal or AVWS heights | approximate: heights are not changed |
coords.transformation(src, dst) reports which case applies before
anything is transformed (kind, exact, changes_z and a note).
An approximate reprojection issues a coords.ApproximateTransformationWarning
naming what was not applied; turn it into an error with
warnings.simplefilter("error", coords.ApproximateTransformationWarning)
where a silent metre matters.
The reference checks in the test suite: the GDA technical manual's Flinders Peak example (MGA zone 55 to 1 mm), UTM and Albers coordinates computed by PROJ 9.8, and PROJ's OSGB36 and GDA94 to GDA2020 Helmert results to 1e-9 degrees.
Things to know:
- Heights. A Helmert datum change treats z as ellipsoidal height and
changes it with the datum. TLS heights are usually orthometric (AHD) or
local; if so, keep the original z (
out.xyz[:, 2] = cloud.z), since converting between height systems needs a geoid model, which is not available. -
GDA94 and GDA2020. The EPSG definition of GDA2020 carries no parameters, so GDA94 to GDA2020 is a null transformation (off by about 1.5 m). Give GDA2020 as a PROJ string with the published Helmert (EPSG:8048) to apply it; this reproduces PROJ's result to 0.1 mm:
gda2020 = ("+proj=utm +zone=55 +south +ellps=GRS80 +units=m +no_defs " "+towgs84=-0.06155,0.01087,0.04019,-0.0394924,-0.0327221,-0.0328979,0.009994") mga2020 = coords.reproject(cloud_mga94, gda2020, src_crs="EPSG:28355") mga2020.xyz[:, 2] = cloud_mga94.z # keep AHD heights mga2020.crs = "EPSG:7855" # label it with the code once shifted -
Round trips through a Helmert change apply the negated parameters on the way back (as PROJ.4 did), which returns to the original within 0.1 mm.
- Out-of-domain points (and NaN coordinates) become NaN rather than failing the whole cloud.
- Rasters are not reprojected. A regular grid does not stay regular
under a change of projection, so moving a
Raster's extent would be wrong; reproject the points and rebuild the DTM or CHM, or resample the GeoTIFF with GDAL'sgdalwarp.coords.reprojectraisesTypeErrorfor aRaster.
Applying registration results¶
coords.apply_transforms puts the scans of a survey into one frame with the
matrices a registration produced, and optionally merges them:
# transforms.json from sylva coreg / SurveyResult.save, matched by scan name
clouds = coords.apply_transforms("scans/", "plot_coreg/transforms.json")
# RiSCAN .DAT matrices (DAT/ScanPos001.DAT, ...), merged into one file
merged = coords.apply_transforms(
["ScanPos001_2cm.laz", "ScanPos002_2cm.laz"], "plot.RiSCAN/DAT",
out="plot_registered.laz", merge=True, # adds scan_id
)
# The SOPs of a RiSCAN project, or matrices in memory
clouds = coords.apply_transforms(paths, "plot.RiSCAN")
clouds = coords.apply_transforms({"north": north, "south": south}, {"north": T1, "south": T2})
clouds = coords.apply_transforms([a, b], [T1, T2], out="registered/") # by position
Transforms can be a {name: 4x4} mapping, a list (by position), a single
matrix for every scan, a transforms.json, a single matrix file, a
directory of .DAT files or a RiSCAN project directory (the SOPs; the POP
is not applied). The existing readers do the parsing:
coreg.load_transforms, io.read_matrix_file and read_riscan_project.
A scan is matched to a named transform by its mapping key or its path: the
file stem, or any folder above it, that equals the name or starts with it
followed by a separator. ScanPos001.rxp, ScanPos001_2cm.laz and
ScanPos001/scans/240101_1200.rxp all take ScanPos001; ScanPos0011
does not. A scan without a match, or with two, raises ValueError.