├── .gitignore ├── download.sh ├── index.js ├── package.json ├── LICENSE ├── README.md └── sidewalker.js /.gitignore: -------------------------------------------------------------------------------- 1 | data/* 2 | node_modules/* 3 | -------------------------------------------------------------------------------- /download.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | mkdir -p data 3 | curl -o data/latest.planet.mbtiles.gz https://s3.amazonaws.com/mapbox/osm-qa-tiles-production/latest.planet.mbtiles.gz 4 | cd data 5 | gunzip latest.planet.mbtiles 6 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var tilereduce = require('tile-reduce'), 2 | path = require('path'); 3 | 4 | var opts = { 5 | zoom: 12, 6 | sourceCover: 'osm', 7 | sources: [ 8 | { 9 | name: 'osm', 10 | mbtiles: path.join(__dirname, 'data/latest.planet.mbtiles'), 11 | layers: ['osm'], 12 | raw: true 13 | } 14 | ], 15 | map: __dirname + '/sidewalker.js' 16 | }; 17 | 18 | 19 | tilereduce(opts) 20 | .on('error', function (error) { 21 | throw error; 22 | }) 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "license": "BSD-2-Clause", 3 | "engines": { 4 | "node": "0.10.x" 5 | }, 6 | "version": "0.0.1", 7 | "repository": { 8 | "url": "git://github.com/mapbox/osm-sidewalker.git", 9 | "type": "git" 10 | }, 11 | "author": "Mapbox", 12 | "description": "", 13 | "bugs": { 14 | "url": "https://github.com/mapbox/osm-sidewalker/issues" 15 | }, 16 | "dependencies": { 17 | "geojson-normalize": "0.0.0", 18 | "minimist": "^1.1.1", 19 | "turf-line-chunk": "^1.0.0", 20 | "lineclip": "^1.1.4", 21 | "tile-reduce": "^3.0.1", 22 | "rbush": "^1.4.1", 23 | "turf-line-slice-at-intersection": "^1.0.0", 24 | "tilebelt": "^0.7.1", 25 | "geojson-utils": "^1.1.0", 26 | "turf": "2.0.2" 27 | }, 28 | "homepage": "https://github.com/mapbox/osm-sidewalker", 29 | "name": "osm-sidewalker" 30 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2017, Mapbox 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # osm-sidewalker 2 | 3 | A [Tile Reduce](https://github.com/mapbox/tile-reduce) processor for detecting potentially untagged sidewalks in OpenStreetMap 4 | 5 | 6 | ## installation 7 | 8 | ``` 9 | npm install 10 | ``` 11 | 12 | ## downloading osm qa tiles 13 | 14 | OSM QA tiles are very large (38 GB compressed, 49 GB expanded). On OSX & Linux systems, you can 15 | run the build process by executing `./download.sh`. 16 | 17 | On Windows, or if you wish to download QA tiles yourself: 18 | 19 | - Create a `data` folder inside your copy of this repository 20 | - [Download OSM QA tiles](https://s3.amazonaws.com/mapbox/osm-qa-tiles/latest.planet.mbtiles.gz) 21 | - Use `gunzip` or any other archiving tool that can expand .gz files to expand OSM QA tiles 22 | - Move the expanded `latest.planet.mbtiles` to the `data` folder 23 | 24 | ## running 25 | 26 | When executing the Tile Reduce task, you must provide a bounding box to select tiles. For example: 27 | 28 | ``` 29 | node index.js --area=[-77.12,38.79,-76.9,39] > output.json 30 | ``` 31 | 32 | ## publishing 33 | 34 | The output of the Tile Reduce job is a line-separated list of sidewalk linestrings. This format works well with [tippecanoe](https://github.com/mapbox/tippecanoe), for example: 35 | 36 | ``` 37 | tippecanoe -f -o sidewalks.mbtiles output.json 38 | ``` 39 | 40 | The resulting mbtiles can be uploaded as a Mapbox data source [online](https://www.mapbox.com/uploads/?source=data), or via command line using [mapbox-upload](https://github.com/mapbox/mapbox-upload) 41 | 42 | -------------------------------------------------------------------------------- /sidewalker.js: -------------------------------------------------------------------------------- 1 | var normalize = require('geojson-normalize'), 2 | gju = require('geojson-utils'), 3 | turf = require('turf'), 4 | tilebelt = require('tilebelt'), 5 | lineChunk = require('turf-line-chunk'), 6 | sliceAtIntersect = require('turf-line-slice-at-intersection'), 7 | rbush = require('rbush'); 8 | 9 | module.exports = function (tileLayers, tile, writeData, done) { 10 | 11 | var footways = filterAndClipFootways(tileLayers.osm.osm, tile), 12 | roads = filterAndClipRoads(tileLayers.osm.osm, tile), 13 | proposals = []; 14 | 15 | var roadIndex = rbush(); 16 | 17 | for(var r = 0; r < roads.length; r++) { 18 | roadIndex.insert(turf.extent(roads[r]).concat({road_id: r})); 19 | } 20 | 21 | for (var f = 0; f < footways.length; f++) { 22 | var segments = sliceAtIntersect(footways[f], findProbablyIntersects(footways[f], roadIndex, roads)); 23 | 24 | // Find which of the remaining segments stay close to a road (should be a sidewalk) 25 | segments.features.forEach(function (segment) { 26 | // found a case where the original linestring is a single coordinate, not wrapped in an array 27 | if (segment.geometry.coordinates.length < 2 || !segment.geometry.coordinates[0].length) return; 28 | // skip short little segments 29 | if (turf.lineDistance(segment, 'miles') <= 10/5280) return; 30 | 31 | var segmented = lineChunk(segment, 250/5280, 'miles'); 32 | 33 | segmented.features.forEach(function (seg) { 34 | if (turf.lineDistance(seg, 'miles') <= 150/5280) return; 35 | 36 | // Get bisectors fo this segment, and match it against 37 | // each road. 38 | var bisectors = buildBisectors(seg); 39 | var isMatched = false; 40 | 41 | var bisectBox = turf.extent(turf.featurecollection(bisectors)); 42 | var maybeCollides = roadIndex.search(bisectBox); 43 | 44 | maybeCollides.forEach(function (maybe) { 45 | var road = roads[maybe[4].road_id]; 46 | 47 | if (isMatched || road.properties.layer !== footways[f].properties.layer) return; 48 | 49 | var matched = 0; 50 | bisectors.forEach(function (bisector) { 51 | if (gju.lineStringsIntersect(bisector.geometry, road.geometry)) matched++; 52 | }); 53 | if (matched / bisectors.length > 0.7) { 54 | isMatched = true; 55 | seg.properties['_osm_way_id'] = footways[f].properties._osm_way_id; 56 | seg.properties['proposed:footway'] = 'sidewalk'; 57 | seg.properties['proposed:associatedStreet'] = road.properties.name; 58 | writeData(JSON.stringify(seg)+'\n'); 59 | } 60 | }); 61 | }); 62 | }); 63 | } 64 | 65 | done(null, proposals); 66 | }; 67 | 68 | 69 | /** 70 | * Generates array of potentially erroneous footways 71 | */ 72 | function filterAndClipFootways(osm, tile) { 73 | var features = []; 74 | 75 | var keepSurfaces = [ 76 | 'paved', 77 | 'concrete', 78 | 'asphalt', 79 | 'concrete:plates', 80 | 'cobblestone', 81 | 'cobblestone:flattened', 82 | 'sett', 83 | ]; 84 | 85 | for (var i = 0; i < osm.length; i++) { 86 | var ft = osm.feature(i); 87 | 88 | // Grab all footways missing footway=[sidewalk, crossing]. 89 | // Exclude surfaces not in our keep list, tunnels and areas. 90 | if (ft.properties.highway === 'footway' 91 | && ft.properties.footway !== 'sidewalk' 92 | && ft.properties.footway !== 'crossing' 93 | && ft.properties.area !== 'yes' 94 | && !ft.properties['area:highway'] 95 | && ft.properties.tunnel !== 'yes' 96 | && (!ft.properties.surface || keepSurfaces.indexOf(ft.properties.surface) > -1) 97 | ) { 98 | features.push(ft.toGeoJSON(tile[0], tile[1], tile[2])); 99 | } 100 | } 101 | 102 | return clipNormalize(features, tile); 103 | } 104 | 105 | /** 106 | * Generates an array of roads in the tile to check footways against 107 | */ 108 | function filterAndClipRoads(osm, tile) { 109 | var roadTypes = [ 110 | 'trunk_link', 111 | 'trunk', 112 | 'primary', 113 | 'secondary', 114 | 'tertiary', 115 | 'unclassified', 116 | 'residential', 117 | 'road', 118 | ]; 119 | 120 | var features = []; 121 | for (var i = 0; i < osm.length; i++) { 122 | var ft = osm.feature(i); 123 | // Grab all footways missing footway=[sidewalk, crossing] 124 | if (roadTypes.indexOf(ft.properties.highway) > -1) { 125 | features.push(ft.toGeoJSON(tile[0], tile[1], tile[2])); 126 | } 127 | } 128 | 129 | return clipNormalize(features, tile); 130 | } 131 | 132 | /** 133 | * normalizes the input features to linestrings 134 | */ 135 | function clipNormalize(features, tile) { 136 | 137 | var newLines = []; 138 | 139 | for (var i = 0; i < features.length; i++) { 140 | if (features[i].geometry.type !== 'LineString' && features[i].geometry.type !== 'MultiLineString') continue; 141 | 142 | var coords = (features[i].geometry.type === 'MultiLineString') ? 143 | features[i].geometry.coordinates : 144 | [features[i].geometry.coordinates]; 145 | 146 | for (var c = 0; c < coords.length; c++) { 147 | // Certain erroneous linestrings are present in the ways data. Possibly an artifact 148 | // of tile clippingice 149 | if (coords[c].length > 0 && typeof coords[c][0] === 'number') continue; 150 | newLines.push(turf.linestring(coords[c], features[i].properties)); 151 | } 152 | } 153 | 154 | return newLines; 155 | } 156 | 157 | /** 158 | * Generates bisectors for all the given footway segments. 159 | * Bisectors are generated every 20 feet, and extend 75 feet on either 160 | * side of the street. 161 | */ 162 | function buildBisectors(footwaySegment) { 163 | var bisectors = []; 164 | var segmented = lineChunk(footwaySegment, 50/5280, 'miles'); 165 | 166 | segmented.features.forEach(function (segment) { 167 | var seglen = segment.geometry.coordinates.length; 168 | var endpoint = turf.point(segment.geometry.coordinates[seglen - 1]); 169 | var bearing = turf.bearing( 170 | turf.point(segment.geometry.coordinates[seglen - 2]), endpoint); 171 | 172 | var start = turf.destination(endpoint, 75/5280, bearing - 90, 'miles'); 173 | var end = turf.destination(endpoint, 75/5280, bearing + 90, 'miles'); 174 | bisectors.push(turf.linestring([start.geometry.coordinates, end.geometry.coordinates])); 175 | }); 176 | 177 | 178 | return bisectors; 179 | } 180 | 181 | /** 182 | * Using our rbush index, find which roads probably intersect the sidewalk 183 | */ 184 | function findProbablyIntersects(footway, roadIndex, roads) { 185 | var extent = turf.extent(footway); 186 | 187 | var colliding = roadIndex.search(extent); 188 | var fc = []; 189 | 190 | 191 | for (var i = 0; i < colliding.length; i++) { 192 | fc.push(roads[colliding[i][4].road_id]) 193 | } 194 | 195 | return turf.featurecollection(fc); 196 | } 197 | 198 | --------------------------------------------------------------------------------