Reprojecting State Plane Feet to Metres
TL;DR: Give filters.reprojection compound CRSs on both sides — for example in_srs: "EPSG:2263+6360" (NAD83 / New York Long Island ftUS + NAVD88 height ftUS) and out_srs: "EPSG:6347+5703" (NAD83(2011) / UTM 18N + NAVD88 height in metres). With a horizontal-only CRS, X and Y are converted but Z stays in feet, which produces a cloud whose heights are 3.28 times too large.
# Context and Motivation
This guide is part of Spatial Reprojection. A large share of US county and state LiDAR is delivered in State Plane coordinates with US survey feet, because that is what local surveyors and engineering departments use. Anything downstream in metres — national mosaics, most scientific software, many cloud services — needs it converted, and the conversion has one well-known trap. filters.reprojection converts the axes the CRS describes. A horizontal-only State Plane CRS describes X and Y, so Z passes through untouched in feet, and nothing warns you.
A second, quieter issue is the foot itself. The US survey foot (1200/3937 m, about 0.3048006 m) and the international foot (exactly 0.3048 m) differ by two parts per million — about 3 cm across a State Plane zone’s typical easting. PROJ handles this correctly when the CRS is right; it goes wrong when someone “fixes” units with a hand-written scale factor.
# Prerequisites and Assumptions
- PDAL 2.3+ built against PROJ 7 or newer (
pdal --version,projinfo --version). - The source CRS identified precisely: State Plane zone, datum realization (NAD83, NAD83(HARN), NAD83(2011)) and units. Check the LAS header with
pdal info --metadatabefore anything else. - The vertical datum and its units — usually NAVD88 in US survey feet for US deliveries.
- A target: here NAD83(2011) / UTM zone 18N (EPSG:6347) with NAVD88 heights in metres (EPSG:5703).
# Step-by-Step Implementation
# Step 1 — Read what the file claims
pdal info --metadata tile.laz shows the WKT in metadata.srs. Look for LENGTHUNIT["US survey foot"...] on the horizontal axes and whether a VERTCRS is present at all.
# Step 2 — Build the compound source CRS
If the file carries a horizontal CRS only, write the compound form yourself: horizontal EPSG code, +, vertical EPSG code. EPSG:6360 is NAVD88 height in US survey feet; EPSG:8228 is NAVD88 height in international feet.
# Step 3 — Build the compound target CRS
EPSG:6347+5703 — the UTM zone plus NAVD88 height in metres. Keeping NAVD88 changes only units; switching to ellipsoidal heights is a separate decision covered in handling vertical datum transforms.
# Step 4 — Reproject and reset scales
Feet coordinates are usually stored with a scale of 0.01 ft. After conversion, write with a metric scale of 0.001 m or 0.01 m and offset: "auto" so precision is preserved and integers do not overflow.
# Step 5 — Verify with a known point
Pick a survey control point or a point you can compute independently with projinfo/cs2cs, and compare.
# Complete Working Example
{
"pipeline": [
{
"type": "readers.las",
"filename": "nyc_ftUS/tile_985000_195000.laz",
"override_srs": "EPSG:2263+6360"
},
{
"type": "filters.reprojection",
"in_srs": "EPSG:2263+6360",
"out_srs": "EPSG:6347+5703"
},
{
"type": "writers.las",
"filename": "nyc_m/tile_985000_195000.laz",
"minor_version": 4,
"dataformat_id": 6,
"scale_x": 0.001, "scale_y": 0.001, "scale_z": 0.001,
"offset_x": "auto", "offset_y": "auto", "offset_z": "auto",
"a_srs": "EPSG:6347+5703",
"forward": "all"
}
]
}And a Python check of one point against PROJ directly:
"""Confirm a converted point against pyproj for the same compound CRSs."""
import json
import numpy as np
import pdal
from pyproj import Transformer
p = pdal.Pipeline(json.dumps({"pipeline": ["nyc_ftUS/tile_985000_195000.laz",
{"type": "filters.head", "count": 1}]}))
p.execute()
src = p.arrays[0][0]
q = pdal.Pipeline(json.dumps({"pipeline": ["nyc_m/tile_985000_195000.laz",
{"type": "filters.head", "count": 1}]}))
q.execute()
dst = q.arrays[0][0]
t = Transformer.from_crs("EPSG:2263+6360", "EPSG:6347+5703", always_xy=True)
x, y, z = t.transform(src["X"], src["Y"], src["Z"])
print(f"PDAL {dst['X']:.3f} {dst['Y']:.3f} {dst['Z']:.3f}")
print(f"pyproj {x:.3f} {y:.3f} {z:.3f}")
assert np.allclose([dst["X"], dst["Y"], dst["Z"]], [x, y, z], atol=0.002)
assert abs(dst["Z"] - src["Z"] * 1200 / 3937) < 0.01, "Z was not converted from feet"The last assertion is the one that catches the horizontal-only mistake: it checks that the output Z equals the input Z times the survey-foot factor, since the vertical datum did not change.
# Key Parameter Table
| Setting | Value | Why |
|---|---|---|
in_srs |
EPSG:2263+6360 |
Horizontal ftUS plus NAVD88 height ftUS; replace with your zone |
out_srs |
EPSG:6347+5703 |
UTM 18N metres plus NAVD88 height metres |
override_srs on reader |
same as in_srs |
Only when the file’s own CRS is missing or wrong |
scale_* |
0.001 | Millimetre storage precision in metres |
offset_* |
auto |
Keeps scaled integers within range after the unit change |
a_srs on writer |
same as out_srs |
Writes the compound CRS into the LAS WKT VLR |
# Verification
- Z ratio. For a sample of points, output Z divided by input Z should be 0.3048006 (1200/3937) when the vertical datum is unchanged.
- Header CRS.
pdal info --metadata out.lazmust show both a projected CRS in metres and a vertical CRS in metres. - Extent sanity. UTM northings in New York are around 4.5 million metres; eastings in zone 18 between roughly 160,000 and 840,000. Values in the millions for eastings mean the conversion did not happen.
# Gotchas and Edge Cases
Header says feet, data is metres (or the reverse). Some deliveries carry a feet CRS in the header while coordinates are metric. Check coordinate magnitudes against the zone before trusting the header, and use override_srs when they disagree.
International feet in a few states. A handful of state specifications use international feet. The EPSG code for the zone differs (for example EPSG:2229 versus its international-foot counterpart), so read the WKT units rather than assuming.
NAD83 realizations. Converting NAD83(HARN) State Plane to NAD83(2011) UTM involves a small datum shift as well as a projection change. PROJ applies it when grids are available; see inspecting PROJ transformations before reprojecting.
Hand-written scale factors. Replacing reprojection with filters.assign multiplying by 0.3048 uses the international foot, introducing the error in the chart above, and leaves the CRS metadata wrong. Let PROJ do the conversion.
# Frequently Asked Questions
Why did my Z values stay in feet after reprojection?
Because the input and output CRSs were horizontal only. filters.reprojection converts only the axes the CRS defines; supply compound CRSs with a vertical component on both sides and Z is converted too.
What is the difference between the US survey foot and the international foot?
The US survey foot is 1200/3937 metres, about 0.3048006; the international foot is exactly 0.3048 metres. The two-parts-per-million difference becomes tens of centimetres at typical State Plane coordinate values. The US survey foot was officially retired at the end of 2022, but existing data still uses it.
Which EPSG code is NAVD88 height in metres?
EPSG:5703. NAVD88 height in US survey feet is EPSG:6360, and in international feet EPSG:8228.
Do I need to change the LAS scale after converting to metres?
Usually yes. A scale of 0.01 chosen for feet becomes 0.01 metres after conversion, which is about three times coarser than the original storage precision, and the old offsets no longer suit the new coordinate range. Setting a metric scale such as 0.001 and automatic offsets avoids both issues.
# Related
- Spatial Reprojection — the reprojection stage in general
- Handling Vertical Datum Transforms in PDAL — changing the vertical datum, not just its units
- Inspecting PROJ Transformations Before Reprojecting — seeing which operation PROJ will use
- Setting a Vertical CRS on a Point Cloud — when the file has none
- Fixing CRS Mismatches in Point Clouds — headers that disagree with the data