Repository: rasterio/rasterio

Issue:
Please add support for retrieving GCPs metadata when available.
In our company we often use imagery in sensor geometry for which no affine transform can be given. To geocode the information we need access to the set of GCPs that are written as metaddata in many spatial formats. As GDAL has support for many of them, it would be a useful feature for us to implement some kind of programmatic access to GDALDataset GetGCP\* methods.



Discussion:
I'm a big :+1: on adding support for GCPs. Related: #174.

I'll try to allocate some time on that, but not in the short term. And I'll need more guidance :)

Hey @alexamici, if you're still interested in working on this, one thing that would be very helpful would be a small test image with GCPs that we could use in the unit test suite. Would you be able to provide or create such an image?

I'm still interested! A VRT file on top of an image already used in the tests is the easiest, more compact source of GCPs I can think of. If someone is willing to work on the issue I'll try to allocate a time-slot to create the file myself ASAP.


Resolving patch:
diff --git a/rasterio/control.py b/rasterio/control.py
new file mode 100644
--- /dev/null
+++ b/rasterio/control.py
@@ -0,0 +1,60 @@
+"""Ground control points"""
+
+import uuid
+
+
+class GroundControlPoint(object):
+    """A mapping of row, col image coordinates to x, y, z."""
+
+    def __init__(self, row=None, col=None, x=None, y=None, z=None,
+                 id=None, info=None):
+        """Create a new ground control point
+
+        Parameters
+        ----------
+        row, col : float, required
+            The row (or line) and column (or pixel) coordinates that
+            map to spatial coordinate values ``y`` and ``x``,
+            respectively.
+        x, y : float, required
+            Spatial coordinates of a ground control point.
+        z : float, optional
+            Optional ``z`` coordinate.
+        id : str, optional
+            A unique identifer for the ground control point.
+        info : str, optional
+            A short description for the ground control point.
+        """
+        if any(x is None for x in (row, col, x, y)):
+            raise ValueError("row, col, x, and y are required parameters.")
+        if id is None:
+            id = str(uuid.uuid4())
+        self.id = id
+        self.info = info
+        self.row = row
+        self.col = col
+        self.x = x
+        self.y = y
+        self.z = z
+
+    def __repr__(self):
+        args = ', '.join(['{}={}'.format(att, repr(getattr(self, att)))
+                         for att in ('row', 'col', 'x', 'y', 'z', 'id', 'info')
+                         if getattr(self, att) is not None])
+        return "GroundControlPoint({})".format(args)
+
+    def asdict(self):
+        """A dict representation of the GCP"""
+        return {'id': self.id, 'info': self.info, 'row': self.row,
+                'col': self.col, 'x': self.x, 'y': self.y, 'z': self.z}
+
+    @property
+    def __geo_interface__(self):
+        """A GeoJSON representation of the GCP"""
+        coords = [self.x, self.y]
+        if self.z is not None:
+            coords.append(self.z)
+        return {'id': self.id, 'type': 'Feature',
+                'geometry': {'type': 'Point', 'coordinates': tuple(coords)},
+                'properties': {'info': self.info, 'row': self.row,
+                               'col': self.col}}
diff --git a/rasterio/rio/warp.py b/rasterio/rio/warp.py
--- a/rasterio/rio/warp.py
+++ b/rasterio/rio/warp.py
@@ -15,7 +15,8 @@
 from rasterio.rio.helpers import resolve_inout
 from rasterio.transform import Affine
 from rasterio.warp import (
-    reproject, Resampling, calculate_default_transform, transform_bounds)
+    reproject, Resampling, transform_bounds,
+    calculate_default_transform as calcdt)
 
 
 # Improper usage of rio-warp can lead to accidental creation of
@@ -199,12 +200,19 @@ def warp(ctx, files, output, driver, like, dst_crs, dimensions, src_bounds,
 
                 else:
                     try:
-                        dst_transform, dst_width, dst_height = calculate_default_transform(
-                            src.crs, dst_crs, src.width, src.height,
-                            *src.bounds, resolution=res)
+                        if src.transform.is_identity and src.gcps:
+                            src_crs = src.gcps[1]
+                            kwargs = {'gcps': src.gcps[0]}
+                        else:
+                            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)
                     except CRSError as err:
                         raise click.BadParameter(
                             str(err), param='dst_crs', param_hint='dst_crs')
