Repository: rasterio/rasterio

Issue:
Add support of CoordinateOperation
GDAL provides a way to override default transformation using [coordinateOperation](https://github.com/OSGeo/gdal/blob/1ca6a3e5168c200763fa46d8aa7e698d0b757e7e/swig/python/osgeo/gdal.py#L493) property (`-ct` option for [gdalwarp](https://gdal.org/programs/gdalwarp.html#cmdoption-gdalwarp-ct)). This feature is not available in rasterio at the moment.


Discussion:
@drnextgis that's interesting, for sure. Might be too big a change for 1.3.0.
Perhaps to do as part of #1990.
#2437, which aimed to fix #1990, is blocked. I'm going to solve the coordinate operation issue for 1.3.0 in a different way.
@drnextgis now that I think about it, I think we are already able to do something like this:

`reproject(..., coordinate_operation="+proj pipeline ...")`

See https://gdal.org/api/gdal_alg.html#_CPPv432GDALCreateGenImgProjTransformer212GDALDatasetH12GDALDatasetHPPc.
I added a new test to verify that coordinate operations are already in effect. They do, however, need to be exposed for rio-warp.

Resolving patch:
diff --git a/rasterio/rio/warp.py b/rasterio/rio/warp.py
--- a/rasterio/rio/warp.py
+++ b/rasterio/rio/warp.py
@@ -12,6 +12,7 @@
 from rasterio.errors import CRSError
 from rasterio.rio import options
 from rasterio.rio.helpers import resolve_inout
+from rasterio.rio.options import _cb_key_val
 from rasterio.transform import Affine
 from rasterio.warp import (
     reproject, Resampling, SUPPORTED_RESAMPLING, transform_bounds,
@@ -63,11 +64,39 @@
               help='align the output bounds based on the resolution')
 @options.overwrite_opt
 @options.creation_options
+@click.option(
+    "--to",
+    "--wo",
+    "--transformer-option",
+    "--warper-option",
+    "warper_options",
+    metavar="NAME=VALUE",
+    multiple=True,
+    callback=_cb_key_val,
+    help="GDAL warper and coordinate transformer options.",
+)
 @click.pass_context
-def warp(ctx, files, output, driver, like, dst_crs, dimensions, src_bounds,
-         dst_bounds, res, resampling, src_nodata, dst_nodata, threads,
-         check_invert_proj, overwrite, creation_options,
-         target_aligned_pixels):
+def warp(
+    ctx,
+    files,
+    output,
+    driver,
+    like,
+    dst_crs,
+    dimensions,
+    src_bounds,
+    dst_bounds,
+    res,
+    resampling,
+    src_nodata,
+    dst_nodata,
+    threads,
+    check_invert_proj,
+    overwrite,
+    creation_options,
+    target_aligned_pixels,
+    warper_options,
+):
     """
     Warp a raster dataset.
 
@@ -224,8 +253,14 @@ def warp(ctx, files, output, driver, like, dst_crs, dimensions, src_bounds,
                             src_crs = src.crs
                             kwargs = src.bounds._asdict()
                         dst_transform, dst_width, dst_height = calcdt(
-                            src_crs, dst_crs, src.width, src.height,
-                            resolution=res, **kwargs)
+                            src_crs,
+                            dst_crs,
+                            src.width,
+                            src.height,
+                            resolution=res,
+                            **kwargs,
+                            **warper_options
+                        )
                     except CRSError as err:
                         raise click.BadParameter(
                             str(err), param='dst_crs', param_hint='dst_crs')
@@ -285,7 +320,9 @@ def warp(ctx, files, output, driver, like, dst_crs, dimensions, src_bounds,
                 dst_transform = Affine.translation(left, top) * Affine.scale(res, -res)
 
             if target_aligned_pixels:
-                dst_transform, dst_width, dst_height = aligned_target(dst_transform, dst_width, dst_height, res)
+                dst_transform, dst_width, dst_height = aligned_target(
+                    dst_transform, dst_width, dst_height, res
+                )
 
             # If src_nodata is not None, update the dst metadata NODATA
             # value to src_nodata (will be overridden by dst_nodata if it is not None
@@ -347,4 +384,6 @@ def warp(ctx, files, output, driver, like, dst_crs, dimensions, src_bounds,
                     dst_crs=out_kwargs['crs'],
                     dst_nodata=dst_nodata,
                     resampling=resampling,
-                    num_threads=threads)
+                    num_threads=threads,
+                    **warper_options
+                )


Test patch:
diff --git a/tests/test_rio_warp.py b/tests/test_rio_warp.py
--- a/tests/test_rio_warp.py
+++ b/tests/test_rio_warp.py
@@ -620,3 +620,30 @@ def test_unrotate(runner, tmp_path):
     with rasterio.open(outputname) as src:
         assert src.transform.b == 0.0
         assert src.transform.d == 0.0
+
+
+@pytest.mark.parametrize(
+    "wotopt", ["--to", "--transformer-option", "--wo", "--warper-option"]
+)
+def test_coordinate_operation(runner, tmp_path, wotopt):
+    """Verify that transformer coordinate operations are activated."""
+    outputname = tmp_path.joinpath("test.tif").as_posix()
+    pipeline = "+proj=pipeline step inv proj=utm zone=11 ellps=clrk66 step proj=unitconvert xy_in=rad xy_out=deg step proj=axisswap order=2,1"
+    result = runner.invoke(
+        main_group,
+        [
+            "warp",
+            "--dst-crs",
+            "EPSG:4326",
+            "--resampling",
+            "cubic",
+            wotopt,
+            "coordinate_operation={}".format(pipeline),
+            "tests/data/byte.tif",
+            outputname,
+        ],
+    )
+    assert result.exit_code == 0
+
+    with rasterio.open(outputname) as src:
+        assert src.checksum(1) == 4705