feat: osm2streets上游箭头修复

This commit is contained in:
2026-07-28 09:08:58 +08:00
parent 694f18826a
commit 96c9baf540
6 changed files with 279 additions and 5 deletions

View File

@@ -78,6 +78,9 @@ cp config/examples/template.json config/areas/my-area.json
},
"qgis": {
"arrowScale": 0.8,
"arrowMergeTriangles": true,
"arrowOutlineSimplifyMeters": 0.05,
"intersectionCornerSourceMaxDimensionMeters": 2.6,
"clipPad": 0.002,
"canvasPad": 0.001,
"previewPad": 0.0007,
@@ -92,6 +95,13 @@ cp config/examples/template.json config/areas/my-area.json
}
```
QGIS road-layer knobs:
- `arrowScale`: scales osm2streets lane-arrow polygons before export.
- `arrowMergeTriangles`: merges each osm2streets lane-arrow triangle mesh into one valid polygon. This preserves the original arrow shape and turn direction while removing renderer gaps along shared triangle edges.
- `arrowOutlineSimplifyMeters`: removes sub-decimeter kinks from the merged arrow exterior. The default `0.05` removes the two malformed tail vertices without changing the arrow head; the remaining tail edge is aligned perpendicular to the shaft.
- `intersectionCornerSourceMaxDimensionMeters`: keeps only small osm2streets `sidewalk corner` polygons. Large intersection-marking polygons are not treated as sidewalk because they can cover the drivable junction.
`scripts/build-area.js` 会按 `id` 自动推导默认输出路径。确实需要定制时,可以增加 `outputs` 覆盖:
```json

View File

