From 17f747cc8d2c6aa1ab16d130718df173d9122646 Mon Sep 17 00:00:00 2001 From: zstadler Date: Sat, 6 Sep 2025 04:14:31 +0300 Subject: [PATCH] Add post-processing settings for `*at_max_zoom` (#1307) --- .../planetiler/FeatureCollector.java | 4 + planetiler-custommap/README.md | 38 ++++++- planetiler-custommap/planetiler.schema.json | 48 +++++++-- .../custommap/ConfiguredFeature.java | 100 ++++++++++++++---- .../custommap/ConfiguredProfile.java | 30 ++++-- .../custommap/configschema/FeatureItem.java | 3 + .../configschema/MergeLineStrings.java | 8 +- .../custommap/configschema/MergePolygons.java | 5 +- .../custommap/ConfiguredFeatureTest.java | 82 ++++++++++++-- 9 files changed, 269 insertions(+), 49 deletions(-) diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureCollector.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureCollector.java index 803552ff..5e903bbc 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureCollector.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureCollector.java @@ -542,6 +542,10 @@ public class FeatureCollector implements Iterable { return geometryType == GeometryType.POLYGON; } + public boolean isLine() { + return geometryType == GeometryType.LINE; + } + /** * Returns the value by which features are sorted within a layer in the output vector tile. */ diff --git a/planetiler-custommap/README.md b/planetiler-custommap/README.md index 1af9eb23..2b2f1200 100644 --- a/planetiler-custommap/README.md +++ b/planetiler-custommap/README.md @@ -240,7 +240,17 @@ A feature is a defined set of objects that meet a specified filter criteria. expression should be skipped. If unspecified, no exclusion filter is applied. - `min_zoom` - An [Expression](#expression) that returns the minimum zoom to render this feature at. - `min_size` - An [Expression](#expression) that returns the minimum length of line features or square root of the - minimum area of polygon features to emit below the maximum zoom-level of the map. + minimum area of polygon features to emit below the maximum zoom-level of the map. This value is ignored if the layer + [Tile Post Process](#tile-post-process) is defined. +- `min_size_at_max_zoom` - An [Expression](#expression) that returns the minimum length of line features or square root of the + minimum area of polygon features to emit at the maximum zoom-level of the map. This value is ignored if the layer + [Tile Post Process](#tile-post-process) is defined. +- `tolerance` - An [Expression](#expression) that returns the value for the tile pixel tolerance to use when + simplifying features below the maximum zoom level of the map. This value is ignored for lines or polygons if the layer + [Tile Post Process](#tile-post-process) `tolerance` is defined for `merge_line_strings` or `merge_polygons`, respectively. +- `tolerance_at_max_zoom` - An [Expression](#expression) that returns the value for the tile pixel tolerance to use when + simplifying features at the maximum zoom level of the map. This value is ignored for lines or polygons if the layer + [Tile Post Process](#tile-post-process) `tolerance_at_max_zoom` is defined for `merge_line_strings` or `merge_polygons`, respectively. - `attributes` - An array of [Feature Attribute](#feature-attribute) objects that specify the attributes to be included on this output feature. @@ -310,23 +320,41 @@ Specific tile post processing operations for merging features may be defined: The follow attributes for `merge_line_strings` may be set: -- `min_length` - Minimum tile pixel length of features to emit, or 0 to emit all merged linestrings. -- `tolerance` - After merging, simplify linestrings using this pixel tolerance, or -1 to skip simplification step. +- `min_length` - Minimum tile pixel length of features to emit, or 0 to emit all merged linestrings, + below the maximum zoom-level of the map. +- `min_length_at_max_zoom` - Minimum tile pixel length of features to emit, or 0 to emit all merged linestrings, + at the maximum zoom-level of the map. +- `tolerance` - After merging, simplify linestrings using this pixel tolerance, or -1 to skip simplification step, + below the maximum zoom-level of the map. +- `tolerance_at_max_zoom` - After merging, simplify linestrings using this pixel tolerance, or -1 to skip simplification step, + at the maximum zoom-level of the map. - `buffer` - Number of pixels outside the visible tile area to include detail for, or -1 to skip clipping step. The follow attribute for `merge_polygons` may be set: -- `min_area` - Minimum area in square tile pixels of polygons to emit. +- `min_area` - Minimum area in square tile pixels of polygons to emit, + below the maximum zoom-level of the map. +- `min_area_at_max_zoom` - Minimum area in square tile pixels of polygons to emit, + below the maximum zoom-level of the map. +- `tolerance` - Before merging, simplify polygons using this pixel tolerance, or 0 to avoid simplification, + below the maximum zoom-level of the map. +- `tolerance_at_max_zoom` - Before merging, simplify polygons using this pixel tolerance, or 0 to avoid simplification, + at the maximum zoom-level of the map. For example: ```yaml merge_line_strings: - min_length: 1 + min_length: 3 + min_length_at_max_zoom: 0.125 tolerance: 1 + tolerance_at_max_zoom: 0.0625 buffer: 5 merge_polygons: min_area: 1 + min_area_at_max_zoom: 0.25 + tolerance: 0.5 + tolerance_at_max_zoom: 0.125 ``` ## Data Type diff --git a/planetiler-custommap/planetiler.schema.json b/planetiler-custommap/planetiler.schema.json index 6f7e6513..9b017447 100644 --- a/planetiler-custommap/planetiler.schema.json +++ b/planetiler-custommap/planetiler.schema.json @@ -402,16 +402,28 @@ "description": "An expression that returns the minimum zoom to render this feature at.", "$ref": "#/$defs/expression" }, + "min_size": { + "description": "Minimum length of line features or square root of the minimum area of polygon features to emit below the maximum zoom-level of the map. This value is ignored if the layer Tile Post Process is defined.", + "$ref": "#/$defs/expression" + }, + "min_size_at_max_zoom": { + "description": "Minimum length of line features or square root of the minimum area of polygon features to emit at the maximum zoom-level of the map. This value is ignored if the layer Tile Post Process is defined.", + "$ref": "#/$defs/expression" + }, + "tolerance": { + "description": "Tile pixel tolerance to use when simplifying features below the maximum zoom level of the map. This value is ignored for lines or polygons if the layerTile Post Process 'tolerance' is defined for 'merge_line_strings' or 'merge_polygons', respectively.", + "$ref": "#/$defs/expression" + }, + "tolerance_at_max_zoom": { + "description": "Tile pixel tolerance to use when simplifying features at the maximum zoom level of the map. This value is ignored for lines or polygons if the layerTile Post Process 'tolerance' is defined for 'merge_line_strings' or 'merge_polygons', respectively.", + "$ref": "#/$defs/expression" + }, "attributes": { - "description": "Specifies the attributes that should be rendered into the tiles for this feature, and how they are constructed", + "description": "Specifies the attributes that should be rendered into the tiles for this feature, and how they are constructed.", "type": "array", "items": { "$ref": "#/$defs/attribute" } - }, - "min_size": { - "description": "Minimum length of line features or square root of the minimum area of polygon features to emit below the maximum zoom-level of the map", - "$ref": "#/$defs/expression" } } }, @@ -423,11 +435,19 @@ "type": "object", "properties": { "min_length": { - "description": "Minimum tile pixel length of features to emit, or 0 to emit all merged linestrings", + "description": "Minimum tile pixel length of features to emit, or 0 to emit all merged linestrings, below the maximum zoom-level of the map.", + "type": "number" + }, + "min_length_at_max_zoom": { + "description": "Minimum tile pixel length of features to emit, or 0 to emit all merged linestrings, at the maximum zoom-level of the map.", "type": "number" }, "tolerance": { - "description": "After merging, simplify linestrings using this pixel tolerance, or -1 to skip simplification step", + "description": "After merging, simplify linestrings using this pixel tolerance, or -1 to skip simplification step, below the maximum zoom-level of the map.", + "type": "number" + }, + "tolerance_at_max_zoom": { + "description": "After merging, simplify linestrings using this pixel tolerance, or -1 to skip simplification step, at the maximum zoom-level of the map.", "type": "number" }, "buffer": { @@ -441,7 +461,19 @@ "type": "object", "properties": { "min_area": { - "description": "Minimum area in square tile pixels of polygons to emit", + "description": "Minimum area in square tile pixels of polygons to emit, below the maximum zoom-level of the map.", + "type": "number" + }, + "min_area_at_max_zoom": { + "description": "Minimum area in square tile pixels of polygons to emit, at the maximum zoom-level of the map.", + "type": "number" + }, + "tolerance": { + "description": "Before merging, simplify polygons using this pixel tolerance, or 0 to avoid simplification, below the maximum zoom-level of the map.", + "type": "number" + }, + "tolerance_at_max_zoom": { + "description": "Before merging, simplify polygons using this pixel tolerance, or 0 to avoid simplification, at the maximum zoom-level of the map.", "type": "number" } } diff --git a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/ConfiguredFeature.java b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/ConfiguredFeature.java index b1622fbb..c8b22f15 100644 --- a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/ConfiguredFeature.java +++ b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/ConfiguredFeature.java @@ -21,8 +21,7 @@ import java.util.Objects; import java.util.Set; import java.util.function.BiConsumer; import java.util.function.Function; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import java.util.function.ObjDoubleConsumer; /** * A map feature, configured from a YML configuration file. @@ -31,8 +30,6 @@ import org.slf4j.LoggerFactory; * and {@link #processFeature(Contexts.FeaturePostMatch, FeatureCollector)} processes matching elements. */ public class ConfiguredFeature { - private static final Logger LOGGER = LoggerFactory.getLogger(ConfiguredFeature.class); - private static final double LOG4 = Math.log(4); private final Expression geometryTest; private final Function geometryFactory; @@ -95,26 +92,59 @@ public class ConfiguredFeature { } processors.add(makeFeatureProcessor(feature.minZoom(), Integer.class, Feature::setMinZoom)); processors.add(makeFeatureProcessor(feature.maxZoom(), Integer.class, Feature::setMaxZoom)); - if (layer.postProcess() == null) { - processors.add(makeFeatureProcessor(feature.minSize(), Double.class, Feature::setMinPixelSize)); - } else { - processors.add(makeFeatureProcessor(0, Double.class, Feature::setMinPixelSize)); - if (feature.minSize() != null ) { - LOGGER.info("Ignored min_size settings in layer {} in favour of its tile_post_process settings", - layer.id()); - } - var merge = layer.postProcess().mergeLineStrings(); - if (merge != null) { - var minLength = merge.minLength(); - if (minLength > 4) { - processors.add(makeFeatureProcessor(minLength, Double.class, Feature::setBufferPixels)); - } - } - } + + addPostProcessingImplications(layer, feature, processors, rootContext); + + // per-feature tolerance settings should take precedence over defaults from post-processing config + processors.add(makeFeatureProcessor(feature.tolerance(), Double.class, Feature::setPixelTolerance)); + processors.add(makeFeatureProcessor(feature.toleranceAtMaxZoom(), Double.class, Feature::setPixelToleranceAtMaxZoom)); featureProcessors = processors.stream().filter(Objects::nonNull).toList(); } + /** Consider implications of Post Processing on the feature's processors **/ + private void addPostProcessingImplications(FeatureLayer layer, FeatureItem feature, + List> processors, + Contexts.Root rootContext) { + var postProcess = layer.postProcess(); + + // Consider min_size and min_size_at_max_zoom + if (postProcess == null) { + processors.add(makeFeatureProcessor(feature.minSize(), Double.class, Feature::setMinPixelSize)); + processors.add(makeFeatureProcessor(feature.minSizeAtMaxZoom(), Double.class, Feature::setMinPixelSizeAtMaxZoom)); + return; + } + // In order for Post-processing to receive all features, the default MinPixelSize* are zero when features are collected + processors.add(makeFeatureProcessor(Objects.requireNonNullElse(feature.minSize(),0), Double.class, Feature::setMinPixelSize)); + processors.add(makeFeatureProcessor(Objects.requireNonNullElse(feature.minSizeAtMaxZoom(),0), Double.class, Feature::setMinPixelSizeAtMaxZoom)); + // Implications of tile_post_process.merge_line_strings + var mergeLineStrings = postProcess.mergeLineStrings(); + if (mergeLineStrings != null) { + processors.add(makeLineFeatureProcessor(mergeLineStrings.tolerance(),Feature::setPixelTolerance)); + processors.add(makeLineFeatureProcessor(mergeLineStrings.toleranceAtMaxZoom(),Feature::setPixelToleranceAtMaxZoom)); + // postProcess.mergeLineStrings.minLength* and postProcess.mergeLineStrings.buffer + var bufferPixels = maxIgnoringNulls(mergeLineStrings.minLength(), mergeLineStrings.buffer()); + var bufferPixelsAtMaxZoom = maxIgnoringNulls(mergeLineStrings.minLengthAtMaxZoom(), mergeLineStrings.buffer()); + int maxZoom = rootContext.config().maxzoomForRendering(); + if (bufferPixels != null || bufferPixelsAtMaxZoom != null) { + processors.add((context, f) -> { + if (f.isLine()) { + f.setBufferPixelOverrides(z -> z == maxZoom ? bufferPixelsAtMaxZoom : bufferPixels); + } + }); + } + + } + // Implications of tile_post_process.merge_polygons + var mergePolygons = postProcess.mergePolygons(); + if (mergePolygons != null) { + // postProcess.mergePolygons.tolerance* + processors.add(makePolygonFeatureProcessor(mergePolygons.tolerance(),Feature::setPixelTolerance)); + processors.add(makePolygonFeatureProcessor(mergePolygons.toleranceAtMaxZoom(),Feature::setPixelToleranceAtMaxZoom)); + // TODO: postProcess.mergeLineStrings.minArea* + } + } + private BiConsumer makeFeatureProcessor(Object input, Class clazz, BiConsumer consumer) { if (input == null) { @@ -137,6 +167,30 @@ public class ConfiguredFeature { }; } + private BiConsumer makeLineFeatureProcessor(Double input, + ObjDoubleConsumer consumer) { + if (input == null) { + return null; + } + return (context, feature) -> { + if (feature.isLine()) { + consumer.accept(feature, input); + } + }; + } + + private BiConsumer makePolygonFeatureProcessor(Double input, + ObjDoubleConsumer consumer) { + if (input == null) { + return null; + } + return (context, feature) -> { + if (feature.isPolygon()) { + consumer.accept(feature, input); + } + }; + } + private static int minZoomFromTilePercent(SourceFeature sf, Double minTilePercent) { if (minTilePercent == null) { return 0; @@ -307,4 +361,10 @@ public class ConfiguredFeature { processor.accept(context, f); } } + + private Double maxIgnoringNulls(Double a, Double b) { + if (a == null) return b; + if (b == null) return a; + return Double.max(a, b); + } } diff --git a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/ConfiguredProfile.java b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/ConfiguredProfile.java index 5e84c8ee..ea0af52d 100644 --- a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/ConfiguredProfile.java +++ b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/ConfiguredProfile.java @@ -18,6 +18,7 @@ import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; /** * A profile configured from a yml file. @@ -88,21 +89,38 @@ public class ConfiguredProfile implements Profile { return items; } + var config = rootContext.config(); if (featureLayer.postProcess().mergeLineStrings() != null) { var merge = featureLayer.postProcess().mergeLineStrings(); + var minLength = Objects.requireNonNullElse( + (zoom == config.maxzoomForRendering()) ? + merge.minLengthAtMaxZoom() : + merge.minLength(), + config.minFeatureSize(zoom)); + var tolerance = Objects.requireNonNullElse( + (zoom == config.maxzoomForRendering()) ? + merge.toleranceAtMaxZoom() : + merge.tolerance(), + config.tolerance(zoom)); + var buffer = Objects.requireNonNullElse(merge.buffer(), 4.0); items = FeatureMerge.mergeLineStrings(items, - merge.minLength(), // after merging, remove lines that are still less than {minLength}px long - merge.tolerance(), // simplify output linestrings using a {tolerance}px tolerance - merge.buffer() // remove any detail more than {buffer}px outside the tile boundary + minLength, // after merging, remove lines that are still less than {minLength}px long + tolerance, // simplify output linestrings using a {tolerance}px tolerance + buffer // remove any detail more than {buffer}px outside the tile boundary ); } - if (featureLayer.postProcess().mergePolygons() != null) { - var merge = featureLayer.postProcess().mergePolygons(); + var merge = featureLayer.postProcess().mergePolygons(); + if (merge != null) { + var minArea = Objects.requireNonNullElse( + (zoom == config.maxzoomForRendering()) ? + merge.minAreaAtMaxZoom() : + merge.minArea(), + config.minFeatureSize(zoom)*config.minFeatureSize(zoom)); items = FeatureMerge.mergeOverlappingPolygons(items, - merge.minArea() // after merging, remove polygons that are still less than {minArea} in square tile pixels + minArea // after merging, remove polygons that are still less than {minArea} in square tile pixels ); } diff --git a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/configschema/FeatureItem.java b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/configschema/FeatureItem.java index 7f94bc87..426fb874 100644 --- a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/configschema/FeatureItem.java +++ b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/configschema/FeatureItem.java @@ -10,6 +10,9 @@ public record FeatureItem( @JsonProperty("min_zoom") Object minZoom, @JsonProperty("max_zoom") Object maxZoom, @JsonProperty("min_size") Object minSize, + @JsonProperty("min_size_at_max_zoom") Object minSizeAtMaxZoom, + @JsonProperty("tolerance") Object tolerance, + @JsonProperty("tolerance_at_max_zoom") Object toleranceAtMaxZoom, @JsonProperty FeatureGeometry geometry, @JsonProperty("include_when") Object includeWhen, @JsonProperty("exclude_when") Object excludeWhen, diff --git a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/configschema/MergeLineStrings.java b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/configschema/MergeLineStrings.java index d94b1f16..4d106862 100644 --- a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/configschema/MergeLineStrings.java +++ b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/configschema/MergeLineStrings.java @@ -3,7 +3,9 @@ package com.onthegomap.planetiler.custommap.configschema; import com.fasterxml.jackson.annotation.JsonProperty; public record MergeLineStrings( - @JsonProperty("min_length") double minLength, - @JsonProperty("tolerance") double tolerance, - @JsonProperty("buffer") double buffer + @JsonProperty("min_length") Double minLength, + @JsonProperty("min_length_at_max_zoom") Double minLengthAtMaxZoom, + @JsonProperty("tolerance") Double tolerance, + @JsonProperty("tolerance_at_max_zoom") Double toleranceAtMaxZoom, + @JsonProperty("buffer") Double buffer ) {} diff --git a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/configschema/MergePolygons.java b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/configschema/MergePolygons.java index b0f06b8c..950b0953 100644 --- a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/configschema/MergePolygons.java +++ b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/configschema/MergePolygons.java @@ -3,5 +3,8 @@ package com.onthegomap.planetiler.custommap.configschema; import com.fasterxml.jackson.annotation.JsonProperty; public record MergePolygons( - @JsonProperty("min_area") double minArea + @JsonProperty("min_area") Double minArea, + @JsonProperty("min_area_at_max_zoom") Double minAreaAtMaxZoom, + @JsonProperty("tolerance") Double tolerance, + @JsonProperty("tolerance_at_max_zoom") Double toleranceAtMaxZoom ) {} diff --git a/planetiler-custommap/src/test/java/com/onthegomap/planetiler/custommap/ConfiguredFeatureTest.java b/planetiler-custommap/src/test/java/com/onthegomap/planetiler/custommap/ConfiguredFeatureTest.java index 355e91aa..960e849e 100644 --- a/planetiler-custommap/src/test/java/com/onthegomap/planetiler/custommap/ConfiguredFeatureTest.java +++ b/planetiler-custommap/src/test/java/com/onthegomap/planetiler/custommap/ConfiguredFeatureTest.java @@ -242,10 +242,12 @@ class ConfiguredFeatureTest { tile_post_process: merge_line_strings: min_length: 10 + min_length_at_max_zoom: 2 tolerance: 5 - buffer: 4 + buffer: 6 """, Map.of(), f -> { - assertEquals(10, f.getBufferPixelsAtZoom(14)); + assertEquals(10, f.getBufferPixelsAtZoom(12)); + assertEquals(6, f.getBufferPixelsAtZoom(14)); }, 1); } @@ -1250,20 +1252,84 @@ class ConfiguredFeatureTest { tile_post_process: merge_line_strings: min_length: 1 + min_length_at_max_zoom: 0.125 tolerance: 5 + tolerance_at_max_zoom: 0.0625 buffer: 10 """; this.planetilerConfig = PlanetilerConfig.from(Arguments.of(Map.of())); assertEquals(new PostProcess( new MergeLineStrings( - 1, - 5, - 10 + 1.0, + 0.125, + 5.0, + 0.0625, + 10.0 ), null ), loadConfig(config).findFeatureLayer("testLayer").postProcess()); } + @Test + void testSchemaPostProcessWithMergeLineStringsDefaults() { + var config = """ + sources: + osm: + type: osm + url: geofabrik:rhode-island + local_path: data/rhode-island.osm.pbf + layers: + - id: testLayer + features: + - source: osm + geometry: point + tile_post_process: + merge_line_strings: + buffer: 10 + """; + this.planetilerConfig = PlanetilerConfig.from(Arguments.of(Map.of())); + assertEquals(new PostProcess( + new MergeLineStrings( + null, + null, + null, + null, + 10.0 + ), + null + ), loadConfig(config).findFeatureLayer("testLayer").postProcess()); + } + + @Test + void testSchemaPostProcessMergePolygonsTolerance() { + var config = """ + sources: + osm: + type: osm + url: geofabrik:rhode-island + local_path: data/rhode-island.osm.pbf + layers: + - id: testLayer + features: + - source: osm + geometry: point + tile_post_process: + merge_polygons: + tolerance: 1.23 + tolerance_at_max_zoom: 0.123 + """; + this.planetilerConfig = PlanetilerConfig.from(Arguments.of(Map.of())); + assertEquals(new PostProcess( + null, + new MergePolygons( + null, + null, + 1.23, + 0.123 + ) + ), loadConfig(config).findFeatureLayer("testLayer").postProcess()); + } + @Test void testSchemaPostProcessWithMergePolygons() { var config = """ @@ -1280,12 +1346,16 @@ class ConfiguredFeatureTest { tile_post_process: merge_polygons: min_area: 3 + min_area_at_max_zoom: 1 """; this.planetilerConfig = PlanetilerConfig.from(Arguments.of(Map.of())); assertEquals(new PostProcess( null, new MergePolygons( - 3 + 3.0, + 1.0, + null, + null ) ), loadConfig(config).findFeatureLayer("testLayer").postProcess()); }