+
             elif dimensions:
                 # Same projection, different dimensions, calculate resolution.
                 dst_crs = src.crs
diff --git a/rasterio/warp.py b/rasterio/warp.py
--- a/rasterio/warp.py
+++ b/rasterio/warp.py
@@ -158,61 +158,60 @@ def transform_bounds(
 
 
 @ensure_env
-def reproject(
-        source,
-        destination,
-        src_transform=None,
-        src_crs=None,
-        src_nodata=None,
-        dst_transform=None,
-        dst_crs=None,
-        dst_nodata=None,
-        resampling=Resampling.nearest,
-        init_dest_nodata=True,
-        **kwargs):
-    """
-    Reproject a source raster to a destination raster.
+def reproject(source, destination, src_transform=None, gcps=None,
+              src_crs=None, src_nodata=None, dst_transform=None, dst_crs=None,
+              dst_nodata=None, resampling=Resampling.nearest,
+              init_dest_nodata=True, **kwargs):
+    """Reproject a source raster to a destination raster.
 
     If the source and destination are ndarrays, coordinate reference
-    system definitions and affine transformation parameters are required
-    for reprojection.
+    system definitions and affine transformation parameters or ground
+    control points (gcps) are required for reprojection.
 
     If the source and destination are rasterio Bands, shorthand for
     bands of datasets on disk, the coordinate reference systems and
-    transforms will be read from the appropriate datasets.
+    transforms or GCPs will be read from the appropriate datasets.
 
     Parameters
     ------------
     source, destination: ndarray or Band
         The source and destination are 2 or 3-D ndarrays, or a single
         or multiple Rasterio Band object. The dimensionality of source
-        and destination must match, i.e., for multiband reprojection 
-        the lengths of the first axes of the source and destination 
+        and destination must match, i.e., for multiband reprojection
+        the lengths of the first axes of the source and destination
         must be the same.
     src_transform: affine.Affine(), optional
-        Source affine transformation.  Required if source and destination
-        are ndarrays.  Will be derived from source if it is a rasterio Band.
+        Source affine transformation. Required if source and
+        destination are ndarrays. Will be derived from source if it is
+        a rasterio Band. An error will be raised if this parameter is
+        defined together with gcps.
+    gcps: sequence of GroundControlPoint, optional
+        Ground control points for the source. An error will be raised
+        if this parameter is defined together with src_transform.
     src_crs: CRS or dict, optional
         Source coordinate reference system, in rasterio dict format.
         Required if source and destination are ndarrays.
         Will be derived from source if it is a rasterio Band.
         Example: CRS({'init': 'EPSG:4326'})
     src_nodata: int or float, optional
-        The source nodata value.  Pixels with this value will not be used
-        for interpolation.  If not set, it will be default to the
-        nodata value of the source image if a masked ndarray or rasterio band,
-        if available.  Must be provided if dst_nodata is not None.
+        The source nodata value.Pixels with this value will not be
+        used for interpolation. If not set, it will be default to the
+        nodata value of the source image if a masked ndarray or
+        rasterio band, if available. Must be provided if dst_nodata is
+        not None.
     dst_transform: affine.Affine(), optional
-        Target affine transformation.  Required if source and destination
-        are ndarrays.  Will be derived from target if it is a rasterio Band.
+        Target affine transformation. Required if source and
+        destination are ndarrays. Will be derived from target if it is
+        a rasterio Band.
     dst_crs: CRS or dict, optional
-        Target coordinate reference system.  Required if source and destination
-        are ndarrays.  Will be derived from target if it is a rasterio Band.
+        Target coordinate reference system. Required if source and
+        destination are ndarrays. Will be derived from target if it
+        is a rasterio Band.
     dst_nodata: int or float, optional
-        The nodata value used to initialize the destination; it will remain
-        in all areas not covered by the reprojected source.  Defaults to the
-        nodata value of the destination image (if set), the value of
-        src_nodata, or 0 (GDAL default).
+        The nodata value used to initialize the destination; it will
+        remain in all areas not covered by the reprojected source.
+        Defaults to the nodata value of the destination image (if set),
+        the value of src_nodata, or 0 (GDAL default).
     resampling: int
         Resampling method to use.  One of the following:
             Resampling.nearest,
@@ -233,6 +232,10 @@ def reproject(
     out: None
         Output is written to destination.
     """
+    if src_transform and gcps:
+        raise ValueError("src_transform and gcps parameters may not"
+                         "be used together.")
+
     # Resampling guard.
     try:
         Resampling(resampling)
@@ -263,38 +266,24 @@ def reproject(
     if dst_crs is None:
         dst_crs = {}
 
-    _reproject(
-        source,
-        destination,
-        src_transform,
-        src_crs,
-        src_nodata,
-        dst_transform,
-        dst_crs,
-        dst_nodata,
-        resampling,
-        init_dest_nodata,
-        **kwargs)
+    _reproject(source, destination, src_transform, gcps, src_crs, src_nodata,
+               dst_transform, dst_crs, dst_nodata, resampling,
+               init_dest_nodata, **kwargs)
 
 
 @ensure_env
-def calculate_default_transform(
-        src_crs,
-        dst_crs,
-        width,
-        height,
-        left,
-        bottom,
-        right,
-        top,
-        resolution=None):
-    """Calculate parameters for reproject function.
+def calculate_default_transform(src_crs, dst_crs, width, height,
+                                left=None, bottom=None, right=None, top=None,
+                                gcps=None, resolution=None):
+    """Output dimensions and transform for a reprojection.
 
-    Transforms bounds to destination coordinate system, calculates resolution
-    if not provided, and returns destination transform and dimensions.
-    Intended to be used to calculate parameters for reproject function.
+    Source and destination coordinate reference systems and output
+    width and height are the first four, required, parameters. Source
+    georeferencing can be specified using either ground control points
+    (gcps) or spatial bounds (left, bottom, right, top). These two
+    forms of georeferencing are mutually exclusive.
 
-    Destination transform is anchored from the left, top coordinate.
+    The destination transform is anchored at the left, top coordinate.
 
     Destination width and height (and resolution if not provided), are
     calculated using GDAL's method for suggest warp output.
@@ -306,36 +295,45 @@ def calculate_default_transform(
         Example: CRS({'init': 'EPSG:4326'})
     dst_crs: CRS or dict
         Target coordinate reference system.
-    width: int
-        Source raster width.
-    height: int
-        Source raster height.
-    left, bottom, right, top: float
-        Bounding coordinates in src_crs, from the bounds property of a raster.
+    width, height: int, optional
+        Source raster width and height. Not needed if gcps are used.
+    left, bottom, right, top: float, optional
+        Bounding coordinates in src_crs, from the bounds property of a
+        raster.
+    gcps: sequence of GroundControlPoint, optional
+        Instead of a bounding box for the source, a sequence of ground
+        control points may be provided.
     resolution: tuple (x resolution, y resolution) or float, optional
-        Target resolution, in units of target coordinate reference system.
+        Target resolution, in units of target coordinate reference
+        system.
 
     Returns
     -------
-    tuple
-        Three elements: ``affine transform, width, and height``
+    transform: Affine
+        Output affine transformation matrix
+    width, height: int
+        Output dimesions
 
+    Notes
+    -----
     Some behavior of this function is determined by the
-    CHECK_WITH_INVERT_PROJ environment variable
+    CHECK_WITH_INVERT_PROJ environment variable:
+
         YES: constrain output raster to extents that can be inverted
              avoids visual artifacts and coordinate discontinuties.
         NO:  reproject coordinates beyond valid bound limits
     """
+    if any(x is not None for x in (left, bottom, right, top)) and gcps:
+        raise ValueError("Bounding values and ground control points may not"
+                         "be used together.")
+
     dst_affine, dst_width, dst_height = _calculate_default_transform(
-        src_crs, dst_crs,
-        width, height,
-        left, bottom, right, top)
+        src_crs, dst_crs, width, height, left, bottom, right, top, gcps)
 
     # If resolution is specified, Keep upper-left anchored
     # adjust the transform resolutions
     # adjust the width/height by the ratio of estimated:specified res (ceil'd)
     if resolution:
-
         # resolutions argument into tuple
         try:
             res = (float(resolution), float(resolution))


Test patch:
diff --git a/tests/data/389225main_sw_1965_1024.jpg b/tests/data/389225main_sw_1965_1024.jpg
new file mode 100644
Binary files /dev/null and b/tests/data/389225main_sw_1965_1024.jpg differ
diff --git a/tests/data/white-gemini-iv.vrt b/tests/data/white-gemini-iv.vrt
new file mode 100644
--- /dev/null
+++ b/tests/data/white-gemini-iv.vrt
@@ -0,0 +1,36 @@
+<VRTDataset rasterXSize="1024" rasterYSize="768">
+<GCPList Projection="EPSG:32618">
+  <GCP Id="1" Info="a" Pixel="0.0" Line="0.0" X="157168.0" Y="2818194.0" Z="0.0" />
+  <GCP Id="2" Info="b" Pixel="1024.0" Line="0.0" X="338615.0" Y="2786088.0" Z="0.0" />
+  <GCP Id="3" Info="c" Pixel="0.0" Line="768.0" X="116792" Y="2651340.0" Z="0.0" />
+</GCPList>
+  <VRTRasterBand dataType="Byte" band="1">
+    <ColorInterp>Gray</ColorInterp>
+    <SimpleSource>
+      <SourceFilename relativeToVRT="0">tests/data/389225main_sw_1965_1024.jpg</SourceFilename>
+      <SourceBand>1</SourceBand>
+      <SrcRect xOff="0" yOff="0" xSize="1024" ySize="768"/>
+      <DstRect xOff="0" yOff="0" xSize="1024" ySize="768"/>
+    </SimpleSource>
+  </VRTRasterBand>
+  <VRTRasterBand dataType="Byte" band="2">
+    <ColorInterp>Gray</ColorInterp>
+    <SimpleSource>
+      <SourceFilename relativeToVRT="0">tests/data/389225main_sw_1965_1024.jpg</SourceFilename>
+      <SourceBand>2</SourceBand>
+      <SrcRect xOff="0" yOff="0" xSize="1024" ySize="768"/>
+      <DstRect xOff="0" yOff="0" xSize="1024" ySize="768"/>
+    </SimpleSource>
+  </VRTRasterBand>
+  <VRTRasterBand dataType="Byte" band="3">
+    <ColorInterp>Gray</ColorInterp>
+    <SimpleSource>
+      <SourceFilename relativeToVRT="0">tests/data/389225main_sw_1965_1024.jpg</SourceFilename>
+      <SourceBand>3</SourceBand>
+      <SrcRect xOff="0" yOff="0" xSize="1024" ySize="768"/>
+      <DstRect xOff="0" yOff="0" xSize="1024" ySize="768"/>
+    </SimpleSource>
+  </VRTRasterBand>
+
+</VRTDataset>
+
diff --git a/tests/test_gcps.py b/tests/test_gcps.py
new file mode 100644
--- /dev/null
+++ b/tests/test_gcps.py
@@ -0,0 +1,105 @@
+"""Tests of ground control points"""
+
+import pytest
+
+import rasterio
+from rasterio.control import GroundControlPoint
+
+
+def test_gcp_empty():
+    with pytest.raises(ValueError):
+        GroundControlPoint()
+
+
+def test_gcp():
+    gcp = GroundControlPoint(1.0, 1.5, 100.0, 1000.0, z=0.0)
+    assert gcp.row == 1.0
+    assert gcp.col == 1.5
+    assert gcp.x == 100.0
+    assert gcp.y == 1000.0
+    assert gcp.z == 0.0
+    assert isinstance(gcp.id, str)
+
+
+def test_gcp_repr():
+    gcp = GroundControlPoint(1.0, 1.5, 100.0, 1000.0, id='foo', info='bar')
+    assert repr(gcp) == "GroundControlPoint(row=1.0, col=1.5, x=100.0, y=1000.0, id='foo', info='bar')"
+
+
+def test_gcp_dict():
+    gcp = GroundControlPoint(1.0, 1.5, 100.0, 1000.0, id='foo', info='bar')
+    assert gcp.asdict()['row'] == 1.0
+    assert gcp.asdict()['col'] == 1.5
+    assert gcp.asdict()['x'] == 100.0
+
+
+def test_gcp_geo_interface():
+    gcp = GroundControlPoint(1.0, 1.5, 100.0, 1000.0, id='foo', info='bar')
+    assert gcp.__geo_interface__['geometry']['coordinates'] == (100.0, 1000.0)
+    assert gcp.__geo_interface__['type'] == 'Feature'
+    assert gcp.__geo_interface__['id'] == 'foo'
+    assert gcp.__geo_interface__['properties']['info'] == 'bar'
+    assert gcp.__geo_interface__['properties']['row'] == 1.0
+    assert gcp.__geo_interface__['properties']['col'] == 1.5
+
+
+def test_gcp_geo_interface_z():
+    gcp = GroundControlPoint(1.0, 1.5, 100.0, 1000.0, z=0.0)
+    assert gcp.__geo_interface__['geometry']['coordinates'] == (100.0, 1000.0, 0.0)
+
+
+def test_write_read_gcps(tmpdir):
+    tiffname = str(tmpdir.join('test.tif'))
+    gcps = [GroundControlPoint(1, 1, 100.0, 1000.0, z=0.0)]
+
+    with rasterio.open(tiffname, 'w', driver='GTiff', dtype='uint8', count=1,
+                       width=10, height=10, crs='epsg:4326', gcps=gcps) as dst:
+        pass
+
+    with rasterio.open(tiffname, 'r+') as dst:
+        gcps, crs = dst.gcps
+        assert crs['init'] == 'epsg:4326'
+        assert len(gcps) == 1
+        point = gcps[0]
+        assert (1, 1) == (point.row, point.col)
+        assert (100.0, 1000.0, 0.0) == (point.x, point.y, point.z)
+
+        dst.gcps = [
+            GroundControlPoint(1, 1, 100.0, 1000.0, z=0.0),
+            GroundControlPoint(2, 2, 200.0, 2000.0, z=0.0)], crs
+
+        gcps, crs = dst.gcps
+
+        assert crs['init'] == 'epsg:4326'
+        assert len(gcps) == 2
+        point = gcps[1]
+        assert (2, 2) == (point.row, point.col)
+        assert (200.0, 2000.0, 0.0) == (point.x, point.y, point.z)
+
+
+def test_read_vrt_gcps(tmpdir):
+    vrtfile = tmpdir.join('test.vrt')
+    vrtfile.write("""
+<VRTDataset rasterXSize="512" rasterYSize="512">
+<GCPList Projection="EPSG:4326">
+  <GCP Id="1" Info="a" Pixel="0.5" Line="0.5" X="0.0" Y="0.0" Z="0.0" />
+  <GCP Id="2" Info="b" Pixel="13.5" Line="23.5" X="1.0" Y="2.0" Z="0.0" />
+</GCPList>
+  <GeoTransform>440720.0, 60.0, 0.0, 3751320.0, 0.0, -60.0</GeoTransform>
+  <VRTRasterBand dataType="Byte" band="1">
+    <ColorInterp>Gray</ColorInterp>
+    <SimpleSource>
+      <SourceFilename relativeToVRT="0">tests/data/RGB.byte.tif</SourceFilename>
+      <SourceBand>1</SourceBand>
+      <SrcRect xOff="0" yOff="0" xSize="512" ySize="512"/>
+      <DstRect xOff="0" yOff="0" xSize="512" ySize="512"/>
+    </SimpleSource>
+  </VRTRasterBand>
+</VRTDataset>""")
+    with rasterio.open(str(vrtfile)) as src:
+        gcps, crs = src.gcps
+        assert crs['init'] == 'epsg:4326'
+        assert len(gcps) == 2
+        assert [(0.5, 0.5), (13.5, 23.5)] == [(p.col, p.row) for p in gcps]
+        assert ['1', '2'] == [p.id for p in gcps]
+        assert ['a', 'b'] == [p.info for p in gcps]
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
@@ -498,8 +498,7 @@ def test_warp_reproject_check_invert_true(runner, tmpdir):
         'warp', srcname, output2name, '--check-invert-proj',
         '--dst-crs', 'EPSG:3759'])
 
