├── docs ├── bands.png ├── tmux.png ├── trump.png ├── bigimg.png ├── colors.png └── subset.png ├── .gitignore ├── README.md └── bv /docs/bands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daleroberts/bv/HEAD/docs/bands.png -------------------------------------------------------------------------------- /docs/tmux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daleroberts/bv/HEAD/docs/tmux.png -------------------------------------------------------------------------------- /docs/trump.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daleroberts/bv/HEAD/docs/trump.png -------------------------------------------------------------------------------- /docs/bigimg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daleroberts/bv/HEAD/docs/bigimg.png -------------------------------------------------------------------------------- /docs/colors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daleroberts/bv/HEAD/docs/colors.png -------------------------------------------------------------------------------- /docs/subset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daleroberts/bv/HEAD/docs/subset.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | .DS_Store 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | **bv** is a small tool to quickly view high-resolution multi-band imagery 3 | directly in your [iTerm 2](https://www.iterm2.com). It was designed for 4 | visualising very large images located on a remote machine over a low-bandwidth 5 | connection. It subsamples and compresses the image sends it over the wire as a 6 | base64-encoded PNG (hence the name "bv") that iTerm 2 inlines in your terminal. 7 | 8 | 9 | 10 | Now, go and compare the above to [old-school rendering](https://camo.githubusercontent.com/a6c791a0b4d97315d00b6592f918fe744abe00e6/687474703a2f2f692e696d6775722e636f6d2f556e666e704d722e706e67) 11 | or my other tool [tv](https://github.com/daleroberts/tv). Welcome to 2017! 12 | 13 | # Some Examples 14 | 15 | Here are a number of examples that show how this tool can be used. 16 | 17 | ## Big image over small connection 18 | 19 | Display a 3.5 billion pixel single-band image (3.3GB) using only 467KB over a SSH connection. 20 | 21 | 22 | 23 | ## Different band combinations 24 | 25 | Display a six-band image (7.2GB) using only 1.1MB over a SSH connection. Here, 26 | we put bands 5-4-3 into the RGB channels using `-b 5 -b 4 -b 3` (ordering 27 | matters) and set the width of the output image to be 600 pixels using `-w 600`. 28 | 29 | 30 | 31 | You can also specify a single band to display (e.g., `-b 1`). 32 | 33 | ## Subset images 34 | 35 | You can subset images using `gdal_translate` syntax which is `-srcwin xoff yoff 36 | xsize ysize`. For example, only displaying a small 1000x1000 area of the same large image above. 37 | 38 | 39 | 40 | This allows you to quickly identify regions of your image and then paste the same options 41 | into `gdal_translate` to complete your desired workflow. For example: 42 | ``` 43 | remote$ gdal_translate tasmania-2014.tif -b 5 -b 4 -b 3 -srcwin 12000 11000 1000 1000 -of PNG -ot UInt16 -scale 0 4000 ~/out.png 44 | Input file size is 20000, 16000 45 | 0...10...20...30...40...50...60...70...80...90...100 - done. 46 | remote$ 47 | ``` 48 | 49 | ## Machine learning multi-class outputs with different color maps 50 | 51 | Sometimes you might have a single-band image that only contains classes 52 | (integers). Different color maps can be applied to these single-band images 53 | using the `-cm` option and any choice from [matplotlib's 54 | colormaps](http://matplotlib.org/examples/color/colormaps_reference.html). 55 | 56 | 57 | 58 | ## URLs 59 | 60 | The **bv** tool can read from URLs (see the Trump image above). It can also 61 | parse URLs on `stdin`, this allows you to [do 62 | things](https://github.com/developmentseed/landsat-util) like this to quicky 63 | display available Landsat images roughly over Dubai. 64 | 65 | ``` 66 | remote$ landsat search --lat 25 --lon 55 --latest 3 | bv -urls - 67 | ``` 68 | 69 | ## Standard Input 70 | 71 | Filenames can be read from `stdin`. For example: 72 | ``` 73 | ls -1 *.tif | bv -w 100 - 74 | ``` 75 | 76 | ## Compression 77 | 78 | The level of compression can be changed using the `-zlevel` option (0-9). 79 | 80 | ## Stacking images 81 | 82 | If your bands are located in seperate images then you can stack them and display them 83 | in the RGB channels using 84 | ``` 85 | bv -stack RED.tif GREEN.tif BLUE.tif 86 | ``` 87 | 88 | There is also the `-revstack` option to do it in reverse order. 89 | 90 | ## Subsampling algorithm 91 | 92 | The subsampling algorithm can be changed using the `-r` option (same syntax as GDAL). The available subsamplings are: 93 | - Nearest 94 | - Average 95 | - Cubic Spline 96 | - Cubic 97 | - Mode 98 | - Lanczos 99 | - Bilinear 100 | 101 | ## Alpha channel 102 | 103 | For single-band images, you can specify the color value to set as the alpha 104 | channel. This is sometimes useful for machine learning outputs where you want 105 | to not display certain classes. You can add multiple of these with different 106 | values. 107 | 108 | ## PDF, EPS, and PNG 109 | 110 | The **bv** tool will display PDF, EPS, and PNG output inline with out any 111 | changes to those files. If you want to disable this behaviour you can pass the 112 | `-nop` option allow GDAL to subsample, etc. 113 | 114 | ## TMUX Support 115 | 116 | 117 | 118 | # Configuration 119 | 120 | You can save your default configuration by setting an alias in your `~/.profile` file. For example, I do: 121 | ``` 122 | alias bv='bv -w 800' 123 | ``` 124 | 125 | # Installation 126 | 127 | It is just a single-file script so all you'll need to do it put it in your 128 | `PATH`. Dependencies are Python 3, GDAL 2, Numpy, Matplotlib, and iTerm 2. I've 129 | found that the best way to install these dependencies are: 130 | ```bash 131 | # Python 3 132 | brew install python3 133 | 134 | # Numpy and matplotlib 135 | pip3 install numpy matplotlib 136 | 137 | # GDAL 2 138 | brew install gdal --HEAD --without-python 139 | pip3 install gdal 140 | ``` 141 | -------------------------------------------------------------------------------- /bv: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | bv: Quickly view hyperspectral imagery, satellite imagery, and 4 | machine learning image outputs directly in your iTerm2 terminal. 5 | 6 | Dale Roberts 7 | 8 | http://www.github.com/daleroberts/bv 9 | """ 10 | import numpy as np 11 | import shutil 12 | import gdal 13 | import sys 14 | import os 15 | import re 16 | 17 | from urllib.request import urlopen, URLError 18 | from os.path import splitext 19 | from base64 import b64encode 20 | from uuid import uuid4 21 | 22 | gdal.UseExceptions() 23 | 24 | SAMPLING = {'nearest': gdal.GRIORA_NearestNeighbour, 25 | 'bilinear': gdal.GRIORA_Bilinear, 26 | 'cubic': gdal.GRIORA_Cubic, 27 | 'cubicspline': gdal.GRIORA_Cubic, 28 | 'lanczos': gdal.GRIORA_Lanczos, 29 | 'average': gdal.GRIORA_Average, 30 | 'mode': gdal.GRIORA_Mode} 31 | RE_URL = 'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+' 32 | TMUX = os.getenv('TERM','').startswith('screen') 33 | 34 | def sizefmt(num, suffix='B'): 35 | for unit in ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z']: 36 | if abs(num) < 1024.0: 37 | return '%3.1f%s%s' % (num, unit, suffix) 38 | num /= 1024.0 39 | return '%.1f%s%s' % (num, 'Yi', suffix) 40 | 41 | 42 | def typescale(data, dtype=np.uint8, scale=None): 43 | typeinfo = np.iinfo(dtype) 44 | low, high = typeinfo.min, typeinfo.max 45 | if scale: 46 | cmin, cmax = scale 47 | else: 48 | cmin, cmax = np.min(data), np.max(data) 49 | cscale = cmax - cmin 50 | scale = float(high - low) / cscale 51 | typedata = (data * 1.0 - cmin) * scale + 0.4999 52 | with np.errstate(all='ignore'): 53 | typedata[typedata < low] = low 54 | typedata[typedata > high] = high 55 | return typedata.astype(dtype) + np.cast[dtype](low) 56 | 57 | 58 | def imgcat(data, lines=-1): 59 | if TMUX: 60 | if lines == -1: 61 | lines = 10 62 | osc = b'\033Ptmux;\033\033]' 63 | st = b'\a\033\\' 64 | else: 65 | osc = b'\033]' 66 | st = b'\a' 67 | csi = b'\033[' 68 | buf = bytes() 69 | if lines > 0: 70 | buf += lines*b'\n' + csi + b'?25l' + csi + b'%dF' % lines + osc 71 | dims = b'width=auto;height=%d;preserveAspectRatio=1' % lines 72 | else: 73 | buf += osc 74 | dims = b'width=auto;height=auto' 75 | buf += b'1337;File=;size=%d;inline=1;' % len(data) + dims + b':' 76 | buf += b64encode(data) + st 77 | if lines > 0: 78 | buf += csi + b'%dE' % lines + csi + b'?25h' 79 | sys.stdout.buffer.write(buf) 80 | sys.stdout.flush() 81 | print() 82 | 83 | 84 | def show(rbs, xoff, yoff, ow, oh, w=500, h=500, r='average', zlevel=1, 85 | cm='bone', alpha=None, scale=None, quiet=None, lines=-1): 86 | memdriver = gdal.GetDriverByName('MEM') 87 | if len(rbs) == 1: 88 | if alpha is None: 89 | md = memdriver.Create('', w, h, 3, gdal.GDT_UInt16) 90 | else: 91 | md = memdriver.Create('', w, h, 4, gdal.GDT_UInt16) 92 | bnd = rbs[0].ReadAsArray(xoff, yoff, ow, oh, buf_xsize=w, 93 | buf_ysize=h, resample_alg=SAMPLING[r]) 94 | try: 95 | import matplotlib.cm as cms 96 | cm = getattr(cms, cm) 97 | except AttributeError: 98 | print('incorrect colormap, defaulting to "bone"') 99 | cm = getattr(cms, 'bone') 100 | dmin, dmax = bnd.min(), bnd.max() 101 | bnds = cm((bnd - dmin) / (dmax - dmin)) 102 | for i in range(3): 103 | obnd = md.GetRasterBand(i + 1) 104 | obnd.WriteArray(typescale(bnds[:, :, i], np.uint16), 0, 0) 105 | if alpha is not None: 106 | obnd = md.GetRasterBand(4) 107 | mask = np.logical_and.reduce([bnd != n for n in alpha]) 108 | obnd.WriteArray((65535 * mask).astype(np.uint16), 0, 0) 109 | obnd.SetColorInterpretation(gdal.GCI_AlphaBand) 110 | else: 111 | if len(rbs) == 4 or alpha is not None: # RGBA 112 | md = memdriver.Create('', w, h, 4, gdal.GDT_UInt16) 113 | else: # RGB 114 | md = memdriver.Create('', w, h, 3, gdal.GDT_UInt16) 115 | rbs = rbs[:3] 116 | for i, b in enumerate(rbs): 117 | bnd = b.ReadAsArray(xoff, yoff, ow, oh, buf_xsize=w, 118 | buf_ysize=h, resample_alg=SAMPLING[r]) 119 | obnd = md.GetRasterBand(i + 1) 120 | obnd.WriteArray(typescale(bnd, np.uint16, scale), 0, 0) 121 | if i == 3: # alpha 122 | obnd.SetColorInterpretation(gdal.GCI_AlphaBand) 123 | if alpha is not None: 124 | obnd = md.GetRasterBand(4) 125 | mask = np.logical_and.reduce([bnd != n for n in alpha]) 126 | obnd.WriteArray((65535 * mask).astype(np.uint16), 0, 0) 127 | obnd.SetColorInterpretation(gdal.GCI_AlphaBand) 128 | 129 | if zlevel is None: 130 | zlevel = 'ZLEVEL=1' 131 | else: 132 | zlevel = 'ZLEVEL={}'.format(zlevel) 133 | 134 | mmapfn = "/vsimem/" + uuid4().hex 135 | driver = gdal.GetDriverByName('PNG') 136 | fd = driver.CreateCopy(mmapfn, md, 0, [zlevel]) 137 | 138 | size = gdal.VSIStatL(mmapfn, gdal.VSI_STAT_SIZE_FLAG).size 139 | fd = gdal.VSIFOpenL(mmapfn, 'rb') 140 | data = gdal.VSIFReadL(1, size, fd) 141 | gdal.VSIFCloseL(fd) 142 | 143 | imgcat(data, lines) 144 | 145 | gdal.Unlink(mmapfn) 146 | 147 | return size 148 | 149 | 150 | def show_stacked(imgs, *args, **kwargs): 151 | b = kwargs.pop('b') 152 | 153 | fds = [gdal.Open(fd) for fd in imgs[:3]] 154 | rbs = [fd.GetRasterBand(1) for fd in fds] 155 | 156 | quiet = kwargs.pop('quiet') 157 | srcwin = kwargs.pop('srcwin') 158 | if srcwin is not None: 159 | xoff, yoff, ow, oh = srcwin 160 | else: 161 | xoff, yoff, ow, oh = 0, 0, fds[0].RasterXSize, fds[0].RasterYSize 162 | 163 | kwargs['h'] = int(oh / ow * kwargs['w']) 164 | 165 | size = show(rbs, xoff, yoff, ow, oh, **kwargs) 166 | 167 | fd = fds[0] 168 | geo = fd.GetGeoTransform() 169 | if not quiet: 170 | desc = '{}x{} pixels / {} bands. [tfr: {}]' 171 | print(desc.format(fd.RasterYSize, fd.RasterXSize, 172 | fd.RasterCount, sizefmt(size))) 173 | 174 | 175 | def show_fd(fd, *args, **kwargs): 176 | b = kwargs.pop('b') 177 | rc = fd.RasterCount 178 | 179 | if rc == 1: 180 | rbs = [fd.GetRasterBand(1)] 181 | else: 182 | if b is None: 183 | if rc == 4: 184 | b = range(1, 5) 185 | else: 186 | b = range(1, 4) 187 | rbs = [fd.GetRasterBand(i) for i in b] 188 | 189 | srcwin = kwargs.pop('srcwin') 190 | if srcwin is not None: 191 | xoff, yoff, ow, oh = srcwin 192 | else: 193 | xoff, yoff, ow, oh = 0, 0, fd.RasterXSize, fd.RasterYSize 194 | 195 | kwargs['h'] = int(oh / ow * kwargs['w']) 196 | 197 | return show(rbs, xoff, yoff, ow, oh, **kwargs) 198 | 199 | 200 | def show_fn(fn, *args, **kwargs): 201 | try: 202 | quiet = kwargs.pop('quiet') 203 | fd = gdal.Open(fn) 204 | size = show_fd(fd, *args, **kwargs) 205 | geo = fd.GetGeoTransform() 206 | if not quiet: 207 | desc = '{}x{} pixels / {} bands. [tfr: {}]' 208 | print(desc.format(fd.RasterYSize, fd.RasterXSize, 209 | fd.RasterCount, sizefmt(size))) 210 | except RuntimeError as e: 211 | print('Error:', e) 212 | sys.exit(1) 213 | except TypeError: 214 | print('Error: bad data. incorrect srcwin?') 215 | sys.exit(1) 216 | 217 | 218 | def show_url(url, *args, **kwargs): 219 | try: 220 | urlfd = urlopen(url, timeout=15) 221 | mmapfn = "/vsimem/" + uuid4().hex 222 | gdal.FileFromMemBuffer(mmapfn, urlfd.read()) 223 | return show_fd(gdal.Open(mmapfn), *args, **kwargs) 224 | except URLError as e: 225 | print(e) 226 | finally: 227 | gdal.Unlink(mmapfn) 228 | 229 | 230 | if __name__ == '__main__': 231 | import argparse 232 | parser = argparse.ArgumentParser() 233 | parser.add_argument('-w', type=int, default=800) 234 | parser.add_argument('-b', action='append', type=int) 235 | parser.add_argument('-r', choices=SAMPLING.keys(), default='nearest') 236 | parser.add_argument('-cm', default="bone") 237 | parser.add_argument('-zlevel', type=int) 238 | parser.add_argument('-scale', nargs=2, type=float, metavar=('minval', 'maxval')) 239 | parser.add_argument('-alpha', action='append', type=int) 240 | parser.add_argument('-quiet', action='store_true') 241 | parser.add_argument('-stack', action='store_true') 242 | parser.add_argument('-revstack', action='store_true') 243 | parser.add_argument('-urls', action='store_true') 244 | parser.add_argument('-nofn', action='store_true') 245 | parser.add_argument('-nopassthrough', action='store_true') 246 | parser.add_argument('-lines', type=int, default=-1) 247 | parser.add_argument('-srcwin', nargs=4, 248 | metavar=('xoff', 'yoff', 'xsize', 'ysize'), 249 | type=int) 250 | parser.add_argument('img', nargs='+') 251 | kwargs = vars(parser.parse_args()) 252 | 253 | imgs = kwargs.pop('img') 254 | urls = kwargs.pop('urls') 255 | nofn = kwargs.pop('nofn') or (imgs[0] != '-' and len(imgs) == 1) 256 | stack = kwargs.pop('stack') 257 | revstack = kwargs.pop('revstack') 258 | nop = kwargs.pop('nopassthrough') 259 | 260 | if TMUX: 261 | # dirty hack to make tmux integration work 262 | kwargs['w'] = min(kwargs['w'], 370) 263 | 264 | try: 265 | if not sys.stdin.isatty() or imgs[0] == '-': 266 | imgs = [line.strip() for line in sys.stdin.readlines()] 267 | 268 | if stack: 269 | show_stacked(imgs, **kwargs) 270 | sys.exit(0) 271 | 272 | if revstack: 273 | show_stacked(list(reversed(imgs)), **kwargs) 274 | sys.exit(0) 275 | 276 | for img in imgs: 277 | if urls: 278 | for url in re.findall(RE_URL, img): 279 | if not nofn: 280 | print(url) 281 | show_url(url, **kwargs) 282 | else: 283 | if not nofn: 284 | print(img) 285 | if not nop and splitext(img)[1][1:].lower() in ['png', 'pdf', 'eps']: 286 | with open(img, 'rb') as fd: 287 | data = fd.read() 288 | imgcat(data, kwargs.pop('lines', -1)) 289 | else: 290 | show_fn(img, **kwargs) 291 | 292 | except KeyboardInterrupt: 293 | pass 294 | 295 | finally: 296 | print() 297 | --------------------------------------------------------------------------------