OpenStreetMap PBF
OpenStreetMap PBF extracts read in pure Rust with no database: the header and its required features, nodes, dense nodes, ways and relations filtered by tags and a bounding box, ways given their coordinates and length by a second pass, tags counted, and matches written as GeoJSON.
Version 1.0.0 · 6 functions · 2 crates
Functions
What use osm_pbf.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use osm_info, osm_nodes from osm_pbf (Quillo 0.6).
osm_info(path: str) → anyor an errorA file's header and how much is in it: { required_features, optional_features, writing_program, source, bbox, replication, nodes, ways, relations, tagged_nodes }. It reads every block; bbox is [west, south, east, north].
osm_nodes(path: str, filter: any) → anyor an errorNodes matching a filter: [{ id, lat, lon, tags }]. filter is text like "amenity=cafe|pub" or "shop", or { tags: { amenity: ["cafe", "pub"], name: true }, not, bbox, limit (default 10000), meta }. Nodes without tags are passed over unless a filter names none and there is a bbox. meta adds version and timestamp.
osm_ways(path: str, filter: any, geometry: bool) → anyor an errorWays matching a filter: [{ id, tags, nodes, closed }], and with geometry true also { coordinates: [[lat, lon]], length_m, missing }. An extract can cut a way, so missing counts node refs not in the file. Finding coordinates reads the file a second time.
osm_relations(path: str, filter: any) → anyor an errorRelations matching a filter: [{ id, tags, members: [{ type, id, role }] }], for routes, boundaries and multipolygons.
osm_tag_counts(path: str, key: str, limit: int) → anyor an errorHow often each key appears, or each value of a key given: [{ tag, count }], commonest first, limit at most. key "" counts keys across nodes, ways and relations.
osm_geojson(path: str, filter: any) → anyor an errorWhat matches a filter as a GeoJSON FeatureCollection, ready for a map: nodes as points, ways as lines, closed ways as polygons unless tagged highway or barrier. Coordinates are [lon, lat]; each feature's id is node/1 or way/2. Ways an extract cut short are left out, with their number in skipped.
Get it
$ quillo install osm_pbf # osm_pbf.qwer into this folder$ quillo install osm_pbf --example # and its example, osm_pbf.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/osm_pbf.qwer$ curl -O https://quillolang.org/plugins/files/osm_pbf.qwe$ curl -O https://quillolang.org/plugins/files/osm_pbf_sample.osm.pbfThen, in your program, all of it or only what you call:
use osm_pbf.qweruse osm_info, osm_nodes from osm_pbf // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
osmpbf = "0.3"serde_json = { version = "1", features = ["preserve_order"] }Example
// OpenStreetMap extracts: a small `.osm.pbf` read directly, with cafes found, a street given its// coordinates, a route's members, tags counted, and matches as GeoJSON. For a real one, Geofabrik// has a file for each country; nothing here needs a database or a download.//// node standins/osm_pbf_sample.mjs makes the tiny sample file// quillo run osm_pbf.qwe// then open http://localhost:8080/info, /nodes, /ways, /relations, /tags, /map.geojson and /refuseduse "osm_pbf.qwer"let pbf = "osm_pbf_sample.osm.pbf"route GET /info = osm_info(pbf)?route GET /nodes = { cafes: osm_nodes(pbf, "amenity=cafe")?, drinks: osm_nodes(pbf, "amenity=cafe|pub")? | map((n) => n.tags.name), named: osm_nodes(pbf, { tags: { name: true }, not: "amenity=pub" })? | map((n) => n.id), in_town: osm_nodes(pbf, { tags: "amenity", bbox: [4.8, 52.3, 5.0, 52.4] })? | map((n) => n.id), bare_in_box: osm_nodes(pbf, { bbox: [4.879, 52.359, 4.882, 52.362] })? | map((n) => n.id), first_only: len(osm_nodes(pbf, { tags: "name", limit: 1 })?), with_meta: first(osm_nodes(pbf, { tags: "shop", meta: true })?).meta, plain_block: osm_nodes(pbf, "tourism=museum")?,}route GET /ways = { streets: osm_ways(pbf, "highway", false)? | map((w) => w.id), street: first(osm_ways(pbf, "highway=residential", true)?), block: first(osm_ways(pbf, "building", true)?).closed, in_box: osm_ways(pbf, { bbox: [4.879, 52.359, 4.882, 52.362] }, false)? | map((w) => w.id), no_tags_rule: len(osm_ways(pbf, {}, false)?),}route GET /relations = { routes: osm_relations(pbf, "route=bicycle")?, all: osm_relations(pbf, {})? | map((r) => r.tags.type),}route GET /tags = { keys: osm_tag_counts(pbf, "", 3)?, amenity: osm_tag_counts(pbf, "amenity", 10)?,}route GET /map.geojson = osm_geojson(pbf, { tags: { name: true } })?route GET /refused = { no_file: osm_info("nothing.osm.pbf").error, not_pbf: osm_info("osm_pbf.qwe").error, rule: osm_nodes(pbf, 5).error, bbox: osm_nodes(pbf, { bbox: [5, 5, 1, 1] }).error, limit: osm_nodes(pbf, { limit: 0 }).error,}$ quillo run osm_pbf.qwe$ curl 'localhost:8080/info'$ curl 'localhost:8080/nodes'$ curl 'localhost:8080/ways'$ curl 'localhost:8080/relations'$ curl 'localhost:8080/tags'$ curl 'localhost:8080/map.geojson'$ curl 'localhost:8080/refused'Source
//! OpenStreetMap PBF extracts read in pure Rust: the file's header, nodes, ways and relations filtered by tags and a bounding box, dense nodes included, ways given their coordinates, tags counted, and matches as GeoJSON.//! version: 1.0.0//!//! [dependencies]//! osmpbf = "0.3"//! serde_json = { version = "1", features = ["preserve_order"] }use osmpbf::{BlobDecode, BlobReader, Element, RelMemberType};use serde_json::{json, Map, Value};use std::collections::{HashMap, HashSet};const KNOWN: [&str; 4] = ["OsmSchema-V0.6", "DenseNodes", "HistoricalInformation", "Sort.Type_then_ID"];const MAX_COORDS: usize = 20_000_000;struct Filter { rules: Vec<(String, Option<Vec<String>>)>, not: Vec<(String, Option<Vec<String>>)>, bbox: Option<[f64; 4]>, limit: usize, meta: bool,}fn rules_of(v: &Value) -> Result<Vec<(String, Option<Vec<String>>)>, String> { let mut out = vec![]; match v { Value::Null => {} Value::String(s) => match s.split_once('=') { Some((k, val)) => out.push((k.to_string(), Some(val.split('|').map(String::from).collect()))), None => out.push((s.clone(), None)), }, Value::Object(m) => { for (k, x) in m { out.push((k.clone(), match x { Value::Bool(true) => None, Value::String(s) => Some(vec![s.clone()]), Value::Array(a) => Some(a.iter().filter_map(|y| y.as_str().map(String::from)).collect()), o => return Err(format!("the rule for `{}` is `{}`: true, a value, or a list of values", k, o)), })); } } Value::Array(a) => { for x in a { out.extend(rules_of(x)?); } } o => return Err(format!("`{}` is no tag rule: use \"amenity=cafe\" or {{ amenity: \"cafe\" }}", o)), } Ok(out)}// A filter is text like "amenity=cafe|pub", or { tags, not, bbox, limit, meta }.fn filter_of(v: &Value) -> Result<Filter, String> { let is_opts = v.as_object().map(|m| m.keys().all(|k| ["tags", "not", "bbox", "limit", "meta"].contains(&k.as_str()))).unwrap_or(false); let (tags, not) = if is_opts { (v.get("tags").cloned().unwrap_or(Value::Null), v.get("not").cloned().unwrap_or(Value::Null)) } else { (v.clone(), Value::Null) }; let bbox = match v.get("bbox").filter(|_| is_opts) { None | Some(Value::Null) => None, Some(b) => { let a: Vec<f64> = b.as_array().ok_or("bbox is [west, south, east, north]")?.iter().filter_map(|x| x.as_f64()).collect(); if a.len() != 4 || a[0] > a[2] || a[1] > a[3] { return Err("bbox is [west, south, east, north], in that order".into()); } Some([a[0], a[1], a[2], a[3]]) } }; let limit = v.get("limit").filter(|_| is_opts).and_then(|x| x.as_i64()).unwrap_or(10_000); if !(1..=2_000_000).contains(&limit) { return Err(format!("limit is 1 to 2000000, not {}", limit)); } Ok(Filter { rules: rules_of(&tags)?, not: rules_of(¬)?, bbox, limit: limit as usize, meta: v.get("meta").filter(|_| is_opts).and_then(|x| x.as_bool()).unwrap_or(false) })}fn hit(rule: &(String, Option<Vec<String>>), tags: &[(&str, &str)]) -> bool { tags.iter().any(|(k, v)| *k == rule.0 && rule.1.as_ref().map(|a| a.iter().any(|x| x == v)).unwrap_or(true))}impl Filter { fn tags_match(&self, tags: &[(&str, &str)]) -> bool { self.rules.iter().all(|r| hit(r, tags)) && !self.not.iter().any(|r| hit(r, tags)) } fn inside(&self, lat: f64, lon: f64) -> bool { self.bbox.map(|b| lon >= b[0] && lat >= b[1] && lon <= b[2] && lat <= b[3]).unwrap_or(true) }}fn tag_map(t: &[(&str, &str)]) -> Value { Value::Object(t.iter().map(|(k, v)| (k.to_string(), json!(v))).collect::<Map<_, _>>())}fn round(x: f64) -> f64 { (x * 1e7).round() / 1e7}fn open(path: &str) -> Result<BlobReader<std::io::BufReader<std::fs::File>>, String> { if !std::path::Path::new(path).is_file() { return Err(format!("{}: no such file", path)); } BlobReader::from_path(path).map_err(|e| format!("cannot read {}: {}", path, e))}// Each element in turn; `f` returns false to stop early. Features the file demands and this reader// lacks stop it, as the format says they must.fn each(path: &str, f: &mut dyn FnMut(Element) -> bool) -> Result<(), String> { for blob in open(path)? { let blob = blob.map_err(|e| format!("{} is damaged or not a PBF: {}", path, e))?; match blob.decode().map_err(|e| format!("{} is damaged: {}", path, e))? { BlobDecode::OsmHeader(h) => { if let Some(bad) = h.required_features().iter().find(|x| !KNOWN.contains(&x.as_str()) && x.as_str() != "LocationsOnWays") { return Err(format!("{} requires `{}`, which this reader does not know", path, bad)); } } BlobDecode::OsmData(b) => { for g in b.groups() { for n in g.dense_nodes() { if !f(Element::DenseNode(n)) { return Ok(()); } } for n in g.nodes() { if !f(Element::Node(n)) { return Ok(()); } } for w in g.ways() { if !f(Element::Way(w)) { return Ok(()); } } for r in g.relations() { if !f(Element::Relation(r)) { return Ok(()); } } } } BlobDecode::Unknown(_) => {} } } Ok(())}/// A file's header and how much is in it: { required_features, optional_features, writing_program, source, bbox, replication, nodes, ways, relations, tagged_nodes }. It reads every block; `bbox` is [west, south, east, north].pub fn osm_info(path: &str) -> Result<Value, String> { let mut o = Map::new(); for blob in open(path)? { let blob = blob.map_err(|e| format!("{} is damaged or not a PBF: {}", path, e))?; if let BlobDecode::OsmHeader(h) = blob.decode().map_err(|e| format!("{} is damaged: {}", path, e))? { o.insert("required_features".into(), json!(h.required_features())); o.insert("optional_features".into(), json!(h.optional_features())); o.insert("writing_program".into(), json!(h.writing_program())); o.insert("source".into(), json!(h.source())); o.insert("bbox".into(), h.bbox().map(|b| json!([round(b.left), round(b.bottom), round(b.right), round(b.top)])).unwrap_or(Value::Null)); o.insert("replication".into(), json!({ "timestamp": h.osmosis_replication_timestamp(), "sequence": h.osmosis_replication_sequence_number(), "base_url": h.osmosis_replication_base_url() })); break; } } let (mut nodes, mut ways, mut relations, mut tagged) = (0u64, 0u64, 0u64, 0u64); each(path, &mut |e| { match e { Element::Node(n) => { nodes += 1; tagged += u64::from(n.tags().next().is_some()); } Element::DenseNode(n) => { nodes += 1; tagged += u64::from(n.tags().next().is_some()); } Element::Way(_) => ways += 1, Element::Relation(_) => relations += 1, } true })?; o.insert("nodes".into(), json!(nodes)); o.insert("tagged_nodes".into(), json!(tagged)); o.insert("ways".into(), json!(ways)); o.insert("relations".into(), json!(relations)); Ok(Value::Object(o))}/// Nodes matching a filter: [{ id, lat, lon, tags }]. `filter` is text like `"amenity=cafe|pub"` or `"shop"`, or { tags: { amenity: ["cafe", "pub"], name: true }, not, bbox, limit (default 10000), meta }. Nodes without tags are passed over unless a filter names none and there is a bbox. `meta` adds version and timestamp.pub fn osm_nodes(path: &str, filter: serde_json::Value) -> Result<Value, String> { let fl = filter_of(&filter)?; let mut out = vec![]; let keep_bare = fl.rules.is_empty() && fl.bbox.is_some(); let mut push = |id: i64, lat: f64, lon: f64, tags: Vec<(&str, &str)>, meta: Value| -> bool { if (tags.is_empty() && !keep_bare) || !fl.inside(lat, lon) || !fl.tags_match(&tags) { return true; } let mut n = json!({ "id": id, "lat": round(lat), "lon": round(lon), "tags": tag_map(&tags) }); if fl.meta { n["meta"] = meta; } out.push(n); out.len() < fl.limit }; each(path, &mut |e| match e { Element::Node(n) => { let i = n.info(); let meta = json!({ "version": i.version(), "timestamp_ms": i.milli_timestamp(), "changeset": i.changeset(), "user": i.user().and_then(|u| u.ok()) }); push(n.id(), n.lat(), n.lon(), n.tags().collect(), meta) } Element::DenseNode(n) => { let meta = n.info().map(|i| json!({ "version": i.version(), "timestamp_ms": i.milli_timestamp(), "changeset": i.changeset(), "user": i.user().ok() })).unwrap_or(Value::Null); push(n.id(), n.lat(), n.lon(), n.tags().collect(), meta) } Element::Way(_) | Element::Relation(_) => false, // Files sort nodes first, so the rest can wait. })?; Ok(json!(out))}struct WayRow { id: i64, refs: Vec<i64>, tags: Value,}// Nodes inside the box, for a way filter that has one.fn nodes_in(path: &str, f: &Filter) -> Result<Option<HashSet<i64>>, String> { if f.bbox.is_none() { return Ok(None); } let mut s = HashSet::new(); each(path, &mut |e| { match e { Element::Node(n) if f.inside(n.lat(), n.lon()) => { s.insert(n.id()); } Element::DenseNode(n) if f.inside(n.lat(), n.lon()) => { s.insert(n.id()); } Element::Way(_) | Element::Relation(_) => return false, _ => {} } s.len() < MAX_COORDS })?; Ok(Some(s))}fn collect_ways(path: &str, f: &Filter) -> Result<Vec<WayRow>, String> { let inside = nodes_in(path, f)?; let mut out = vec![]; each(path, &mut |e| match e { Element::Way(w) => { let t: Vec<(&str, &str)> = w.tags().collect(); if f.tags_match(&t) { let refs: Vec<i64> = w.refs().collect(); if inside.as_ref().map(|s| refs.iter().any(|r| s.contains(r))).unwrap_or(true) { out.push(WayRow { id: w.id(), refs, tags: tag_map(&t) }); return out.len() < f.limit; } } true } Element::Relation(_) => false, _ => true, })?; Ok(out)}fn coords(path: &str, ways: &[WayRow]) -> Result<HashMap<i64, (f64, f64)>, String> { let want: HashSet<i64> = ways.iter().flat_map(|w| w.refs.iter().copied()).collect(); if want.len() > MAX_COORDS { return Err(format!("{} nodes to look up is too many: narrow the filter", want.len())); } let mut got = HashMap::with_capacity(want.len()); each(path, &mut |e| { match e { Element::Node(n) if want.contains(&n.id()) => { got.insert(n.id(), (n.lat(), n.lon())); } Element::DenseNode(n) if want.contains(&n.id()) => { got.insert(n.id(), (n.lat(), n.lon())); } Element::Way(_) | Element::Relation(_) => return false, _ => {} } got.len() < want.len() })?; Ok(got)}fn metres(a: (f64, f64), b: (f64, f64)) -> f64 { let r = 6_371_008.8; let (p1, p2) = (a.0.to_radians(), b.0.to_radians()); let (dp, dl) = (p2 - p1, (b.1 - a.1).to_radians()); let h = (dp / 2.0).sin().powi(2) + p1.cos() * p2.cos() * (dl / 2.0).sin().powi(2); 2.0 * r * h.sqrt().asin()}/// Ways matching a filter: [{ id, tags, nodes, closed }], and with `geometry` true also { coordinates: [[lat, lon]], length_m, missing }. An extract can cut a way, so `missing` counts node refs not in the file. Finding coordinates reads the file a second time.pub fn osm_ways(path: &str, filter: serde_json::Value, geometry: bool) -> Result<Value, String> { let f = filter_of(&filter)?; let ways = collect_ways(path, &f)?; let got = if geometry { coords(path, &ways)? } else { HashMap::new() }; Ok(json!(ways.iter().map(|w| { let mut o = json!({ "id": w.id, "tags": w.tags, "nodes": w.refs, "closed": w.refs.len() > 2 && w.refs.first() == w.refs.last() }); if geometry { let pts: Vec<(f64, f64)> = w.refs.iter().filter_map(|r| got.get(r).copied()).collect(); o["coordinates"] = json!(pts.iter().map(|p| [round(p.0), round(p.1)]).collect::<Vec<_>>()); o["length_m"] = json!((pts.windows(2).map(|x| metres(x[0], x[1])).sum::<f64>() * 10.0).round() / 10.0); o["missing"] = json!(w.refs.len() - pts.len()); } o }).collect::<Vec<_>>()))}/// Relations matching a filter: [{ id, tags, members: [{ type, id, role }] }], for routes, boundaries and multipolygons.pub fn osm_relations(path: &str, filter: serde_json::Value) -> Result<Value, String> { let f = filter_of(&filter)?; let mut out = vec![]; let mut bad = None; each(path, &mut |e| { if let Element::Relation(r) = e { let t: Vec<(&str, &str)> = r.tags().collect(); if f.tags_match(&t) { let mut ms = vec![]; for m in r.members() { let kind = match m.member_type { RelMemberType::Node => "node", RelMemberType::Way => "way", RelMemberType::Relation => "relation", }; match m.role() { Ok(role) => ms.push(json!({ "type": kind, "id": m.member_id, "role": role })), Err(x) => { bad = Some(x.to_string()); return false; } } } out.push(json!({ "id": r.id(), "tags": tag_map(&t), "members": ms })); return out.len() < f.limit; } } true })?; match bad { Some(e) => Err(format!("{} has a broken string table: {}", path, e)), None => Ok(json!(out)), }}/// How often each key appears, or each value of a key given: [{ tag, count }], commonest first, `limit` at most. `key` "" counts keys across nodes, ways and relations.pub fn osm_tag_counts(path: &str, key: &str, limit: i64) -> Result<Value, String> { let mut c: HashMap<String, u64> = HashMap::new(); let mut add = |tags: Vec<(&str, &str)>| { for (k, v) in tags { if key.is_empty() { *c.entry(k.to_string()).or_insert(0) += 1; } else if k == key { *c.entry(v.to_string()).or_insert(0) += 1; } } }; each(path, &mut |e| { match e { Element::Node(n) => add(n.tags().collect()), Element::DenseNode(n) => add(n.tags().collect()), Element::Way(w) => add(w.tags().collect()), Element::Relation(r) => add(r.tags().collect()), } true })?; let mut rows: Vec<(String, u64)> = c.into_iter().collect(); rows.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); rows.truncate(limit.max(0) as usize); Ok(json!(rows.iter().map(|(k, n)| json!({ "tag": k, "count": n })).collect::<Vec<_>>()))}/// What matches a filter as a GeoJSON FeatureCollection, ready for a map: nodes as points, ways as lines, closed ways as polygons unless tagged `highway` or `barrier`. Coordinates are [lon, lat]; each feature's id is `node/1` or `way/2`. Ways an extract cut short are left out, with their number in `skipped`.pub fn osm_geojson(path: &str, filter: serde_json::Value) -> Result<Value, String> { let f = filter_of(&filter)?; let mut features = vec![]; let nodes = osm_nodes(path, filter.clone())?; for n in nodes.as_array().into_iter().flatten() { features.push(json!({ "type": "Feature", "id": format!("node/{}", n["id"]), "properties": n["tags"], "geometry": { "type": "Point", "coordinates": [n["lon"], n["lat"]] } })); } let ways = collect_ways(path, &f)?; let got = coords(path, &ways)?; let mut skipped = 0; for w in &ways { let pts: Vec<[f64; 2]> = w.refs.iter().filter_map(|r| got.get(r)).map(|p| [round(p.1), round(p.0)]).collect(); if pts.len() != w.refs.len() || pts.len() < 2 { skipped += 1; continue; } let closed = pts.len() > 3 && pts.first() == pts.last(); let area = closed && w.tags.get("highway").is_none() && w.tags.get("barrier").is_none(); let geometry = if area { json!({ "type": "Polygon", "coordinates": [pts] }) } else { json!({ "type": "LineString", "coordinates": pts }) }; features.push(json!({ "type": "Feature", "id": format!("way/{}", w.id), "properties": w.tags, "geometry": geometry })); } Ok(json!({ "type": "FeatureCollection", "features": features, "skipped": skipped }))}Related
mbtiles.qwer · 1.0.0MBTiles files read by a small SQLite reader of its own, in pure Rust and with no locks, so no bundled SQLite sits beside Quillo's: metadata, tiles by web-map z/x/y with the TMS row flip, the unique index walked where there is one, overflow pages followed, mbutil's `map` and `images` layout joined, and tiles written out for `file()`.2 views · 8 downloadsTopoJSON topojson.qwer · 1.0.0TopoJSON read: arcs decoded with quantization and delta encoding, reversed arcs followed, and any object turned into GeoJSON with its ids and properties. GeoJSON made into a topology, where a border two features share is one arc; neighbours found and borders drawn as a mesh.5 views · 8 downloadsCoordinates coordinates.qwer · 1.0.0Coordinates as people write them, such as 52°22'N 4°53'E or N 52° 22.2', read as one, written as degrees-minutes-seconds, and converted to and from UTM.7 views · 8 downloadsGeoTIFF geotiff.qwer · 1.0.0GeoTIFF files read in pure Rust, for elevation data above all: tags and GeoKeys, tie points or a transformation matrix, bounds, EPSG code, no-data, and the value at a coordinate, nearest or bilinear, reading only the strip or tile it needs. Profiles along a line, statistics, strips or tiles, classic or BigTIFF, either byte order, plain, deflate, LZW or PackBits, with predictors.3 views · 7 downloadsGPX tracks gpx.qwer · 1.0.0GPX tracks from watches and phones read: distance, climbing, duration, moving pace, per-kilometre splits, and the points for a map line.7 views · 8 downloadsH3 hexagons h3.qwer · 1.0.0H3 hexagon cells: the cell of a point at a resolution, its neighbours within k rings, its outline for a map, and distances between cells.7 views · 8 downloadsThread 0 posts
On OpenStreetMap PBF: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.