-    with rasterio.open(outputname) as output, \
-         rasterio.open(output2name) as output2:
+    with rasterio.open(outputname) as output, rasterio.open(output2name) as output2:
         assert output.shape == output2.shape
 
 
@@ -517,6 +516,22 @@ def test_warp_reproject_check_invert_false(runner, tmpdir):
         'warp', srcname, output2name, '--no-check-invert-proj',
         '--dst-crs', 'EPSG:3759'])
 
-    with rasterio.open(outputname) as output, \
-         rasterio.open(output2name) as output2:
+    with rasterio.open(outputname) as output, rasterio.open(output2name) as output2:
         assert output.shape != output2.shape
+
+
+def test_warp_vrt_gcps(runner, tmpdir):
+    """A VRT with GCPs can be warped."""
+    srcname = 'tests/data/white-gemini-iv.vrt'
+    outputname = str(tmpdir.join('test.tif'))
+    result = runner.invoke(main_group, [
+        'warp', srcname, outputname, '--dst-crs', 'epsg:32618'])
+    assert result.exit_code == 0
+
+    # All 4 corners have no data.
+    with rasterio.open(outputname) as src:
+        data = src.read()
+        assert not data[:, 0, 0].any()
+        assert not data[:, 0, -1].any()
+        assert not data[:, -1, -1].any()
+        assert not data[:, -1, 0].any()
diff --git a/tests/test_warp.py b/tests/test_warp.py
--- a/tests/test_warp.py
+++ b/tests/test_warp.py
@@ -7,6 +7,7 @@
 from packaging.version import parse
 
 import rasterio
