min_area_pixels is probably not the best measure. #36

Closed
opened 2026-08-02 07:41:29 +00:00 by art · 2 comments
Owner

We do a lot of filtering/dropping of polygons by their area. While this works most of the time, it's probably not the most correct measure. What we want to filter out is stuff that will be rendered too small.

In my head math, which is one level below napkin math, some kind of measure of area divided by circumference (or the other way around) might be more useful. I guess this would be relevant for rivers. But also landcover in Poland is very stripey and would be affected.

Explore what kind of measure would be better at filtering landcover and rivers/lakes so that things that are too thin get dropped.

We do a lot of filtering/dropping of polygons by their area. While this works most of the time, it's probably not the most correct measure. What we want to filter out is stuff that will be rendered too small. In my head math, which is one level below napkin math, some kind of measure of area divided by circumference (or the other way around) might be more useful. I guess this would be relevant for rivers. But also landcover in Poland is very stripey and would be affected. Explore what kind of measure would be better at filtering landcover and rivers/lakes so that things that are too thin get dropped.
Collaborator

Context from the #26/#20/#27 work (commit 3ae69c3), which ran into this from the river
side and left it deliberately undone. Everything below is measured, not reasoned.

Your napkin math is dimensionally right, and better than it looks

ST_Area / ST_Perimeter has units of length, and for the shapes we care about it is
approximately a quarter of the width: a long thin rectangle w x l with l >> w gives
A/P = wl / 2(w+l) ~= w/2, and a disc gives r/2. So 4A/P is an estimate of "how wide
is this thing"
, which is exactly the quantity that should be compared against a pixel.
That makes it a much better-founded measure than area, which conflates a 96 km² pond with a
96 km² river reach.

The obvious objection is that perimeter is fractal — Sweden's lakes are extremely crinkly,
so P is inflated and 4A/P should underestimate their width badly. Measured on Sweden's six
largest lakes at z9, against ST_MaximumInscribedCircle (PostGIS 3.6.2 here, so it is
available) as ground truth for width:

area km² perimeter km 4A/P inscribed diameter
521 164 12 720 m 17 691 m
459 178 10 337 m 10 594 m
422 306 5 528 m 5 688 m
402 304 5 290 m 6 077 m
395 107 14 777 m 13 442 m
386 147 10 472 m 10 819 m

Agreement is 2–28%, not orders of magnitude. The reason is structural and worth knowing:
the filter runs on the geometry from the zoom above, which is already simplified for that
zoom
(importLayerZ: sizeFilter tests geom, the source column, while geomExpr
simplifies into the output). Crinkle finer than the previous level's tolerance is already
gone, so P is bounded at every level rather than being the true fractal perimeter.
ST_MaximumInscribedCircle is the robust option if that ever stops holding, at real cost.

There are two decisions here, and they disagree

  • "too small to see" — the current area test. Right for a pond.
  • "too thin to see" — what you are proposing. Right for a stripe.

For a river they point in opposite directions, which is the trap. At z6 one pixel is 2 446 m
and the Vistula is roughly 400 m wide — 0.16 px. A thinness rule would therefore drop
the Vistula polygon at z6, which is defensible, but only if waterways carries a centreline
there so the river still appears. #20 was fixed in the other direction (min_area_pixels
16 -> 4 for water_bodies, so the polygon survives and renders as a hairline).

So #36 and #20 have to be settled together, and the acceptance test is "is the river still
visible", never "does the polygon still exist".
The cartographically standard answer is
area-to-line collapse — drop the polygon once it is sub-pixel wide and let the line layer
draw it — and the map is already shaped for that, since waterways exists and is now cheap
after the merge work. ST_ApproximateMedialAxis (SFCGAL) is the real collapse if it is ever
wanted; not needing it is why the layer split exists.

The threshold is one knob wired to two different mechanisms

