Repository: rasterio/rasterio

Issue:
Add a fio translate command
Best features of gdal_translate, but better interface.



Discussion:
Enumerating the different things that gdal_translate does:
- Format conversion
- Data type conversion and scaling
- Adding georeferencing, CRS, nodata values
- Subsetting

TODO: a GitHub code search/survey of how developers are using gdal_translate and consideration of  https://github.com/dwtkns/gdal-cheat-sheet.

Excited to see this, having just written a `gdal_translate` shell script with a ton of `-gcp` arguments chained together. Is there room for an intermediate file type that cleans this up? Right now [the only georeference workflow I'm familiar with](https://www.mapbox.com/tilemill/docs/guides/georeferencing-satellite-images/) is QGIS-based, which generates bash scripts as output but can also [save points to a CSV](https://gist.github.com/sbma44/b4e63f069576cf5cf161).

@sbma44 so something like `rio translate --ground-control P,L,X,Y ...` or  `rio translate --ground-control points.txt ...` or even

```
cat << EOF | rio translate --ground-control - ...
P,L,X,Y
P,L,X,Y
EOF
```

? I've got a pattern established in some of these scripts for getting lines of input from an option or file that I could use here.

y, having the flexibility to add a bunch of points on the command line or pipe/point to a file would be great. doing multiline strings in bash is a little frightening to me, but maybe others would like it...


Resolving patch:
diff --git a/rasterio/rio/convert.py b/rasterio/rio/convert.py
new file mode 100644
--- /dev/null
+++ b/rasterio/rio/convert.py
@@ -0,0 +1,105 @@
+"""File translation command"""
+
+import logging
+import warnings
+
+import click
+from cligj import files_inout_arg, format_opt
+import numpy as np
+
+from .helpers import resolve_inout
+from . import options
+import rasterio
+
+
+warnings.simplefilter('default')
+
+
+@click.command(short_help="Copy and convert raster dataset.")
+@click.argument(
+    'files',
+    nargs=-1,
+    type=click.Path(resolve_path=True),
+    required=True,
+    metavar="INPUT OUTPUT")
+@options.output_opt
+@format_opt
+@options.dtype_opt
+@click.option('--scale-ratio', type=float, default=None,
+              help="Source to destination scaling ratio.")
+@click.option('--scale-offset', type=float, default=None,
+              help="Source to destination scaling offset.")
+@options.creation_options
+@click.pass_context
+def convert(
+        ctx, files, output, driver, dtype, scale_ratio, scale_offset,
+        creation_options):
+    """Copy and convert raster datasets to other data types and formats.
+
+    Data values may be linearly scaled when copying by using the
+    --scale-ratio and --scale-offset options. Destination raster values
+    are calculated as
+
+      dst = scale_ratio * src + scale_offset
+
+    For example, to scale uint16 data with an actual range of 0-4095 to
+    0-255 as uint8:
+
+      $ rio convert in16.tif out8.tif --dtype uint8 --scale-ratio 0.0625
+
+    Format specific creation options may also be passed using --co. To
+    tile a new GeoTIFF output file, do the following.
+
+      --co tiled=true --co blockxsize=256 --co blockysize=256
+
+    To compress it using the LZW method, add
+
+      --co compress=LZW
+
+    """
+    verbosity = (ctx.obj and ctx.obj.get('verbosity')) or 1
+    logger = logging.getLogger('rio')
+
+    with rasterio.drivers(CPL_DEBUG=verbosity > 2):
+
+        outputfile, files = resolve_inout(files=files, output=output)
+        inputfile = files[0]
+
+        with rasterio.open(inputfile) as src:
+
+            # Use the input file's profile, updated by CLI
+            # options, as the profile for the output file.
+            profile = src.profile
+
+            if 'affine' in profile:
+                profile['transform'] = profile.pop('affine')
+
+            if driver:
+                profile['driver'] = driver
+
+            if dtype:
+                profile['dtype'] = dtype
+            dst_dtype = profile['dtype']
+
+            profile.update(**creation_options)
+
+            with rasterio.open(outputfile, 'w', **profile) as dst:
+
+                data = src.read()
+
+                if scale_ratio:
+                    # Cast to float64 before multiplying.
+                    data = data.astype('float64', casting='unsafe', copy=False)
+                    np.multiply(
+                        data, scale_ratio, out=data, casting='unsafe')
+
+                if scale_offset:
+                    # My understanding of copy=False is that this is a
+                    # no-op if the array was cast for multiplication.
+                    data = data.astype('float64', casting='unsafe', copy=False)
+                    np.add(
+                        data, scale_offset, out=data, casting='unsafe')
+
+                # Cast to the output dtype and write.
+                result = data.astype(dst_dtype, casting='unsafe', copy=False)
+                dst.write(result)
diff --git a/rasterio/rio/options.py b/rasterio/rio/options.py
--- a/rasterio/rio/options.py
+++ b/rasterio/rio/options.py
@@ -111,7 +111,7 @@ def _cb_key_val(ctx, param, value):
         'ubyte', 'uint8', 'uint16', 'int16', 'uint32', 'int32',
         'float32', 'float64']),
     default=None,