+from rasterio.control import GroundControlPoint
 from rasterio.enums import Resampling
 from rasterio.warp import (
     reproject, transform_geom, transform, transform_bounds,
@@ -328,7 +329,7 @@ def test_reproject_nodata(options, expected):
     with rasterio.Env(**options):
         source = np.ones((params.width, params.height), dtype=np.uint8)
         out = np.zeros((params.dst_width, params.dst_height),
-                          dtype=source.dtype)
+                       dtype=source.dtype)
         out.fill(120)  # Fill with arbitrary value
 
         reproject(
@@ -354,7 +355,7 @@ def test_reproject_nodata_nan(options, expected):
     with rasterio.Env(**options):
         source = np.ones((params.width, params.height), dtype=np.float32)
         out = np.zeros((params.dst_width, params.dst_height),
-                          dtype=source.dtype)
+                       dtype=source.dtype)
         out.fill(120)  # Fill with arbitrary value
 
         reproject(
@@ -381,7 +382,7 @@ def test_reproject_dst_nodata_default(options, expected):
     with rasterio.Env(**options):
         source = np.ones((params.width, params.height), dtype=np.uint8)
         out = np.zeros((params.dst_width, params.dst_height),
-                          dtype=source.dtype)
+                       dtype=source.dtype)
         out.fill(120)  # Fill with arbitrary value
 
         reproject(
@@ -948,3 +949,40 @@ def test_reproject_identity():
             dst_transform=dstaff,
             dst_crs=dstcrs,
             resampling=Resampling.nearest)
