├── .gitignore ├── README.md ├── example ├── file_mosaic.png ├── flickr_mosaic.jpg ├── images │ ├── image1.png │ ├── image2.png │ ├── image3.png │ ├── image4.png │ ├── image5.png │ ├── image6.png │ ├── image7.png │ ├── image8.png │ └── image9.png ├── mosaic_files.js ├── mosaic_flickr.js └── mosaic_map.js ├── index.js ├── lib ├── pixel-multi-stream.js └── stitch-image-stream.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | example/map_mosaic.jpg 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mosaic-image-stream 2 | 3 | [![npm](https://img.shields.io/npm/v/mosaic-image-stream.svg)](https://www.npmjs.com/package/mosaic-image-stream) 4 | [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?maxAge=2592000)](http://standardjs.com/) 5 | 6 | > Streaming mosaic of multiple images into a single image 7 | 8 | Take a 2-D array of input [`pixel-streams`](https://github.com/devongovett/pixel-stream) and mosaic them together into a single image. Everything is streams, so you can theoretically mosaic thousands of images into a megapixel image without much memory usage. 9 | 10 | ## Table of Contents 11 | 12 | - [Installation](#installation) 13 | - [Usage](#usage) 14 | - [API](#api) 15 | - [Contribute](#contribute) 16 | - [License](#license) 17 | 18 | ## Install 19 | 20 | ``` 21 | npm i mosaic-image-stream 22 | ``` 23 | 24 | ## Usage 25 | 26 | See [examples](./example). 27 | 28 | Mosaic local file streams: 29 | 30 | ```js 31 | var fs = require('fs') 32 | var path = require('path') 33 | var PNGDecoder = require('png-stream/decoder') 34 | var PNGEncoder = require('png-stream/encoder') 35 | 36 | var Mosaic = require('../') 37 | 38 | var baseName = path.join(__dirname, 'images/image') 39 | 40 | var streams = [ 41 | [ 42 | fs.createReadStream(baseName + '1.png').pipe(new PNGDecoder()), 43 | fs.createReadStream(baseName + '2.png').pipe(new PNGDecoder()), 44 | fs.createReadStream(baseName + '3.png').pipe(new PNGDecoder()) 45 | ], 46 | [ 47 | fs.createReadStream(baseName + '4.png').pipe(new PNGDecoder()), 48 | fs.createReadStream(baseName + '5.png').pipe(new PNGDecoder()), 49 | fs.createReadStream(baseName + '6.png').pipe(new PNGDecoder()) 50 | ], 51 | [ 52 | fs.createReadStream(baseName + '7.png').pipe(new PNGDecoder()), 53 | fs.createReadStream(baseName + '8.png').pipe(new PNGDecoder()), 54 | fs.createReadStream(baseName + '9.png').pipe(new PNGDecoder()) 55 | ] 56 | ] 57 | 58 | Mosaic(streams, {height: 300}) 59 | .pipe(new PNGEncoder()) 60 | .pipe(fs.createWriteStream(path.join(__dirname, 'file_mosaic.png'))) 61 | ``` 62 | 63 | The giant wallmap of the Middle East you've always wanted (17,000px x 17,000px): 64 | 65 | ```js 66 | var fs = require('fs') 67 | var path = require('path') 68 | var request = require('request') 69 | var JPGDecoder = require('jpg-stream/decoder') 70 | var JPGEncoder = require('jpg-stream/encoder') 71 | var tilebelt = require('tilebelt') 72 | 73 | var MAPBOX_TOKEN = 'pk.eyJ1IjoiZ21hY2xlbm5hbiIsImEiOiJSaWVtd2lRIn0.ASYMZE2HhwkAw4Vt7SavEg' 74 | var urlBase = 'https://api.mapbox.com/v4/mapbox.streets/' 75 | 76 | var Mosaic = require('../') 77 | 78 | var zoom = 8 79 | var tl = tilebelt.pointToTile(24, 48, zoom) 80 | var br = tilebelt.pointToTile(72, 9, zoom) 81 | 82 | var size = [br[0] - tl[0] + 1, br[1] - tl[1] + 1] 83 | var factories = Array(size[0]).fill().map((v, i) => { 84 | var count = 0 85 | return function (cb) { 86 | var x = tl[0] + i 87 | var y = tl[1] + count 88 | if (++count > size[1]) return cb(null, null) 89 | var url = urlBase + zoom + '/' + x + '/' + y + '@2x.jpg?access_token=' + MAPBOX_TOKEN 90 | cb(null, request(url).pipe(new JPGDecoder()).on('error', err => { 91 | console.error(url) 92 | console.error(err) 93 | })) 94 | } 95 | }) 96 | 97 | Mosaic(factories, size[1] * 512) 98 | .on('error', console.error) 99 | .pipe(new JPGEncoder()) 100 | .pipe(fs.createWriteStream(path.join(__dirname, 'map_mosaic.jpg'))) 101 | ``` 102 | 103 | Mosaic a whole bunch of images from Flickr: 104 | 105 | ```js 106 | var fs = require('fs') 107 | var path = require('path') 108 | var request = require('request') 109 | var JPEGDecoder = require('jpg-stream/decoder') 110 | var JPEGEncoder = require('jpg-stream/encoder') 111 | 112 | var Mosaic = require('../') 113 | 114 | var reqUrl = 'https://api.flickr.com/services/rest/?' + 115 | 'method=flickr.photos.search&' + 116 | 'api_key=ea621d507593aa247dcaa792268b93d7&' + 117 | 'tags=portrait&' + 118 | 'sort=interestingness-desc&' + 119 | 'media=photos&' + 120 | 'extras=url_q&' + 121 | 'format=json&' + 122 | 'nojsoncallback=1&' + 123 | 'per_page=500' 124 | 125 | // One of the images Flickr returns does not have a height of 150px, even though the Flickr API thinks it does 126 | var badUrl = 'https://farm2.staticflickr.com/1554/24516806801_084046c4dc_q.jpg' 127 | 128 | var size = [15, 15] 129 | 130 | request(reqUrl, function (err, resp, body) { 131 | if (err) return console.error(err) 132 | var urls = JSON.parse(body).photos.photo 133 | .map(d => d.url_q) 134 | .filter(d => d !== badUrl) 135 | var factories = Array(size[0]).fill().map((v, i) => { 136 | var count = 0 137 | return function (cb) { 138 | var url = urls[count + i * size[1]] 139 | if (++count > size[1]) return cb(null, null) 140 | cb(null, request(url).pipe(new JPEGDecoder())) 141 | } 142 | }) 143 | 144 | Mosaic(factories, {height: size[1] * 150}) 145 | .on('error', console.error) 146 | .pipe(new JPEGEncoder()) 147 | .pipe(fs.createWriteStream(path.join(__dirname, 'flickr_mosaic.jpg'))) 148 | }) 149 | ``` 150 | 151 | Input streams must be [`pixel-streams`](https://github.com/devongovett/pixel-stream). If you want to stream raw image data, stream it through a `pixel-stream` constructed with your image width, height and color space: 152 | 153 | ```js 154 | myRawImageStream.pipe(new PixelStream(myImageWidth, myImageHeight, {colorSpace: myImageColorSpace})) 155 | ``` 156 | 157 | ## API 158 | 159 | ```js 160 | var Mosaic = require('mosaic-image-stream') 161 | ``` 162 | 163 | ### Mosaic(streams, height) 164 | 165 | Where: 166 | 167 | - `streams` - a 2-d array of input stream, columns, then rows: e.g. `streams = [[row1col1, row2col2], [row1col2, row2col2]]` 168 | - `height` - the total height of the input streams (width can be calculated on the fly from input images) 169 | 170 | All input streams *must* have the same dimensions and color space - the output stream will throw an error if they differ, or if the total height of the input streams does not match the height specified. 171 | 172 | Returns a [`pixel-stream`](https://github.com/devongovett/pixel-stream). 173 | 174 | ## Contribute 175 | 176 | PRs accepted. 177 | 178 | Small note: If editing the Readme, please conform to the [standard-readme](https://github.com/RichardLitt/standard-readme) specification. 179 | 180 | ## License 181 | 182 | MIT © Gregor MacLennan / Digital Democracy 183 | -------------------------------------------------------------------------------- /example/file_mosaic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digidem/mosaic-image-stream/0cae99e6c1fc2c6b2d65a7fff7448aadc5c17417/example/file_mosaic.png -------------------------------------------------------------------------------- /example/flickr_mosaic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digidem/mosaic-image-stream/0cae99e6c1fc2c6b2d65a7fff7448aadc5c17417/example/flickr_mosaic.jpg -------------------------------------------------------------------------------- /example/images/image1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digidem/mosaic-image-stream/0cae99e6c1fc2c6b2d65a7fff7448aadc5c17417/example/images/image1.png -------------------------------------------------------------------------------- /example/images/image2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digidem/mosaic-image-stream/0cae99e6c1fc2c6b2d65a7fff7448aadc5c17417/example/images/image2.png -------------------------------------------------------------------------------- /example/images/image3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digidem/mosaic-image-stream/0cae99e6c1fc2c6b2d65a7fff7448aadc5c17417/example/images/image3.png -------------------------------------------------------------------------------- /example/images/image4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digidem/mosaic-image-stream/0cae99e6c1fc2c6b2d65a7fff7448aadc5c17417/example/images/image4.png -------------------------------------------------------------------------------- /example/images/image5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digidem/mosaic-image-stream/0cae99e6c1fc2c6b2d65a7fff7448aadc5c17417/example/images/image5.png -------------------------------------------------------------------------------- /example/images/image6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digidem/mosaic-image-stream/0cae99e6c1fc2c6b2d65a7fff7448aadc5c17417/example/images/image6.png -------------------------------------------------------------------------------- /example/images/image7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digidem/mosaic-image-stream/0cae99e6c1fc2c6b2d65a7fff7448aadc5c17417/example/images/image7.png -------------------------------------------------------------------------------- /example/images/image8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digidem/mosaic-image-stream/0cae99e6c1fc2c6b2d65a7fff7448aadc5c17417/example/images/image8.png -------------------------------------------------------------------------------- /example/images/image9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digidem/mosaic-image-stream/0cae99e6c1fc2c6b2d65a7fff7448aadc5c17417/example/images/image9.png -------------------------------------------------------------------------------- /example/mosaic_files.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var path = require('path') 3 | var PNGDecoder = require('png-stream/decoder') 4 | var PNGEncoder = require('png-stream/encoder') 5 | 6 | var Mosaic = require('../') 7 | 8 | var baseName = path.join(__dirname, 'images/image') 9 | 10 | var streams = [ 11 | [ 12 | fs.createReadStream(baseName + '1.png').pipe(new PNGDecoder()), 13 | fs.createReadStream(baseName + '2.png').pipe(new PNGDecoder()), 14 | fs.createReadStream(baseName + '3.png').pipe(new PNGDecoder()) 15 | ], 16 | [ 17 | fs.createReadStream(baseName + '4.png').pipe(new PNGDecoder()), 18 | fs.createReadStream(baseName + '5.png').pipe(new PNGDecoder()), 19 | fs.createReadStream(baseName + '6.png').pipe(new PNGDecoder()) 20 | ], 21 | [ 22 | fs.createReadStream(baseName + '7.png').pipe(new PNGDecoder()), 23 | fs.createReadStream(baseName + '8.png').pipe(new PNGDecoder()), 24 | fs.createReadStream(baseName + '9.png').pipe(new PNGDecoder()) 25 | ] 26 | ] 27 | 28 | Mosaic(streams, 300) 29 | .pipe(new PNGEncoder()) 30 | .pipe(fs.createWriteStream(path.join(__dirname, 'file_mosaic.png'))) 31 | -------------------------------------------------------------------------------- /example/mosaic_flickr.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var path = require('path') 3 | var request = require('request') 4 | var JPEGDecoder = require('jpg-stream/decoder') 5 | var JPEGEncoder = require('jpg-stream/encoder') 6 | 7 | var Mosaic = require('../') 8 | 9 | var reqUrl = 'https://api.flickr.com/services/rest/?' + 10 | 'method=flickr.photos.search&' + 11 | 'api_key=ea621d507593aa247dcaa792268b93d7&' + 12 | 'tags=portrait&' + 13 | 'sort=interestingness-desc&' + 14 | 'media=photos&' + 15 | 'extras=url_q&' + 16 | 'format=json&' + 17 | 'nojsoncallback=1&' + 18 | 'per_page=500' 19 | 20 | // One of the images Flickr returns does not have a height of 150px, even though the Flickr API thinks it does 21 | var badUrl = 'https://farm2.staticflickr.com/1554/24516806801_084046c4dc_q.jpg' 22 | 23 | var size = [15, 15] 24 | 25 | request(reqUrl, function (err, resp, body) { 26 | if (err) return console.error(err) 27 | var urls = JSON.parse(body).photos.photo 28 | .map(d => d.url_q) 29 | .filter(d => d !== badUrl) 30 | var factories = Array(size[0]).fill().map((v, i) => { 31 | var count = 0 32 | return function (cb) { 33 | var url = urls[count + i * size[1]] 34 | if (++count > size[1]) return cb(null, null) 35 | cb(null, request(url).pipe(new JPEGDecoder())) 36 | } 37 | }) 38 | 39 | Mosaic(factories, size[1] * 150) 40 | .on('error', console.error) 41 | .pipe(new JPEGEncoder()) 42 | .pipe(fs.createWriteStream(path.join(__dirname, 'flickr_mosaic.jpg'))) 43 | }) 44 | -------------------------------------------------------------------------------- /example/mosaic_map.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs') 2 | var path = require('path') 3 | var request = require('request') 4 | var JPGDecoder = require('jpg-stream/decoder') 5 | var JPGEncoder = require('jpg-stream/encoder') 6 | var tilebelt = require('@mapbox/tilebelt') 7 | 8 | var MAPBOX_TOKEN = 'pk.eyJ1IjoiZ21hY2xlbm5hbiIsImEiOiJSaWVtd2lRIn0.ASYMZE2HhwkAw4Vt7SavEg' 9 | var urlBase = 'https://api.mapbox.com/v4/mapbox.streets/' 10 | 11 | var Mosaic = require('../') 12 | 13 | var zoom = 8 14 | var tl = tilebelt.pointToTile(24, 48, zoom) 15 | var br = tilebelt.pointToTile(72, 9, zoom) 16 | 17 | var size = [br[0] - tl[0] + 1, br[1] - tl[1] + 1] 18 | var factories = Array(size[0]).fill().map((v, i) => { 19 | var count = 0 20 | return function (cb) { 21 | var x = tl[0] + i 22 | var y = tl[1] + count 23 | if (++count > size[1]) return cb(null, null) 24 | var url = urlBase + zoom + '/' + x + '/' + y + '@2x.jpg?access_token=' + MAPBOX_TOKEN 25 | cb(null, request(url).pipe(new JPGDecoder()).on('error', err => { 26 | console.error(url) 27 | console.error(err) 28 | })) 29 | } 30 | }) 31 | 32 | Mosaic(factories, size[1] * 512) 33 | .on('error', console.error) 34 | .pipe(new JPGEncoder()) 35 | .pipe(fs.createWriteStream(path.join(__dirname, 'map_mosaic.jpg'))) 36 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var StitchImageStream = require('./lib/stitch-image-stream') 2 | var PixelMultiStream = require('./lib/pixel-multi-stream') 3 | 4 | function MosaicStream (tiles, height) { 5 | if (!height) throw new Error('Must specify the height of the mosaic') 6 | var pixelStreams = tiles.map(streams => PixelMultiStream(streams, height)) 7 | return StitchImageStream(pixelStreams) 8 | } 9 | 10 | module.exports = MosaicStream 11 | -------------------------------------------------------------------------------- /lib/pixel-multi-stream.js: -------------------------------------------------------------------------------- 1 | var MultiStream = require('multistream') 2 | var inherits = require('inherits') 3 | 4 | /** 5 | * Takes an array of multiple [pixel-streams](https://github.com/devongovett/pixel-stream) 6 | * and returns a single pixel stream concatinating images vertically. 7 | * All input pixel-streams must be the same width and have the same colorSpace. 8 | * 9 | * @param {Array} streams Array of ReadableStream or functions that return a ReadableStream 10 | * @param {Number} height Total height of combined streams - must match actual height or will throw error 11 | * @param {Object} opts Passed to MultiStream with in turn passes these opts to ReadableStream 12 | */ 13 | function PixelMultiStream (streams, height, opts) { 14 | if (!(this instanceof PixelMultiStream)) return new PixelMultiStream(streams, height, opts) 15 | this.height = height 16 | MultiStream.call(this, streams, opts) 17 | } 18 | 19 | inherits(PixelMultiStream, MultiStream) 20 | 21 | /** 22 | * Wrap the `MultiStream.prototype._gotNextStream()` which is called for each new stream. 23 | * Reads the format from incoming streams and sets the format of the output stream 24 | * @param {ReadableStream} stream 25 | */ 26 | PixelMultiStream.prototype._gotNextStream = function (stream) { 27 | if (stream) { 28 | if (stream.format && stream.format.height) { 29 | onFormat.call(this, stream.format) 30 | } else { 31 | stream.on('format', onFormat.bind(this)) 32 | } 33 | } else if (this._actualHeight !== this.format.height) { 34 | this.emit('error', new Error('Total height of mosaiced images (' + this._actualHeight + 35 | 'px) did not match specified height (' + this.format.height + 'px)')) 36 | } 37 | MultiStream.prototype._gotNextStream.call(this, stream) 38 | } 39 | 40 | /** 41 | * When an incoming stream's format is available, read the width from the first stream and 42 | * ensure that subsequent streams share the same width and colorSpace. 43 | * @param {Object} format pixel-stream format object: `{ width, height, colorSpace }` 44 | */ 45 | function onFormat (format) { 46 | if (!this.format) { 47 | this.format = Object.assign({}, format, {height: this.height}) 48 | this._actualHeight = format.height 49 | this.emit('format', this.format) 50 | } else { 51 | if (this.format.width !== format.width) this.emit('error', new Error('input widths must be the same')) 52 | if (this.format.colorSpace !== format.colorSpace) this.emit('error', new Error('input color spaces must be the same')) 53 | this._actualHeight += format.height 54 | } 55 | } 56 | 57 | module.exports = PixelMultiStream 58 | -------------------------------------------------------------------------------- /lib/stitch-image-stream.js: -------------------------------------------------------------------------------- 1 | var block = require('block-stream2') 2 | var interleave = require('interleave-stream') 3 | var pumpify = require('pumpify') 4 | var BufferPeekStream = require('buffer-peek-stream').BufferPeekStream 5 | 6 | // color space component counts 7 | var COMPONENTS = { 8 | rgb: 3, 9 | rgba: 4, 10 | cmyk: 4, 11 | gray: 1, 12 | graya: 2, 13 | indexed: 1 14 | } 15 | 16 | /** 17 | * Stitches together multiple images horizontally. 18 | * @param {Array} streams Array of image pixel-streams to merge 19 | * @return {ReadableStream} 20 | */ 21 | module.exports = function StitchImageStream (streams) { 22 | var pending = streams.length 23 | var blockStreams = streams.map(createBlockStream) 24 | var outputFormat 25 | 26 | var mosaicedStream = interleave(blockStreams) 27 | var output = pumpify() 28 | return output 29 | 30 | function createBlockStream (stream, i) { 31 | var buffer = new BufferPeekStream({peekBytes: 2}) 32 | var blockStream = pumpify() 33 | stream.on('format', onFormat) 34 | stream.on('error', onError) 35 | stream.pipe(buffer) 36 | return blockStream 37 | 38 | function onFormat (inputFormat) { 39 | if (!outputFormat) { 40 | outputFormat = Object.assign({}, inputFormat) 41 | } else { 42 | if (inputFormat.height !== outputFormat.height) onError(new Error('input heights must be the same')) 43 | if (inputFormat.colorSpace !== outputFormat.colorSpace) onError(new Error('input color spaces must be the same')) 44 | outputFormat.width += inputFormat.width 45 | } 46 | var inputBlockSize = inputFormat.width * COMPONENTS[outputFormat.colorSpace] 47 | blockStream.setPipeline(buffer, block(inputBlockSize)) 48 | if (--pending === 0) { 49 | var outputBlockSize = outputFormat.width * COMPONENTS[outputFormat.colorSpace] 50 | output.emit('format', Object.assign({}, outputFormat)) 51 | output.setPipeline(mosaicedStream, block(outputBlockSize)) 52 | } 53 | } 54 | } 55 | 56 | function onError (err) { 57 | mosaicedStream.destroy(err) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mosaic-image-stream", 3 | "version": "1.0.2", 4 | "description": "Streaming mosaic of multiple images into a single image", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "standard" 8 | }, 9 | "keywords": [ 10 | "mosaic", 11 | "image", 12 | "stream", 13 | "join", 14 | "merge", 15 | "stitch" 16 | ], 17 | "author": "Gregor MacLennan", 18 | "license": "MIT", 19 | "dependencies": { 20 | "block-stream2": "^1.1.0", 21 | "buffer-peek-stream": "^1.0.1", 22 | "from2": "^2.3.0", 23 | "inherits": "^2.0.1", 24 | "interleave-stream": "^1.0.2", 25 | "multistream": "^2.1.0", 26 | "pumpify": "^1.3.5" 27 | }, 28 | "devDependencies": { 29 | "jpg-stream": "^1.1.1", 30 | "png-stream": "^1.0.4", 31 | "request": "^2.74.0", 32 | "standard": "^8.0.0", 33 | "@mapbox/tilebelt": "^1.0.1" 34 | }, 35 | "directories": { 36 | "example": "example" 37 | }, 38 | "repository": { 39 | "type": "git", 40 | "url": "git+https://github.com/digidem/mosaic-image-stream.git" 41 | }, 42 | "bugs": { 43 | "url": "https://github.com/digidem/mosaic-image-stream/issues" 44 | }, 45 | "homepage": "https://github.com/digidem/mosaic-image-stream#readme" 46 | } 47 | --------------------------------------------------------------------------------