Repository: rasterio/rasterio

Issue:
Add a command to create TIFF overviews
à la gdaladdo. Based on the code in rio-merge we can offer a better syntax, like

``` bash
$ rio addo a.tif -r cubic 2..12
```

/cc @jacquestardie



Discussion:
Typically the levels are not linear ranges but are exponential. Maybe the syntax could be extended further.

I'm thinking something like `2^1..4` for  2 4 8 16, or `3^1..4` for 3 9 27 81.

@mwtoews Yes... that's great. I was thinking we'd allow a list as well, but without exponentiation, like `2,4,8,16`.


Resolving patch:
diff --git a/rasterio/enums.py b/rasterio/enums.py
--- a/rasterio/enums.py
+++ b/rasterio/enums.py
@@ -1,5 +1,6 @@
 
-from enum import IntEnum
+from enum import Enum, IntEnum
+
 
 class ColorInterp(IntEnum):
     undefined=0
@@ -16,4 +17,14 @@ class ColorInterp(IntEnum):
     cyan=10
     magenta=11
     yellow=12
-    black=13
\ No newline at end of file
+    black=13
+
+
+class Resampling(Enum):
+    nearest='NEAREST'
+    gauss='GAUSS'
+    cubic='CUBIC'
+    average='AVERAGE',
+    mode='MODE'
+    average_magphase='AVERAGE_MAGPHASE'
+    none='NONE'
diff --git a/rasterio/rio/overview.py b/rasterio/rio/overview.py
new file mode 100644
--- /dev/null
+++ b/rasterio/rio/overview.py
@@ -0,0 +1,101 @@
+# coding: utf-8
+"""Manage overviews of a dataset."""
+
+from functools import reduce
+import logging
+import operator
+
+import click
+
+import rasterio
+from rasterio.enums import Resampling
+
+from . import options
+
+
+def build_handler(ctx, param, value):
+    if value:
+        try:
+            if '^' in value:
+                base, exp_range = value.split('^')
+                exp_min, exp_max = (int(v) for v in exp_range.split('..'))
+                value = [pow(int(base), k) for k in range(exp_min, exp_max+1)]
+            else:
+                value = [int(v) for v in value.split(',')]
+        except Exception as exc:
+            raise click.BadParameter(u"must match 'n,n,n,…' or 'n^n..n'.")
+    return value
+
+
+@click.command('overview', short_help="Construct overviews in an existing dataset.")
+@options.file_in_arg
+@click.option('--build', callback=build_handler, metavar=u"f1,f2,…|b^min..max",
+              help="A sequence of decimation factors specied as "
+                   "comma-separated list of numbers or a base and range of "
+                   "exponents.")
+@click.option('--ls', help="Print the overviews for each band.",
+              is_flag=True, default=False)
+@click.option('--rebuild', help="Reconstruct existing overviews.",
+              is_flag=True, default=False)
+@click.option('--resampling', help="Resampling algorithm.",
+              type=click.Choice([item.name for item in Resampling]),
+              default='nearest', show_default=True)
+@click.pass_context
+def overview(ctx, input, build, ls, rebuild, resampling):
+    """Construct overviews in an existing dataset.
+
+    A pyramid of overviews computed once and stored in the dataset can
+    improve performance in some applications.
+
+    The decimation levels at which to build overviews can be specified as
+    a comma separated list
+
+      rio pyramid --build 2,4,8,16
+
+    or a base and range of exponents.
+
+      rio pyramid --build 2^1..4
+
+    Note that overviews can not currently be removed and are not 
+    automatically updated when the dataset's primary bands are
+    modified.
+
+    Information about existing overviews can be printed using the --ls
+    option.
+
+      rio pyramid --ls
+
+    """
+    verbosity = (ctx.obj and ctx.obj.get('verbosity')) or 1
+    logger = logging.getLogger('rio')
+
+    with rasterio.drivers(CPL_DEBUG=(verbosity > 2)) as env:
+        with rasterio.open(input, 'r+') as dst:
+
+            if ls:
+                resampling_method = dst.tags(
+                    ns='rio_overview').get('resampling') or 'unknown'
+
+                click.echo("Overview factors:")
+                for idx in dst.indexes:
+                    click.echo("  Band %d: %s (method: '%s')" % (
+                        idx, dst.overviews(idx) or 'None', resampling_method))
+
+            elif rebuild:
+                # Build the same overviews for all bands.
+                factors = reduce(
+                    operator.or_,
+                    [set(dst.overviews(i)) for i in dst.indexes])
+
+                # Attempt to recover the resampling method from dataset tags.
+                resampling_method = dst.tags(
+                    ns='rio_overview').get('resampling') or resampling
+
+                dst.build_overviews(
+                    list(factors), Resampling[resampling_method])
+
+            elif build:
+                dst.build_overviews(build, Resampling[resampling])
+
+                # Save the resampling method to a tag.
+                dst.update_tags(ns='rio_overview', resampling=resampling)
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -240,6 +240,7 @@ def copy_data_tree(datadir, destdir):
         insp=rasterio.rio.info:insp
         mask=rasterio.rio.features:mask
         merge=rasterio.rio.merge:merge