+
+
+@pytest.fixture(scope='function')
+def rgb_byte_profile():
+    with rasterio.open('tests/data/RGB.byte.tif') as src:
+        return src.profile
+
+
+def test_reproject_gcps_transform_exclusivity():
+    """gcps and transform can't be used together."""
+    with pytest.raises(ValueError):
+        reproject(1, 1, gcps=[0], src_transform=[0])
+
+
+def test_reproject_gcps(rgb_byte_profile):
+    """Reproject using ground control points for the source"""
+    source = np.ones((3, 800, 800), dtype=np.uint8) * 255
+    out = np.zeros((3, rgb_byte_profile['height'], rgb_byte_profile['height']), dtype=np.uint8)
+    src_gcps = [
+        GroundControlPoint(row=0, col=0, x=156113, y=2818720, z=0),
+        GroundControlPoint(row=0, col=800, x=338353, y=2785790, z=0),
+        GroundControlPoint(row=800, col=800, x=297939, y=2618518, z=0),
+        GroundControlPoint(row=800, col=0, x=115698, y=2651448, z=0)]
+    reproject(
+        source,
+        out,
+        src_crs='epsg:32618',
+        gcps=src_gcps,
+        dst_transform=rgb_byte_profile['transform'],
+        dst_crs=rgb_byte_profile['crs'],
+        resampling=Resampling.nearest)
+
+    assert not out.all()
+    assert not out[:, 0, 0].any()
+    assert not out[:, 0, -1].any()
+    assert not out[:, -1, -1].any()
+    assert not out[:, -1, 0].any()
diff --git a/tests/test_warp_transform.py b/tests/test_warp_transform.py
--- a/tests/test_warp_transform.py
+++ b/tests/test_warp_transform.py
@@ -4,9 +4,17 @@
 
 import rasterio
 from rasterio._warp import _calculate_default_transform