This is the thing most likely to cause a surprise, because min_area_pixels does not mean
the same thing in both places (importLayerZ):

  • simplify (water_bodies) — sizeFilter is ST_Area(geom) > minArea, an outright
    drop. Relaxing it can only add features and can never open a hole, so a keep-more rule
    is monotone and safe to experiment with.
  • cover (landcover) — the threshold instead selects absorption candidates, and the
    semantic-rollup path deliberately passes area 0 because dropping from a coverage creates a
    hole. So a thinness measure there changes what gets recoloured into its neighbour, not
    what gets deleted. That is exactly your stripey-Poland case: a field strip does not
    disappear, it turns into whatever is beside it. Note it now also interacts with
    absorb_boundary (added in 3ae69c3), which already stops that happening across water.

Baseline to test a fix against

River polygon counts, per zoom, measured just before the change:

z9 z7 z6 z5
Poland at min_area_pixels: 16 463 15 0 0
Poland at 4 (now) 1 180 229 35 1
Sweden at 16 1 565 37 0 0
Sweden at 4 (now) 4 308 613 68 0
Iceland (always 4) 482 179 68 19

Sweden's cost for that change was 295 -> 1 270 lake polygons at z6, which a thinness rule
should be able to win back — most of those are small and round, so both measures agree they
can go.

Practical notes for whoever picks this up

  • -regeneralize <lo>-<hi> makes this cheap: Poland's water_bodies z5–9 rebuilds in 2 s
    and Sweden's in 13 s, so a measure can be tried at one zoom before committing to a cascade.
  • Do not use exact row counts as a regression test on cover — absorption is not
    row-for-row reproducible (CLAUDE.md, "Generalization strategies"). Compare orders of
    magnitude and class distributions.
  • Check all three countries. Iceland has never shown the river symptom because it was
    already at 4, so it is the reference for what correct looks like, not a test case.
