Repository: rasterio/rasterio

Issue:
Add a function to easily display a matotlib histogram in rio-insp
See #455 for background 

Might be useful to surface this both in `rio insp` and as `rasterio.show_histogram()`.



Discussion:
:+1: to developing the bulk of the functionality outside the CLI (see also #453).


Resolving patch:
diff --git a/rasterio/tool.py b/rasterio/tool.py
--- a/rasterio/tool.py
+++ b/rasterio/tool.py
@@ -11,6 +11,7 @@
 import numpy
 
 import rasterio
+from rasterio.five import zip_longest
 
 
 logger = logging.getLogger('rasterio')
@@ -48,6 +49,76 @@ def stats(source):
     return Stats(numpy.min(arr), numpy.max(arr), numpy.mean(arr))
 
 
+def show_hist(source, bins=10, masked=True, title='Histogram'):
+
+    """
+    Easily display a histogram with matplotlib.
+
+    Parameters
+    ----------
+    bins : int, optional
+        Compute histogram across N bins.
+    data : np.array or rasterio.Band or tuple(dataset, bidx)
+        Input data to display.  The first three arrays in multi-dimensional
+        arrays are plotted as red, green, and blue.
+    masked : bool, optional
+        When working with a `rasterio.Band()` object, specifies if the data
+        should be masked on read.
+    title : str, optional
+        Title for the figure.
+    """
+
+    if plt is None:
+        raise ImportError("Could not import matplotlib")
+
+    if isinstance(source, (tuple, rasterio.Band)):
+        arr = source[0].read(source[1], masked=masked)
+    else:
+        arr = source
+
+    # The histogram is computed individually for each 'band' in the array
+    # so we need the overall min/max to constrain the plot
+    rng = arr.min(), arr.max()
+
+    if len(arr.shape) is 2:
+        arr = [arr]
+        colors = ['gold']
+    else:
+        colors = ('red', 'green', 'blue', 'violet', 'gold', 'saddlebrown')
+
+    # If a rasterio.Band() is given make sure the proper index is displayed
+    # in the legend.
+    if isinstance(source, (tuple, rasterio.Band)):
+        labels = [str(source[1])]
+    else:
+        labels = (str(i + 1) for i in range(len(arr)))
+
+    # This loop should add a single plot each band in the input array,
+    # regardless of if the number of bands exceeds the number of colors.
+    # The colors slicing ensures that the number of iterations always
+    # matches the number of bands.
+    # The goal is to provide a curated set of colors for working with
+    # smaller datasets and let matplotlib define additional colors when
+    # working with larger datasets.
+    for bnd, color, label in zip_longest(arr, colors[:len(arr)], labels):
+
+        plt.hist(
+            bnd.flatten(),
+            bins=bins,
+            alpha=0.5,
+            color=color,
+            label=label,
+            range=rng
+        )
+
+    plt.legend(loc="upper right")
+    plt.title(title, fontweight='bold')
+    plt.grid(True)
+    plt.xlabel('DN')
+    plt.ylabel('Frequency')
+    plt.show()
+
+
 def main(banner, dataset, alt_interpreter=None):
     """ Main entry point for use with python interpreter """
     local = dict(funcs, src=dataset, np=numpy, rio=rasterio, plt=plt)


Test patch:
diff --git a/tests/test_tool.py b/tests/test_tool.py
--- a/tests/test_tool.py
+++ b/tests/test_tool.py
@@ -6,7 +6,7 @@
     plt = None
 
 import rasterio
-from rasterio.tool import show, stats
+from rasterio.tool import show, show_hist, stats
 
 
 def test_stats():
@@ -42,3 +42,24 @@ def test_show():
                 show(src.read(1))
             except ImportError:
                 pass
+
+
+def test_show_hist():
+    """
+    This test only verifies that code up to the point of plotting with
+    matplotlib works correctly.  Tests do not exercise matplotlib.
+    """
+    if plt:
+        # Return because plotting causes the tests to block until the plot
+        # window is closed.
+        return
+    with rasterio.open('tests/data/RGB.byte.tif') as src:
+        try:
+            show_hist((src, 1), bins=256)
+        except ImportError:
+            pass
+
+        try:
+            show_hist(src.read(), bins=256)
+        except ImportError:
+            pass