feat: osm2streets上游箭头修复
This commit is contained in:
10
README.md
10
README.md
@@ -78,6 +78,9 @@ cp config/examples/template.json config/areas/my-area.json
|
|||||||
},
|
},
|
||||||
"qgis": {
|
"qgis": {
|
||||||
"arrowScale": 0.8,
|
"arrowScale": 0.8,
|
||||||
|
"arrowMergeTriangles": true,
|
||||||
|
"arrowOutlineSimplifyMeters": 0.05,
|
||||||
|
"intersectionCornerSourceMaxDimensionMeters": 2.6,
|
||||||
"clipPad": 0.002,
|
"clipPad": 0.002,
|
||||||
"canvasPad": 0.001,
|
"canvasPad": 0.001,
|
||||||
"previewPad": 0.0007,
|
"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` 覆盖:
|
`scripts/build-area.js` 会按 `id` 自动推导默认输出路径。确实需要定制时,可以增加 `outputs` 覆盖:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
},
|
},
|
||||||
"qgis": {
|
"qgis": {
|
||||||
"arrowScale": 0.8,
|
"arrowScale": 0.8,
|
||||||
|
"arrowMergeTriangles": true,
|
||||||
|
"arrowOutlineSimplifyMeters": 0.05,
|
||||||
|
"intersectionCornerSourceMaxDimensionMeters": 2.6,
|
||||||
"clipPad": 0.002,
|
"clipPad": 0.002,
|
||||||
"canvasPad": 0.001,
|
"canvasPad": 0.001,
|
||||||
"previewPad": 0.0007,
|
"previewPad": 0.0007,
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
},
|
},
|
||||||
"qgis": {
|
"qgis": {
|
||||||
"arrowScale": 0.8,
|
"arrowScale": 0.8,
|
||||||
|
"arrowMergeTriangles": true,
|
||||||
|
"arrowOutlineSimplifyMeters": 0.05,
|
||||||
|
"intersectionCornerSourceMaxDimensionMeters": 2.6,
|
||||||
"clipPad": 0.002,
|
"clipPad": 0.002,
|
||||||
"canvasPad": 0.001,
|
"canvasPad": 0.001,
|
||||||
"previewPad": 0.0007,
|
"previewPad": 0.0007,
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ function normalizeAreaConfig(raw) {
|
|||||||
},
|
},
|
||||||
qgis: {
|
qgis: {
|
||||||
arrowScale: raw.qgis?.arrowScale ?? raw.arrowScale ?? 0.8,
|
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,
|
clipPad: raw.qgis?.clipPad ?? raw.clipPad ?? 0.002,
|
||||||
canvasPad: raw.qgis?.canvasPad ?? raw.canvasPad ?? 0.001,
|
canvasPad: raw.qgis?.canvasPad ?? raw.canvasPad ?? 0.001,
|
||||||
previewPad: raw.qgis?.previewPad ?? raw.previewPad ?? 0.0007,
|
previewPad: raw.qgis?.previewPad ?? raw.previewPad ?? 0.0007,
|
||||||
@@ -175,6 +178,9 @@ function buildIntermediates(area) {
|
|||||||
project: area.outputs.qgisProject,
|
project: area.outputs.qgisProject,
|
||||||
preview: area.outputs.qgisPreview,
|
preview: area.outputs.qgisPreview,
|
||||||
arrowScale: area.qgis.arrowScale,
|
arrowScale: area.qgis.arrowScale,
|
||||||
|
arrowMergeTriangles: area.qgis.arrowMergeTriangles,
|
||||||
|
arrowOutlineSimplifyMeters: area.qgis.arrowOutlineSimplifyMeters,
|
||||||
|
intersectionCornerSourceMaxDimensionMeters: area.qgis.intersectionCornerSourceMaxDimensionMeters,
|
||||||
clipPad: area.qgis.clipPad,
|
clipPad: area.qgis.clipPad,
|
||||||
canvasPad: area.qgis.canvasPad,
|
canvasPad: area.qgis.canvasPad,
|
||||||
previewPad: area.qgis.previewPad,
|
previewPad: area.qgis.previewPad,
|
||||||
|
|||||||
@@ -14,12 +14,16 @@ const qgisApp = config.qgisApp;
|
|||||||
const qgisMacOS = path.join(qgisApp, "Contents", "MacOS");
|
const qgisMacOS = path.join(qgisApp, "Contents", "MacOS");
|
||||||
const qgisPython = path.join(qgisMacOS, "python3.12");
|
const qgisPython = path.join(qgisMacOS, "python3.12");
|
||||||
const ogr2ogr = path.join(qgisMacOS, "ogr2ogr");
|
const ogr2ogr = path.join(qgisMacOS, "ogr2ogr");
|
||||||
|
const normalizeLaneArrowsScript = path.join(repoRoot, "scripts", "normalize-lane-arrows.py");
|
||||||
const inputPath = path.resolve(config.input);
|
const inputPath = path.resolve(config.input);
|
||||||
const outDir = path.resolve(config.outDir);
|
const outDir = path.resolve(config.outDir);
|
||||||
const gpkgPath = path.resolve(config.gpkg);
|
const gpkgPath = path.resolve(config.gpkg);
|
||||||
const projectPath = path.resolve(config.project);
|
const projectPath = path.resolve(config.project);
|
||||||
const previewPath = path.resolve(config.preview);
|
const previewPath = path.resolve(config.preview);
|
||||||
const arrowScale = Number(config.arrowScale);
|
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 clipPad = Number(config.clipPad);
|
||||||
const canvasPad = Number(config.canvasPad);
|
const canvasPad = Number(config.canvasPad);
|
||||||
const previewPad = Number(config.previewPad);
|
const previewPad = Number(config.previewPad);
|
||||||
@@ -28,6 +32,12 @@ const layerPrefix = config.layerPrefix || "osm2streets";
|
|||||||
if (!Number.isFinite(arrowScale) || arrowScale <= 0) {
|
if (!Number.isFinite(arrowScale) || arrowScale <= 0) {
|
||||||
throw new Error(`Invalid arrowScale: ${config.arrowScale}`);
|
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]]) {
|
for (const [key, value] of [["clipPad", clipPad], ["canvasPad", canvasPad], ["previewPad", previewPad]]) {
|
||||||
if (!Number.isFinite(value) || value < 0) {
|
if (!Number.isFinite(value) || value < 0) {
|
||||||
throw new Error(`Invalid ${key}: ${config[key]}`);
|
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}`);
|
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(outDir, { recursive: true });
|
||||||
fs.mkdirSync(path.dirname(gpkgPath), { 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());
|
writeGeoJson(outDir, "intersection_markings.geojson", network.toIntersectionMarkingsGeojson());
|
||||||
fs.writeFileSync(path.join(outDir, "network.json"), network.toJson());
|
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, "road_surface.geojson"), split.roadSurface);
|
||||||
writeJson(path.join(outDir, "intersection_surface.geojson"), split.intersectionSurface);
|
writeJson(path.join(outDir, "intersection_surface.geojson"), split.intersectionSurface);
|
||||||
writeJson(path.join(outDir, "sidewalks.geojson"), split.sidewalks);
|
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, "center_lines.geojson"), split.centerLines);
|
||||||
writeJson(path.join(outDir, "vehicle_stop_lines.geojson"), split.vehicleStopLines);
|
writeJson(path.join(outDir, "vehicle_stop_lines.geojson"), split.vehicleStopLines);
|
||||||
writeJson(path.join(outDir, "lane_arrows_webscale.geojson"), split.laneArrows);
|
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, "sidewalk_corners.geojson"), split.sidewalkCorners);
|
||||||
writeJson(path.join(outDir, "crosswalks.geojson"), split.crosswalks);
|
writeJson(path.join(outDir, "crosswalks.geojson"), split.crosswalks);
|
||||||
writeJson(path.join(outDir, "osm2streets_scene.geojson"), mergedScene(split));
|
writeJson(path.join(outDir, "osm2streets_scene.geojson"), mergedScene(split));
|
||||||
@@ -161,6 +183,9 @@ function loadConfig(file, cliArgs) {
|
|||||||
project: "project",
|
project: "project",
|
||||||
preview: "preview",
|
preview: "preview",
|
||||||
arrowScale: "arrowScale",
|
arrowScale: "arrowScale",
|
||||||
|
arrowMergeTriangles: "arrowMergeTriangles",
|
||||||
|
arrowOutlineSimplifyMeters: "arrowOutlineSimplifyMeters",
|
||||||
|
intersectionCornerSourceMaxDimensionMeters: "intersectionCornerSourceMaxDimensionMeters",
|
||||||
clipPad: "clipPad",
|
clipPad: "clipPad",
|
||||||
pad: "clipPad",
|
pad: "clipPad",
|
||||||
canvasPad: "canvasPad",
|
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 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 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"));
|
const markings = JSON.parse(fs.readFileSync(path.join(dir, "lane_markings.geojson"), "utf8"));
|
||||||
@@ -445,7 +470,7 @@ function splitLayers(dir, arrowScaleValue, osm) {
|
|||||||
centerLines: emptyCollection(),
|
centerLines: emptyCollection(),
|
||||||
vehicleStopLines: crosswalkData.stopLines,
|
vehicleStopLines: crosswalkData.stopLines,
|
||||||
laneArrows: emptyCollection(),
|
laneArrows: emptyCollection(),
|
||||||
sidewalkCorners: intersections,
|
sidewalkCorners: filteredSidewalkCorners(intersections, maxCornerDimensionMeters),
|
||||||
crosswalks: crosswalkData.stripes,
|
crosswalks: crosswalkData.stripes,
|
||||||
};
|
};
|
||||||
const serviceDrivingPolygons = [];
|
const serviceDrivingPolygons = [];
|
||||||
@@ -455,7 +480,6 @@ function splitLayers(dir, arrowScaleValue, osm) {
|
|||||||
out.intersectionSurface.features.push(feature);
|
out.intersectionSurface.features.push(feature);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const feature of lanePolygons.features || []) {
|
for (const feature of lanePolygons.features || []) {
|
||||||
const type = feature.properties?.type;
|
const type = feature.properties?.type;
|
||||||
if (type === "Sidewalk" || type === "Footway") {
|
if (type === "Sidewalk" || type === "Footway") {
|
||||||
@@ -479,7 +503,9 @@ function splitLayers(dir, arrowScaleValue, osm) {
|
|||||||
if (type === "vehicle stop line" && !isInAnyPolygon(feature, crosswalkData.stopLineExclusionZones, "intersects")) {
|
if (type === "vehicle stop line" && !isInAnyPolygon(feature, crosswalkData.stopLineExclusionZones, "intersects")) {
|
||||||
out.vehicleStopLines.features.push(feature);
|
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;
|
return out;
|
||||||
@@ -490,6 +516,30 @@ function hasAnyWayId(value, ids) {
|
|||||||
return values.some((id) => ids.has(Number(id)));
|
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) {
|
function polygonRings(geometry) {
|
||||||
if (!geometry?.coordinates) return [];
|
if (!geometry?.coordinates) return [];
|
||||||
if (geometry.type === "Polygon") return [geometry.coordinates];
|
if (geometry.type === "Polygon") return [geometry.coordinates];
|
||||||
@@ -846,6 +896,26 @@ function scaleCoords(obj, cx, cy, scale) {
|
|||||||
return obj;
|
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) {
|
function importLayer(gpkg, source, layerName, update, env) {
|
||||||
const args = ["-f", "GPKG"];
|
const args = ["-f", "GPKG"];
|
||||||
if (update) args.push("-update", "-overwrite");
|
if (update) args.push("-update", "-overwrite");
|
||||||
|
|||||||
182
scripts/normalize-lane-arrows.py
Normal file
182
scripts/normalize-lane-arrows.py
Normal 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()
|
||||||
Reference in New Issue
Block a user