Context from the #26/#20/#27 work (commit `3ae69c3`), which ran into this from the river side and left it deliberately undone. Everything below is measured, not reasoned. ### Your napkin math is dimensionally right, and better than it looks `ST_Area / ST_Perimeter` has units of **length**, and for the shapes we care about it is approximately a quarter of the width: a long thin rectangle `w x l` with `l >> w` gives `A/P = wl / 2(w+l) ~= w/2`, and a disc gives `r/2`. So **`4A/P` is an estimate of "how wide is this thing"**, which is exactly the quantity that should be compared against a pixel. That makes it a much better-founded measure than area, which conflates a 96 km² pond with a 96 km² river reach. The obvious objection is that perimeter is fractal — Sweden's lakes are extremely crinkly, so P is inflated and `4A/P` should underestimate their width badly. Measured on Sweden's six largest lakes at z9, against `ST_MaximumInscribedCircle` (PostGIS 3.6.2 here, so it is available) as ground truth for width: | area km² | perimeter km | `4A/P` | inscribed diameter | |---:|---:|---:|---:| | 521 | 164 | 12 720 m | 17 691 m | | 459 | 178 | 10 337 m | 10 594 m | | 422 | 306 | 5 528 m | 5 688 m | | 402 | 304 | 5 290 m | 6 077 m | | 395 | 107 | 14 777 m | 13 442 m | | 386 | 147 | 10 472 m | 10 819 m | Agreement is 2–28%, not orders of magnitude. The reason is structural and worth knowing: **the filter runs on the geometry from the zoom above, which is already simplified for that zoom** (`importLayerZ`: `sizeFilter` tests `geom`, the source column, while `geomExpr` simplifies into the output). Crinkle finer than the previous level's tolerance is already gone, so P is bounded at every level rather than being the true fractal perimeter. `ST_MaximumInscribedCircle` is the robust option if that ever stops holding, at real cost. ### There are two decisions here, and they disagree - **"too small to see"** — the current area test. Right for a pond. - **"too thin to see"** — what you are proposing. Right for a stripe. For a river they point in opposite directions, which is the trap. At z6 one pixel is 2 446 m and the Vistula is roughly 400 m wide — **0.16 px**. A thinness rule would therefore *drop* the Vistula polygon at z6, which is defensible, but only if `waterways` carries a centreline there so the river still appears. #20 was fixed in the other direction (`min_area_pixels` 16 -> 4 for `water_bodies`, so the polygon survives and renders as a hairline). **So #36 and #20 have to be settled together, and the acceptance test is "is the river still visible", never "does the polygon still exist".** The cartographically standard answer is area-to-line collapse — drop the polygon once it is sub-pixel wide and let the line layer draw it — and the map is already shaped for that, since `waterways` exists and is now cheap after the merge work. `ST_ApproximateMedialAxis` (SFCGAL) is the real collapse if it is ever wanted; not needing it is why the layer split exists. ### The threshold is one knob wired to two different mechanisms This is the thing most likely to cause a surprise, because `min_area_pixels` does not mean the same thing in both places (`importLayerZ`): - **`simplify`** (`water_bodies`) — `sizeFilter` is `ST_Area(geom) > minArea`, an outright **drop**. Relaxing it can only add features and can never open a hole, so a keep-more rule is monotone and safe to experiment with. - **`cover`** (landcover) — the threshold instead selects **absorption candidates**, and the semantic-rollup path deliberately passes area 0 because dropping from a coverage creates a hole. So a thinness measure there changes *what gets recoloured into its neighbour*, not what gets deleted. That is exactly your stripey-Poland case: a field strip does not disappear, it turns into whatever is beside it. Note it now also interacts with `absorb_boundary` (added in `3ae69c3`), which already stops that happening across water. ### Baseline to test a fix against River polygon counts, per zoom, measured just before the change: | | z9 | z7 | z6 | z5 | |---|---:|---:|---:|---:| | Poland at `min_area_pixels: 16` | 463 | 15 | 0 | 0 | | Poland at 4 (now) | 1 180 | 229 | 35 | 1 | | Sweden at 16 | 1 565 | 37 | 0 | 0 | | Sweden at 4 (now) | 4 308 | 613 | 68 | 0 | | Iceland (always 4) | 482 | 179 | 68 | 19 | Sweden's cost for that change was 295 -> 1 270 lake polygons at z6, which a thinness rule should be able to win back — most of those are small and round, so both measures agree they can go. ### Practical notes for whoever picks this up - `-regeneralize <lo>-<hi>` makes this cheap: Poland's `water_bodies` z5–9 rebuilds in 2 s and Sweden's in 13 s, so a measure can be tried at one zoom before committing to a cascade. - **Do not use exact row counts as a regression test on `cover`** — absorption is not row-for-row reproducible (CLAUDE.md, "Generalization strategies"). Compare orders of magnitude and class distributions. - Check all three countries. Iceland has never shown the river symptom because it was already at 4, so it is the reference for what correct looks like, not a test case.
Author
Owner

The river going through a removed polygon is ok if there is a centerline for that river through that polygon, but if it's considered a lake?

If this is a problem at all, I think it should be resolved early. Not as part of the generalization when the polygon disappears, but as part of the import - if we know that two linestrings are the same river (by name or some id) check if they all connect and if they don't create fake centerlines through any water polygons. But of course, if it rarely ever happens we probably shouldn't waste time doing this, so weigh the extra cost of doing this (if it's a trivial 10 second operation, we probably should always do it, if it's a 10 hour ordeal, we probably should never do it, no matter how bad the problem is).

The river going through a removed polygon is ok if there is a centerline for that river through that polygon, but if it's considered a lake? If this is a problem at all, I think it should be resolved early. Not as part of the generalization when the polygon disappears, but as part of the import - if we know that two linestrings are the same river (by name or some id) check if they all connect and if they don't create fake centerlines through any water polygons. But of course, if it rarely ever happens we probably shouldn't waste time doing this, so weigh the extra cost of doing this (if it's a trivial 10 second operation, we probably should always do it, if it's a 10 hour ordeal, we probably should never do it, no matter how bad the problem is).
art closed this issue 2026-08-03 08:34:30 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
art/ismap#36
No description provided.