You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
normalizeForVisualization() - the function behind pyproj's always_xy=True
and projinfo --normalize-axis-order is supposed to guarantee that both
ends of a coordinate operation use (lon, lat) / (E, N) axis order. For
operations between a Geographic3D CRS and a VerticalCRS (or a
CompoundCRS whose first component is a VerticalCRS), it leaves a residual +proj=axisswap +order=2,1 in the normalized pipeline, so the output
horizontal coordinates come back lat/lon instead of lon/lat, silently,
with no error.
Root cause
A VerticalCRS has no horizontal axes, so CRS::mustAxisOrderBeSwitchedForVisualization()
(src/iso19111/crs.cpp) always returns false for it. Isolateded this is correct of cource,
since a 1D CRS has no axis order of its own to reverse.
But the actual instantiated operation (e.g. Geographic3D -> Vertical via a
geoid grid) still carries a horizontal pass-through pair internally, built
from the geographic end's own axis convention on both sides of the
pipeline (src/iso19111/operation/singleoperation.cpp, the isGeographic3DToGravityRelatedHeight export branch). That pass-through is
symmetric and correct by itself.
CoordinateOperation::normalizeForVisualization()
(src/iso19111/operation/singleoperation.cpp) asks each end independently
whether it needs a swap: swapSource=true (geographic), swapTarget=false
(vertical, no horizontal axes to swap). It therefore prepends a reversal at
only one end, breaking the operation's internal symmetry. PROJ's
PROJ-string optimizer then cancels the leading pair (the prepended reversal
against the operation's own leading swap), but there is nothing to cancel the
operation's own trailing swap, which gets into the final pipeline.
Steps to reproduce
pyproj (forward and reverse)
frompyproj.transformerimportTransformerGroup# EPSG:4937 = ETRS89 (geographic 3D, lat/lon/h)# EPSG:5776 = NN54 height (Norway vertical CRS)tg=TransformerGroup("EPSG:4937", "EPSG:5776", always_xy=True, allow_ballpark=False)
t=tg.transformers[0]
print(t.to_proj4())
# +proj=pipeline +step +proj=unitconvert +xy_in=deg +xy_out=rad# +step +inv +proj=vgridshift +grids=no_kv_href2008a.tif +multiplier=1# +step +proj=unitconvert +xy_in=rad +xy_out=deg# +step +proj=axisswap +order=2,1 should NOT be hereprint(t.transform(5.5, 58.5, 100.0))
# (58.5, 5.5, 57.087...) lon/lat swapped on output (always_xy=True should give lon/lat)# Reverse direction leaves the residual swap on the *input* side instead:tg_rev=TransformerGroup("EPSG:5776", "EPSG:4937", always_xy=True, allow_ballpark=False)
print(tg_rev.transformers[0].to_proj4())
# +proj=pipeline +step +proj=axisswap +order=2,1 should NOT be here# +step +proj=unitconvert +xy_in=deg +xy_out=rad# +step +proj=vgridshift +grids=no_kv_href2008a.tif +multiplier=1# +step +proj=unitconvert +xy_in=rad +xy_out=deg
PROJ core (C API) - the exact function pyproj's always_xy calls
projinfo --normalize-axis-order does not reproduce this: it normalizes
the CRS objects first and searches for an operation between the already-
normalized CRSs, which for a 1D vertical target yields a symmetric pair of
swaps that cancel out. The bug only appears through the operation-level
entry point, proj_normalize_for_visualization() (PJ_OBJ.cpp), i.e. the
same call pyproj's always_xy=True makes internally:
When always_xy=True / proj_normalize_for_visualization() is used, a
Geographic3D <-> VerticalCRS pipeline shouldn't have any leftover +proj=axisswap +order=2,1 in either direction. Horizontal coordinates
should stay in (lon, lat) order all the way through, exactly as always_xy
promises. (Without it, the CRS's own declared axis order applies as usual;
this issue is only about what happens once that flag is set.)
I've got a fix for this (tested both directions, ran the full PROJ test suite
against it, no regressions). Happy to open a PR if that's useful.
Important
The same class of bug likely affects EngineeringCRS pairs with
non-trivial axis directions (e.g. EPSG:5800, axis order north/west), but
fixing that requires an axis direction flip that Conversion::createAxisOrderReversal cannot currently express, which is a
separate, larger change. This issue and fix are scoped to the
Geographic3D <-> VerticalCRS case only.
Description of the issue
normalizeForVisualization()- the function behind pyproj'salways_xy=Trueand
projinfo --normalize-axis-orderis supposed to guarantee that bothends of a coordinate operation use (lon, lat) / (E, N) axis order. For
operations between a Geographic3D CRS and a VerticalCRS (or a
CompoundCRS whose first component is a VerticalCRS), it leaves a residual
+proj=axisswap +order=2,1in the normalized pipeline, so the outputhorizontal coordinates come back lat/lon instead of lon/lat, silently,
with no error.
Root cause
A
VerticalCRShas no horizontal axes, soCRS::mustAxisOrderBeSwitchedForVisualization()(
src/iso19111/crs.cpp) always returnsfalsefor it. Isolateded this is correct of cource,since a 1D CRS has no axis order of its own to reverse.
But the actual instantiated operation (e.g. Geographic3D -> Vertical via a
geoid grid) still carries a horizontal pass-through pair internally, built
from the geographic end's own axis convention on both sides of the
pipeline (
src/iso19111/operation/singleoperation.cpp, theisGeographic3DToGravityRelatedHeightexport branch). That pass-through issymmetric and correct by itself.
CoordinateOperation::normalizeForVisualization()(
src/iso19111/operation/singleoperation.cpp) asks each end independentlywhether it needs a swap:
swapSource=true(geographic),swapTarget=false(vertical, no horizontal axes to swap). It therefore prepends a reversal at
only one end, breaking the operation's internal symmetry. PROJ's
PROJ-string optimizer then cancels the leading pair (the prepended reversal
against the operation's own leading swap), but there is nothing to cancel the
operation's own trailing swap, which gets into the final pipeline.
Steps to reproduce
pyproj (forward and reverse)
PROJ core (C API) - the exact function pyproj's
always_xycallsprojinfo --normalize-axis-orderdoes not reproduce this: it normalizesthe CRS objects first and searches for an operation between the already-
normalized CRSs, which for a 1D vertical target yields a symmetric pair of
swaps that cancel out. The bug only appears through the operation-level
entry point,
proj_normalize_for_visualization()(PJ_OBJ.cpp), i.e. thesame call pyproj's
always_xy=Truemakes internally:Expected behaviour
When
always_xy=True/proj_normalize_for_visualization()is used, aGeographic3D <-> VerticalCRS pipeline shouldn't have any leftover
+proj=axisswap +order=2,1in either direction. Horizontal coordinatesshould stay in (lon, lat) order all the way through, exactly as
always_xypromises. (Without it, the CRS's own declared axis order applies as usual;
this issue is only about what happens once that flag is set.)
I've got a fix for this (tested both directions, ran the full PROJ test suite
against it, no regressions). Happy to open a PR if that's useful.
Important
The same class of bug likely affects
EngineeringCRSpairs withnon-trivial axis directions (e.g. EPSG:5800, axis order north/west), but
fixing that requires an axis direction flip that
Conversion::createAxisOrderReversalcannot currently express, which is aseparate, larger change. This issue and fix are scoped to the
Geographic3D <-> VerticalCRS case only.