-    help="Output data type (default: float64).")
+    help="Output data type.")
 
 like_file_opt = click.option(
     '--like',
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -234,6 +234,7 @@ def copy_data_tree(datadir, destdir):
         [rasterio.rio_commands]
         bounds=rasterio.rio.features:bounds
         calc=rasterio.rio.calc:calc
+        convert=rasterio.rio.convert:convert
         edit-info=rasterio.rio.info:edit
         env=rasterio.rio.info:env
         info=rasterio.rio.info:info


Test patch:
diff --git a/tests/test_rio_convert.py b/tests/test_rio_convert.py
new file mode 100644
--- /dev/null
+++ b/tests/test_rio_convert.py
@@ -0,0 +1,120 @@
+import sys
+import os
+import logging
+import click
+from click.testing import CliRunner
+
+import rasterio
+from rasterio.rio.convert import convert
+
+
+logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
+
+
+# Tests: format and type conversion, --format and --dtype
+
+def test_format(tmpdir):
+    outputname = str(tmpdir.join('test.jpg'))
+    runner = CliRunner()
+    result = runner.invoke(
+        convert,
+        ['tests/data/RGB.byte.tif', outputname, '--format', 'JPEG'])
+    assert result.exit_code == 0
+    with rasterio.open(outputname) as src:
+        assert src.driver == 'JPEG'
+
+
+def test_format_short(tmpdir):
+    outputname = str(tmpdir.join('test.jpg'))
+    runner = CliRunner()
+    result = runner.invoke(
+        convert,
+        ['tests/data/RGB.byte.tif', outputname, '-f', 'JPEG'])
+    assert result.exit_code == 0
+    with rasterio.open(outputname) as src:
+        assert src.driver == 'JPEG'
+
+
+def test_output_opt(tmpdir):
+    outputname = str(tmpdir.join('test.jpg'))
+    runner = CliRunner()
+    result = runner.invoke(
+        convert,
+        ['tests/data/RGB.byte.tif', '-o', outputname, '-f', 'JPEG'])
+    assert result.exit_code == 0
+    with rasterio.open(outputname) as src:
+        assert src.driver == 'JPEG'
+
+
+def test_dtype(tmpdir):
+    outputname = str(tmpdir.join('test.tif'))
+    runner = CliRunner()
+    result = runner.invoke(
+        convert,
+        ['tests/data/RGB.byte.tif', outputname, '--dtype', 'uint16'])
+    assert result.exit_code == 0
+    with rasterio.open(outputname) as src:
+        assert src.dtypes == ['uint16']*3
+
+
+def test_dtype_rescaling_uint8_full(tmpdir):
+    """Rescale uint8 [0, 255] to uint8 [0, 255]"""
+    outputname = str(tmpdir.join('test.tif'))
+    runner = CliRunner()
+    result = runner.invoke(
+        convert,
+        ['tests/data/RGB.byte.tif', outputname, '--scale-ratio', '1.0'])
+    assert result.exit_code == 0
+
+    src_stats = [
+        {"max": 255.0, "mean": 44.434478650699106, "min": 1.0},
+        {"max": 255.0, "mean": 66.02203484105824, "min": 1.0},
+        {"max": 255.0, "mean": 71.39316199120559, "min": 1.0}]
+
+    with rasterio.open(outputname) as src:
+        for band, expected in zip(src.read(masked=True), src_stats):
+            assert round(band.min() - expected['min'], 6) == 0.0
+            assert round(band.max() - expected['max'], 6) == 0.0
+            assert round(band.mean() - expected['mean'], 6) == 0.0
+
+
+def test_dtype_rescaling_uint8_half(tmpdir):
+    """Rescale uint8 [0, 255] to uint8 [0, 127]"""
+    outputname = str(tmpdir.join('test.tif'))
+    runner = CliRunner()
+    result = runner.invoke(convert, [
+        'tests/data/RGB.byte.tif', outputname, '--scale-ratio', '0.5'])
+    assert result.exit_code == 0
+    with rasterio.open(outputname) as src:
+        for band in src.read():
+            assert round(band.min() - 0, 6) == 0.0
+            assert round(band.max() - 127, 6) == 0.0
+
+
+def test_dtype_rescaling_uint16(tmpdir):
+    """Rescale uint8 [0, 255] to uint16 [0, 4095]"""
+    # NB: 255 * 16 is 4080, we don't actually get to 4095.
+    outputname = str(tmpdir.join('test.tif'))
+    runner = CliRunner()
+    result = runner.invoke(convert, [
+        'tests/data/RGB.byte.tif', outputname, '--dtype', 'uint16',
+        '--scale-ratio', '16'])
+    assert result.exit_code == 0
+    with rasterio.open(outputname) as src:
+        for band in src.read():
+            assert round(band.min() - 0, 6) == 0.0
+            assert round(band.max() - 4080, 6) == 0.0
+
+
+def test_dtype_rescaling_float64(tmpdir):
+    """Rescale uint8 [0, 255] to float64 [-1, 1]"""
+    outputname = str(tmpdir.join('test.tif'))
+    runner = CliRunner()
+    result = runner.invoke(convert, [
+        'tests/data/RGB.byte.tif', outputname, '--dtype', 'float64',
+        '--scale-ratio', str(2.0/255), '--scale-offset', '-1.0'])
+    assert result.exit_code == 0
+    with rasterio.open(outputname) as src:
+        for band in src.read():
+            assert round(band.min() + 1.0, 6) == 0.0
+            assert round(band.max() - 1.0, 6) == 0.0