+        overview=rasterio.rio.overview:overview
         rasterize=rasterio.rio.features:rasterize
         sample=rasterio.rio.sample:sample
         shapes=rasterio.rio.features:shapes


Test patch:
diff --git a/tests/data/RGB.byte.tif b/tests/data/RGB.byte.tif
Binary files a/tests/data/RGB.byte.tif and b/tests/data/RGB.byte.tif differ
diff --git a/tests/test_overviews.py b/tests/test_overviews.py
new file mode 100644
--- /dev/null
+++ b/tests/test_overviews.py
@@ -0,0 +1,40 @@
+"""Tests of overview counting and creation."""
+
+import logging
+import sys
+
+from click.testing import CliRunner
+
+import rasterio
+from rasterio.enums import Resampling
+
+
+logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
+
+
+def test_count_overviews_zero(data):
+    inputfile = str(data.join('RGB.byte.tif'))
+    with rasterio.open(inputfile) as src:
+        assert src.overviews(1) == []
+        assert src.overviews(2) == []
+        assert src.overviews(3) == []
+
+
+def test_build_overviews_one(data):
+    inputfile = str(data.join('RGB.byte.tif'))
+    with rasterio.open(inputfile, 'r+') as src:
+        overview_factors = [2]
+        src.build_overviews(overview_factors, resampling=Resampling.nearest)
+        assert src.overviews(1) == [2]
+        assert src.overviews(2) == [2]
+        assert src.overviews(3) == [2]
+
+
+def test_build_overviews_two(data):
+    inputfile = str(data.join('RGB.byte.tif'))
+    with rasterio.open(inputfile, 'r+') as src:
+        overview_factors = [2, 4]
+        src.build_overviews(overview_factors, resampling=Resampling.nearest)
+        assert src.overviews(1) == [2, 4]
+        assert src.overviews(2) == [2, 4]
+        assert src.overviews(3) == [2, 4]
diff --git a/tests/test_rio_overview.py b/tests/test_rio_overview.py
new file mode 100644
--- /dev/null
+++ b/tests/test_rio_overview.py
@@ -0,0 +1,67 @@
+import logging
+import sys
+
+from click.testing import CliRunner
+
+import rasterio
+from rasterio.rio.main import main_group as cli
+
+
+logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
+
+
+def test_err(data):
+    runner = CliRunner()
+    inputfile = str(data.join('RGB.byte.tif'))
+    result = runner.invoke(cli, ['overview', inputfile, '--build', 'a^2'])
+    assert result.exit_code == 2
+    assert "must match" in result.output
+
+
+def test_ls_none(data):
+    runner = CliRunner()
+    inputfile = str(data.join('RGB.byte.tif'))
+    result = runner.invoke(cli, ['overview', inputfile, '--ls'])
+    assert result.exit_code == 0
+    expected = "Overview factors:\n  Band 1: None (method: 'unknown')\n  Band 2: None (method: 'unknown')\n  Band 3: None (method: 'unknown')\n"
+    assert result.output == expected
+
+
+def test_build_ls(data):
+    runner = CliRunner()
+    inputfile = str(data.join('RGB.byte.tif'))
+    result = runner.invoke(cli, ['overview', inputfile, '--build', '2,4,8'])
+    assert result.exit_code == 0
+    result = runner.invoke(cli, ['overview', inputfile, '--ls'])
+    assert result.exit_code == 0
+    expected = "  Band 1: [2, 4, 8] (method: 'nearest')\n  Band 2: [2, 4, 8] (method: 'nearest')\n  Band 3: [2, 4, 8] (method: 'nearest')\n"
+    assert result.output.endswith(expected)
+
+
+def test_build_pow_ls(data):
+    runner = CliRunner()
+    inputfile = str(data.join('RGB.byte.tif'))
+    result = runner.invoke(cli, ['overview', inputfile, '--build', '2^1..3'])
+    assert result.exit_code == 0
+    result = runner.invoke(cli, ['overview', inputfile, '--ls'])
+    assert result.exit_code == 0
+    expected = "  Band 1: [2, 4, 8] (method: 'nearest')\n  Band 2: [2, 4, 8] (method: 'nearest')\n  Band 3: [2, 4, 8] (method: 'nearest')\n"
+    assert result.output.endswith(expected)
+
+
+def test_rebuild_ls(data):
+    runner = CliRunner()
+    inputfile = str(data.join('RGB.byte.tif'))
+
+    result = runner.invoke(cli,
+        ['overview', inputfile, '--build', '2,4,8', '--resampling', 'cubic'])
+    assert result.exit_code == 0
+
+    result = runner.invoke(cli, ['overview', inputfile, '--rebuild'])
+    assert result.exit_code == 0
+
+    result = runner.invoke(cli, ['overview', inputfile, '--ls'])
+    assert result.exit_code == 0
+
+    expected = "  Band 1: [2, 4, 8] (method: 'cubic')\n  Band 2: [2, 4, 8] (method: 'cubic')\n  Band 3: [2, 4, 8] (method: 'cubic')\n"
+    assert result.output.endswith(expected)