@@ -11,6 +11,9 @@
},
"qgis": {
"arrowScale": 0.8,
"arrowMergeTriangles": true,
"arrowOutlineSimplifyMeters": 0.05,
"intersectionCornerSourceMaxDimensionMeters": 2.6,
"clipPad": 0.002,
"canvasPad": 0.001,
"previewPad": 0.0007,

View File

@@ -11,6 +11,9 @@
},
"qgis": {
"arrowScale": 0.8,
"arrowMergeTriangles": true,
"arrowOutlineSimplifyMeters": 0.05,
"intersectionCornerSourceMaxDimensionMeters": 2.6,
"clipPad": 0.002,
"canvasPad": 0.001,
"previewPad": 0.0007,

View File

@@ -101,6 +101,9 @@ function normalizeAreaConfig(raw) {
},
qgis: {
arrowScale: raw.qgis?.arrowScale ?? raw.arrowScale ?? 0.8,
arrowMergeTriangles: raw.qgis?.arrowMergeTriangles ?? raw.arrowMergeTriangles ?? true,
arrowOutlineSimplifyMeters: raw.qgis?.arrowOutlineSimplifyMeters ?? raw.arrowOutlineSimplifyMeters ?? 0.05,
intersectionCornerSourceMaxDimensionMeters: raw.qgis?.intersectionCornerSourceMaxDimensionMeters ?? raw.intersectionCornerSourceMaxDimensionMeters ?? 2.6,
clipPad: raw.qgis?.clipPad ?? raw.clipPad ?? 0.002,
canvasPad: raw.qgis?.canvasPad ?? raw.canvasPad ?? 0.001,
previewPad: raw.qgis?.previewPad ?? raw.previewPad ?? 0.0007,
@@ -175,6 +178,9 @@ function buildIntermediates(area) {
project: area.outputs.qgisProject,
preview: area.outputs.qgisPreview,
arrowScale: area.qgis.arrowScale,
arrowMergeTriangles: area.qgis.arrowMergeTriangles,
arrowOutlineSimplifyMeters: area.qgis.arrowOutlineSimplifyMeters,
intersectionCornerSourceMaxDimensionMeters: area.qgis.intersectionCornerSourceMaxDimensionMeters,
clipPad: area.qgis.clipPad,
canvasPad: area.qgis.canvasPad,
previewPad: area.qgis.previewPad,

View File

@@ -14,12 +14,16 @@ const qgisApp = config.qgisApp;
const qgisMacOS = path.join(qgisApp, "Contents", "MacOS");
const qgisPython = path.join(qgisMacOS, "python3.12");
const ogr2ogr = path.join(qgisMacOS, "ogr2ogr");
const normalizeLaneArrowsScript = path.join(repoRoot, "scripts", "normalize-lane-arrows.py");
const inputPath = path.resolve(config.input);
const outDir = path.resolve(config.outDir);
const gpkgPath = path.resolve(config.gpkg);
const projectPath = path.resolve(config.project);
const previewPath = path.resolve(config.preview);
const arrowScale = Number(config.arrowScale);
const arrowMergeTriangles = config.arrowMergeTriangles !== false;
const arrowOutlineSimplifyMeters = Number(config.arrowOutlineSimplifyMeters ?? 0.05);
const intersectionCornerSourceMaxDimensionMeters = Number(config.intersectionCornerSourceMaxDimensionMeters ?? 2.6);
const clipPad = Number(config.clipPad);
const canvasPad = Number(config.canvasPad);
const previewPad = Number(config.previewPad);
@@ -28,6 +32,12 @@ const layerPrefix = config.layerPrefix || "osm2streets";
if (!Number.isFinite(arrowScale) || arrowScale <= 0) {
throw new Error(`Invalid arrowScale: ${config.arrowScale}`);
}
if (!Number.isFinite(arrowOutlineSimplifyMeters) || arrowOutlineSimplifyMeters < 0) {
throw new Error(`Invalid arrowOutlineSimplifyMeters: ${config.arrowOutlineSimplifyMeters}`);
}
if (!Number.isFinite(intersectionCornerSourceMaxDimensionMeters) || intersectionCornerSourceMaxDimensionMeters <= 0) {
throw new Error(`Invalid intersectionCornerSourceMaxDimensionMeters: ${config.intersectionCornerSourceMaxDimensionMeters}`);
}
for (const [key, value] of [["clipPad", clipPad], ["canvasPad", canvasPad], ["previewPad", previewPad]]) {
if (!Number.isFinite(value) || value < 0) {
throw new Error(`Invalid ${key}: ${config[key]}`);
@@ -41,6 +51,9 @@ for (const exe of [ogr2ogr, qgisPython]) {
throw new Error(`QGIS executable not found: ${exe}`);
}
}
if (!fs.existsSync(normalizeLaneArrowsScript)) {
throw new Error(`Lane-arrow normalizer not found: ${normalizeLaneArrowsScript}`);
}
fs.mkdirSync(outDir, { recursive: true });
fs.mkdirSync(path.dirname(gpkgPath), { recursive: true });
@@ -60,7 +73,12 @@ writeGeoJson(outDir, "lane_markings.geojson", network.toLaneMarkingsGeojson());
writeGeoJson(outDir, "intersection_markings.geojson", network.toIntersectionMarkingsGeojson());
fs.writeFileSync(path.join(outDir, "network.json"), network.toJson());
const split = splitLayers(outDir, arrowScale, osm);
const split = splitLayers(
outDir,
arrowScale,
intersectionCornerSourceMaxDimensionMeters,
osm,
);
writeJson(path.join(outDir, "road_surface.geojson"), split.roadSurface);
writeJson(path.join(outDir, "intersection_surface.geojson"), split.intersectionSurface);
writeJson(path.join(outDir, "sidewalks.geojson"), split.sidewalks);
@@ -68,6 +86,10 @@ writeJson(path.join(outDir, "lane_separators.geojson"), split.laneSeparators);
writeJson(path.join(outDir, "center_lines.geojson"), split.centerLines);
writeJson(path.join(outDir, "vehicle_stop_lines.geojson"), split.vehicleStopLines);
writeJson(path.join(outDir, "lane_arrows_webscale.geojson"), split.laneArrows);
if (arrowMergeTriangles) {
normalizeLaneArrows(path.join(outDir, "lane_arrows_webscale.geojson"), arrowOutlineSimplifyMeters);
split.laneArrows = JSON.parse(fs.readFileSync(path.join(outDir, "lane_arrows_webscale.geojson"), "utf8"));
}
writeJson(path.join(outDir, "sidewalk_corners.geojson"), split.sidewalkCorners);
writeJson(path.join(outDir, "crosswalks.geojson"), split.crosswalks);
writeJson(path.join(outDir, "osm2streets_scene.geojson"), mergedScene(split));
@@ -161,6 +183,9 @@ function loadConfig(file, cliArgs) {
project: "project",
preview: "preview",
arrowScale: "arrowScale",
arrowMergeTriangles: "arrowMergeTriangles",
arrowOutlineSimplifyMeters: "arrowOutlineSimplifyMeters",
intersectionCornerSourceMaxDimensionMeters: "intersectionCornerSourceMaxDimensionMeters",
clipPad: "clipPad",
pad: "clipPad",
canvasPad: "canvasPad",
@@ -428,7 +453,7 @@ function sceneStyle() {
};
}
function splitLayers(dir, arrowScaleValue, osm) {
function splitLayers(dir, arrowScaleValue, maxCornerDimensionMeters, osm) {
const plain = JSON.parse(fs.readFileSync(path.join(dir, "plain.geojson"), "utf8"));
const lanePolygons = JSON.parse(fs.readFileSync(path.join(dir, "lane_polygons.geojson"), "utf8"));
const markings = JSON.parse(fs.readFileSync(path.join(dir, "lane_markings.geojson"), "utf8"));
@@ -445,7 +470,7 @@ function splitLayers(dir, arrowScaleValue, osm) {
centerLines: emptyCollection(),
vehicleStopLines: crosswalkData.stopLines,
laneArrows: emptyCollection(),
sidewalkCorners: intersections,
sidewalkCorners: filteredSidewalkCorners(intersections, maxCornerDimensionMeters),
crosswalks: crosswalkData.stripes,
};
const serviceDrivingPolygons = [];
@@ -455,7 +480,6 @@ function splitLayers(dir, arrowScaleValue, osm) {
out.intersectionSurface.features.push(feature);
}
}
for (const feature of lanePolygons.features || []) {
const type = feature.properties?.type;
if (type === "Sidewalk" || type === "Footway") {
@@ -479,7 +503,9 @@ function splitLayers(dir, arrowScaleValue, osm) {
if (type === "vehicle stop line" && !isInAnyPolygon(feature, crosswalkData.stopLineExclusionZones, "intersects")) {
out.vehicleStopLines.features.push(feature);
}
if (type === "lane arrow" && !conflictsWithCrosswalk && !isInAnyPolygon(feature, serviceDrivingPolygons)) out.laneArrows.features.push(scaleFeature(feature, arrowScaleValue));
if (type === "lane arrow" && !conflictsWithCrosswalk && !isInAnyPolygon(feature, serviceDrivingPolygons)) {
out.laneArrows.features.push(scaleFeature(feature, arrowScaleValue));
}
}
return out;
@@ -490,6 +516,30 @@ function hasAnyWayId(value, ids) {
return values.some((id) => ids.has(Number(id)));
}
function filteredSidewalkCorners(intersections, maxDimensionMeters) {
const out = emptyCollection();
for (const feature of intersections.features || []) {
if (feature.properties?.type !== "sidewalk corner") continue;
const dimension = maxFeatureDimensionMeters(feature);
if (dimension === null || dimension > maxDimensionMeters) continue;
out.features.push(feature);
}
return out;
}
function maxFeatureDimensionMeters(feature) {
const points = [];
collectCoords(feature.geometry?.coordinates, points);
if (points.length === 0) return null;
const lat = points.reduce((sum, point) => sum + point[1], 0) / points.length;
const meters = metersForLat(lat);
const xs = points.map((point) => point[0]);
const ys = points.map((point) => point[1]);
const width = (Math.max(...xs) - Math.min(...xs)) * meters.lon;
const height = (Math.max(...ys) - Math.min(...ys)) * meters.lat;
return Math.max(width, height);
}
function polygonRings(geometry) {
if (!geometry?.coordinates) return [];
if (geometry.type === "Polygon") return [geometry.coordinates];
@@ -846,6 +896,26 @@ function scaleCoords(obj, cx, cy, scale) {
return obj;
}
function normalizeLaneArrows(geojsonPath, outlineSimplifyMeters) {
execFileSync(qgisPython, [
normalizeLaneArrowsScript,
"--input", geojsonPath,
"--outline-simplify-meters", String(outlineSimplifyMeters),
], {
stdio: "inherit",
env: {
...process.env,
...qgisEnv(),
QT_QPA_PLATFORM: "offscreen",
PYTHONHOME: path.join(qgisApp, "Contents", "Frameworks"),
PYTHONPATH: [
path.join(qgisApp, "Contents", "Resources", "python"),
path.join(qgisApp, "Contents", "Resources", "python", "plugins"),
].join(path.delimiter),
},
});
}
function importLayer(gpkg, source, layerName, update, env) {
const args = ["-f", "GPKG"];
if (update) args.push("-update", "-overwrite");

View File

@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Normalize malformed lane-arrow meshes emitted by osm2streets."""
import argparse
import json
import math
from pathlib import Path
from osgeo import ogr
METERS_PER_DEGREE = 111320.0
ogr.UseExceptions()
def cli_args():
parser = argparse.ArgumentParser(
description="Normalize osm2streets triangulated lane-arrow polygons."
)
parser.add_argument("--input", required=True, type=Path)
parser.add_argument("--outline-simplify-meters", required=True, type=float)
return parser.parse_args()
def point_segment_distance(point, start, end, latitude):
meters_lon = METERS_PER_DEGREE * math.cos(math.radians(latitude))
px = (point[0] - start[0]) * meters_lon
py = (point[1] - start[1]) * METERS_PER_DEGREE
bx = (end[0] - start[0]) * meters_lon
by = (end[1] - start[1]) * METERS_PER_DEGREE
length_squared = bx * bx + by * by
if length_squared == 0:
return math.hypot(px, py)
projection = max(0.0, min(1.0, (px * bx + py * by) / length_squared))
return math.hypot(px - projection * bx, py - projection * by)
def simplify_ring(points, tolerance, latitude):
points = list(points)
changed = True
while changed and len(points) > 3:
changed = False
for index, point in enumerate(points):
previous = points[index - 1]
following = points[(index + 1) % len(points)]
if point_segment_distance(point, previous, following, latitude) <= tolerance:
points.pop(index)
changed = True
break
return points
def tail_edge_candidate(points):
best = None
for index in range(len(points)):
start = points[index]
end = points[(index + 1) % len(points)]
previous = points[index - 1]
following = points[(index + 2) % len(points)]
before = [start[0] - previous[0], start[1] - previous[1]]
after = [following[0] - end[0], following[1] - end[1]]
edge = [end[0] - start[0], end[1] - start[1]]
before_length = math.hypot(*before)
after_length = math.hypot(*after)
edge_length = math.hypot(*edge)
if min(before_length, after_length, edge_length) == 0:
continue
alignment = (
before[0] * after[0] + before[1] * after[1]
) / (before_length * after_length)
if before_length <= edge_length or after_length <= edge_length or alignment >= -0.9:
continue
score = -alignment * min(before_length, after_length) / edge_length
if best is None or score > best[0]:
best = (score, index, before, after, before_length, after_length)
return best
def square_arrow_tail(points, latitude):
# A normalized straight arrow has seven exterior vertices. Other arrow
# silhouettes are left untouched because their tail cannot be inferred safely.
if len(points) != 7:
return points
meters_lon = METERS_PER_DEGREE * math.cos(math.radians(latitude))
origin = points[0]
local = [
[
(point[0] - origin[0]) * meters_lon,
(point[1] - origin[1]) * METERS_PER_DEGREE,
]
for point in points
]
candidate = tail_edge_candidate(local)
if candidate is None:
return points
_, index, before, after, before_length, after_length = candidate
axis = [
before[0] / before_length - after[0] / after_length,
before[1] / before_length - after[1] / after_length,
]
axis_length = math.hypot(*axis)
if axis_length == 0:
return points
axis = [axis[0] / axis_length, axis[1] / axis_length]
end_index = (index + 1) % len(local)
midpoint = [
(local[index][0] + local[end_index][0]) / 2.0,
(local[index][1] + local[end_index][1]) / 2.0,
]
for point_index in (index, end_index):
offset = [
local[point_index][0] - midpoint[0],
local[point_index][1] - midpoint[1],
]
projection = offset[0] * axis[0] + offset[1] * axis[1]
local[point_index][0] -= projection * axis[0]
local[point_index][1] -= projection * axis[1]
points[point_index] = [
origin[0] + local[point_index][0] / meters_lon,
origin[1] + local[point_index][1] / METERS_PER_DEGREE,
]
return points
def normalize_polygon(geometry, tolerance):
if geometry.GetGeometryName() == "MULTIPOLYGON":
geometry = geometry.UnionCascaded()
if geometry is None or geometry.GetGeometryName() != "POLYGON":
raise ValueError("triangle merge did not produce a Polygon")
source_ring = geometry.GetGeometryRef(0)
points = [source_ring.GetPoint(i)[:2] for i in range(source_ring.GetPointCount() - 1)]
if len(points) <= 3:
raise ValueError("arrow exterior has too few points")
latitude = sum(point[1] for point in points) / len(points)
points = simplify_ring(points, tolerance, latitude)
points = square_arrow_tail(points, latitude)
normalized = ogr.Geometry(ogr.wkbPolygon)
outer = ogr.Geometry(ogr.wkbLinearRing)
for point in points + [points[0]]:
outer.AddPoint_2D(*point)
normalized.AddGeometry(outer)
for index in range(1, geometry.GetGeometryCount()):
normalized.AddGeometry(geometry.GetGeometryRef(index))
if normalized.IsEmpty() or not normalized.IsValid():
raise ValueError("normalized arrow geometry is invalid")
return normalized
def normalize_file(input_path, tolerance):
with input_path.open("r", encoding="utf-8") as handle:
collection = json.load(handle)
for index, feature in enumerate(collection.get("features", [])):
geometry = ogr.CreateGeometryFromJson(json.dumps(feature.get("geometry", {})))
if geometry is None or geometry.IsEmpty():
raise ValueError(f"feature {index} has no usable geometry")
try:
normalized = normalize_polygon(geometry, tolerance)
except ValueError as error:
raise ValueError(f"feature {index}: {error}") from error
feature["geometry"] = json.loads(
normalized.ExportToJson(options=["COORDINATE_PRECISION=15"])
)
temp_path = input_path.with_name(f".{input_path.name}.tmp")
with temp_path.open("w", encoding="utf-8") as handle:
json.dump(collection, handle, ensure_ascii=False, separators=(",", ":"))
handle.write("\n")
temp_path.replace(input_path)
def main():
args = cli_args()
if args.outline_simplify_meters < 0:
raise ValueError("--outline-simplify-meters must be non-negative")
normalize_file(args.input.resolve(), args.outline_simplify_meters)
if __name__ == "__main__":
main()