├── 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 |
--------------------------------------------------------------------------------