+from rasterio.control import GroundControlPoint
 from rasterio.errors import CRSError
 from rasterio.transform import from_bounds
-from rasterio.warp import transform_bounds
+from rasterio.warp import calculate_default_transform, transform_bounds
+
+
+def test_gcps_bounds_exclusivity():
+    """gcps and bounds parameters are mutually exclusive"""
+    with pytest.raises(ValueError):
+        calculate_default_transform(
+            'epsg:4326', 'epsg:3857', width=1, height=1, left=1.0, gcps=[1])
 
 
 def test_identity():
@@ -29,6 +37,34 @@ def test_identity():
         assert round(res, 7) == round(exp, 7)
 
 
+def test_identity_gcps():
+    """Define an identity transform using GCPs"""
+    # Tile: [53, 96, 8]
+    src_crs = dst_crs = 'EPSG:3857'
+    width = height = 1000
+    left, bottom, right, top = (
+        -11740727.544603072, 4852834.0517692715, -11584184.510675032,
+        5009377.085697309)
+    # For comparison only, these are not used to calculate the transform.
+    transform = from_bounds(left, bottom, right, top, width, height)
+
+    # Define 4 ground control points at the corners of the image.
+    gcps = [
+        GroundControlPoint(row=0, col=0, x=left, y=top, z=0.0),
+        GroundControlPoint(row=0, col=1000, x=right, y=top, z=0.0),
+        GroundControlPoint(row=1000, col=1000, x=right, y=bottom, z=0.0),
+        GroundControlPoint(row=1000, col=0, x=left, y=bottom, z=0.0)]
+
+    # Compute an output transform.
+    res_transform, res_width, res_height = _calculate_default_transform(
+        src_crs, dst_crs, height=height, width=width, gcps=gcps)
+
+    assert res_width == width
+    assert res_height == height
+    for res, exp in zip(res_transform, transform):
+        assert round(res, 7) == round(exp, 7)
+
+
 def test_transform_bounds():
     """CRSError is raised."""
     with rasterio.Env():
@@ -83,7 +119,7 @@ def test_gdal_transform_fail_src_crs():
 
 @pytest.mark.xfail(
     os.environ.get('GDALVERSION', 'a.b.c').startswith('1.9'),
-                   reason="GDAL 1.9 doesn't catch this error")
+    reason="GDAL 1.9 doesn't catch this error")
 def test_gdal_transform_fail_dst_crs_xfail():
     with rasterio.Env():
         with pytest.raises(CRSError):