Skip to content

Commit c98b415

Browse files
authored
Merge pull request #294 from statisticsnorway/map-hatches
Wms with polygon masks
2 parents 5fb4e66 + 7837284 commit c98b415

File tree

7 files changed

+4991
-2429
lines changed

7 files changed

+4991
-2429
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "ssb-sgis"
3-
version = "1.2.7"
3+
version = "1.2.8"
44
description = "GIS functions used at Statistics Norway."
55
authors = ["Morten Letnes <morten.letnes@ssb.no>"]
66
license = "MIT"

src/sgis/geopandas_tools/buffer_dissolve_explode.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,7 @@ def _run_func_by_cluster(
432432

433433
def get_group_clusters(group: GeoDataFrame):
434434
"""Adds cluster column. Applied to each group because much faster."""
435-
group = group.reset_index(drop=True)
435+
group = group[~group.is_empty].reset_index(drop=True)
436436
group["_cluster"] = get_cluster_mapper(group, predicate=predicate)
437437
group["_cluster"] = get_grouped_centroids(group, groupby="_cluster")
438438
return group

src/sgis/maps/explore.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -779,9 +779,13 @@ def _add_tiles(
779779
for tile in tiles:
780780
to_tile(tile, max_zoom=self.max_zoom).add_to(mapobj)
781781

782-
def _add_wms(self, map_: folium.Map, bbox: Any) -> None:
782+
def _add_wms(self, map_: folium.Map) -> None:
783+
try:
784+
gdf = self._gdf.to_crs(4326)
785+
except Exception:
786+
gdf = self._gdf.set_crs(4326)
783787
for wms in self.wms:
784-
tiles = wms.get_tiles(bbox, max_zoom=self.max_zoom)
788+
tiles = wms.get_tiles(gdf, max_zoom=self.max_zoom)
785789
for tile in tiles.values():
786790
map_.add_child(tile)
787791

@@ -995,7 +999,7 @@ def _make_folium_map(
995999
# folium.LayerControl(collapsed=False).add_to(m)
9961000

9971001
if self.wms:
998-
self._add_wms(m, bounds)
1002+
self._add_wms(m)
9991003

10001004
return m
10011005

src/sgis/maps/maps.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,10 +197,13 @@ def explore(
197197

198198
bounds: Polygon = box(*get_total_bounds(*gdfs, *list(kwargs.values())))
199199

200-
any_intersections: bool = mask.intersects(bounds).any()
200+
if bounds is not None:
201+
any_intersections: bool = mask.intersects(bounds).any()
202+
else:
203+
any_intersections = False
201204
if not any_intersections and to_crs is None:
202205
mask = to_gdf(Polygon(), to_crs)
203-
elif not any_intersections:
206+
elif not any_intersections and bounds is not None:
204207
bounds4326 = to_gdf(bounds, to_crs).to_crs(25833).geometry.iloc[0]
205208
mask4326 = mask.set_crs(4326, allow_override=True).to_crs(25833)
206209

src/sgis/maps/norge_i_bilder.json

Lines changed: 4792 additions & 2380 deletions
Large diffs are not rendered by default.

src/sgis/maps/wms.py

Lines changed: 159 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,33 @@
44
import re
55
from collections.abc import Iterable
66
from dataclasses import dataclass
7+
from io import BytesIO
78
from pathlib import Path
89
from typing import Any
910
from urllib.request import urlopen
1011

1112
import folium
13+
import numpy as np
14+
import pandas as pd
1215
import shapely
13-
16+
from geopandas import GeoDataFrame
17+
from geopandas import GeoSeries
18+
from shapely import Geometry
19+
from shapely import get_exterior_ring
20+
from shapely import make_valid
21+
from shapely import polygons
22+
from shapely import set_precision
23+
from shapely import simplify
24+
from shapely.errors import GEOSException
25+
26+
from ..geopandas_tools.conversion import to_gdf
1427
from ..geopandas_tools.conversion import to_shapely
28+
from ..geopandas_tools.sfilter import sfilter
29+
from ..raster.image_collection import Band
1530

1631
JSON_PATH = Path(__file__).parent / "norge_i_bilder.json"
1732

18-
JSON_YEARS = [str(year) for year in range(1999, 2025)]
33+
JSON_YEARS = [str(year) for year in range(2006, datetime.datetime.now().year + 1)]
1934

2035
DEFAULT_YEARS: tuple[str] = tuple(
2136
str(year)
@@ -57,7 +72,7 @@ class NorgeIBilderWms(WmsLoader):
5772
show: bool | Iterable[int] | int = False
5873
_use_json: bool = True
5974

60-
def load_tiles(self) -> None:
75+
def load_tiles(self, verbose: bool = False) -> None:
6176
"""Load all Norge i bilder tiles into self.tiles."""
6277
url = "https://wms.geonorge.no/skwms1/wms.nib-prosjekter?SERVICE=WMS&REQUEST=GetCapabilities"
6378

@@ -107,7 +122,10 @@ def load_tiles(self) -> None:
107122
this_tile["name"] = name
108123
this_tile["bbox"] = this_bbox
109124
year = name.split(" ")[-1]
110-
if year.isnumeric() and len(year) == 4:
125+
is_year_or_interval: bool = all(
126+
part.isnumeric() and len(part) == 4 for part in year.split("-")
127+
)
128+
if is_year_or_interval:
111129
this_tile["year"] = year
112130
else:
113131
this_tile["year"] = "9999"
@@ -116,41 +134,123 @@ def load_tiles(self) -> None:
116134

117135
self.tiles = sorted(all_tiles, key=lambda x: x["year"])
118136

119-
def get_tiles(self, bbox: Any, max_zoom: int = 40) -> list[folium.WmsTileLayer]:
120-
"""Get all Norge i bilder tiles intersecting with a bbox."""
137+
masks = self._get_norge_i_bilder_polygon_masks(verbose=verbose)
138+
for tile in self.tiles:
139+
mask = masks.get(tile["name"], None)
140+
tile["geometry"] = mask
141+
142+
def _get_norge_i_bilder_polygon_masks(self, verbose: bool):
143+
from owslib.util import ServiceException
144+
from owslib.wms import WebMapService
145+
from PIL import Image
146+
147+
relevant_names: dict[str, str] = {x["name"]: x["bbox"] for x in self.tiles}
148+
assert len(relevant_names), relevant_names
149+
150+
url = "https://wms.geonorge.no/skwms1/wms.nib-mosaikk?SERVICE=WMS&REQUEST=GetCapabilities"
151+
wms = WebMapService(url, version="1.3.0")
152+
out = {}
153+
# ttiles = {wms[layer].title: [] for layer in list(wms.contents)}
154+
# for layer in list(wms.contents):
155+
# if wms[layer].title not in relevant_names:
156+
# continue
157+
# ttiles[wms[layer].title].append(layer)
158+
# import pandas as pd
159+
160+
# df = pd.Series(ttiles).to_frame("title")
161+
# df["n"] = df["title"].str.len()
162+
# df = df.sort_values("n")
163+
# for x in df["title"]:
164+
# if len(x) == 1:
165+
# continue
166+
# bounds = {tuple(wms[layer].boundingBoxWGS84) for layer in x}
167+
# if len(bounds) <= 1:
168+
# continue
169+
# print()
170+
# for layer in x:
171+
# print(layer)
172+
# print(wms[layer].title)
173+
# bbox = wms[layer].boundingBoxWGS84
174+
# print(bbox)
175+
176+
for layer in list(wms.contents):
177+
title = wms[layer].title
178+
if title not in relevant_names:
179+
continue
180+
bbox = wms[layer].boundingBoxWGS84
181+
bbox = tuple(to_gdf(bbox, crs=4326).to_crs(25832).total_bounds)
182+
183+
existing_bbox = relevant_names[title]
184+
existing_bbox = to_gdf(existing_bbox, crs=4326).to_crs(25832).union_all()
185+
if not to_shapely(bbox).intersects(existing_bbox):
186+
continue
187+
diffx = bbox[2] - bbox[0]
188+
diffy = bbox[3] - bbox[1]
189+
width = int(diffx / 40)
190+
height = int(diffy / 40)
191+
if not bbox:
192+
continue
193+
try:
194+
img = wms.getmap(
195+
layers=[layer],
196+
styles=[""], # Empty unless you know the style
197+
srs="EPSG:25832",
198+
bbox=bbox,
199+
size=(width, height),
200+
format="image/jpeg",
201+
transparent=True,
202+
bgcolor="#FFFFFF",
203+
)
204+
except (ServiceException, AttributeError) as e:
205+
if verbose:
206+
print(type(e), e)
207+
continue
208+
209+
arr = np.array(Image.open(BytesIO(img.read())))
210+
if not np.sum(arr):
211+
continue
212+
213+
band = Band(
214+
np.where(np.any(arr != 0, axis=-1), 1, 0), bounds=bbox, crs=25832
215+
)
216+
polygon = band.to_geopandas()[lambda x: x["value"] == 1].geometry.values
217+
polygon = make_valid(polygons(get_exterior_ring(polygon)))
218+
polygon = make_valid(set_precision(polygon, 1))
219+
polygon = make_valid(simplify(polygon, 100))
220+
polygon = make_valid(set_precision(polygon, 1))
221+
polygon = GeoSeries(polygon, crs=25832).to_crs(4326)
222+
if verbose:
223+
print(f"Layer name: {layer}")
224+
print(f"Title: {wms[layer].title}")
225+
print(f"Bounding box: {wms[layer].boundingBoxWGS84}")
226+
print(f"polygon: {polygon}")
227+
print("-" * 40)
228+
229+
for x in [0, 0.1, 0.001, 1]:
230+
try:
231+
out[title] = make_valid(polygon.buffer(x).make_valid().union_all())
232+
except GEOSException:
233+
pass
234+
break
235+
236+
return out
237+
238+
def get_tiles(self, mask: Any, max_zoom: int = 40) -> list[folium.WmsTileLayer]:
239+
"""Get all Norge i bilder tiles intersecting with a mask (bbox or polygon)."""
121240
if self.tiles is None:
122241
self.load_tiles()
123242

124-
all_tiles = {}
125-
126-
bbox = to_shapely(bbox)
243+
if not isinstance(mask, (GeoSeries | GeoDataFrame | Geometry)):
244+
mask = to_shapely(mask)
127245

128246
if isinstance(self.show, bool):
129247
show = self.show
130248
else:
131249
show = False
132250

133-
for tile in self.tiles:
134-
if not tile["bbox"] or not tile["bbox"].intersects(bbox):
135-
continue
136-
137-
name = tile["name"]
138-
139-
if (
140-
not name
141-
or not any(year in name for year in self.years)
142-
or (
143-
self.contains
144-
and not any(re.search(x, name.lower()) for x in self.contains)
145-
)
146-
or (
147-
self.not_contains
148-
and any(re.search(x, name.lower()) for x in self.not_contains)
149-
)
150-
):
151-
continue
152-
153-
all_tiles[name] = folium.WmsTileLayer(
251+
relevant_tiles = self._filter_tiles(mask)
252+
tile_layers = {
253+
name: folium.WmsTileLayer(
154254
url="https://wms.geonorge.no/skwms1/wms.nib-prosjekter",
155255
name=name,
156256
layers=name,
@@ -161,16 +261,37 @@ def get_tiles(self, bbox: Any, max_zoom: int = 40) -> list[folium.WmsTileLayer]:
161261
show=show,
162262
max_zoom=max_zoom,
163263
)
264+
for name in relevant_tiles["name"]
265+
}
266+
267+
if not len(tile_layers):
268+
return tile_layers
164269

165270
if isinstance(self.show, int):
166-
tile = all_tiles[list(all_tiles)[self.show]]
271+
tile = tile_layers[list(tile_layers)[self.show]]
167272
tile.show = True
168273
elif isinstance(self.show, Iterable):
169274
for i in self.show:
170-
tile = all_tiles[list(all_tiles)[i]]
275+
tile = tile_layers[list(tile_layers)[i]]
171276
tile.show = True
172277

173-
return all_tiles
278+
return tile_layers
279+
280+
def _filter_tiles(self, mask):
281+
"""Filter relevant dates with pandas and geopandas because fast."""
282+
df = pd.DataFrame(self.tiles)
283+
filt = (df["name"].notna()) & (df["year"].str.contains("|".join(self.years)))
284+
if self.contains:
285+
for x in self.contains:
286+
filt &= df["name"].str.contains(x)
287+
if self.not_contains:
288+
for x in self.not_contains:
289+
filt &= ~df["name"].str.contains(x)
290+
df = df[filt]
291+
geoms = np.where(df["geometry"].notna(), df["geometry"], df["bbox"])
292+
geoms = GeoSeries(geoms)
293+
assert geoms.index.is_unique
294+
return df.iloc[sfilter(geoms, mask).index]
174295

175296
def __post_init__(self) -> None:
176297
"""Fix typings."""
@@ -195,7 +316,11 @@ def __post_init__(self) -> None:
195316
return
196317
self.tiles = [
197318
{
198-
key: value if key != "bbox" else shapely.wkt.loads(value)
319+
key: (
320+
value
321+
if key not in ["bbox", "geometry"]
322+
else shapely.wkt.loads(value)
323+
)
199324
for key, value in tile.items()
200325
}
201326
for tile in self.tiles

tests/test_explore.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
# %%
2-
import datetime
32
import json
43
import os
54
import warnings
@@ -117,10 +116,19 @@ def test_explore(points_oslo, roads_oslo):
117116
roads["geometry"] = roads.buffer(3)
118117

119118
r300 = roads.clip(p.buffer(300))
120-
121119
r200 = roads.clip(p.buffer(200))
122120
r100 = roads.clip(p.buffer(100))
123121

122+
e = sg.explore(
123+
r300,
124+
"meters",
125+
r100,
126+
bygdoy=7000,
127+
wms=sg.NorgeIBilderWms(
128+
years=sg.maps.wms.JSON_YEARS, not_contains="sentinel", show=-1
129+
),
130+
)
131+
assert isinstance(next(iter(e.wms)), sg.NorgeIBilderWms)
124132
e = sg.explore(
125133
r300,
126134
"meters",
@@ -285,10 +293,13 @@ def not_test_wms_json():
285293
print(
286294
"IMPORTANT: if you run this function, make sure to change the global variable JSON_YEARS in wms.py"
287295
)
288-
wms = sg.NorgeIBilderWms(
289-
years=range(1999, datetime.datetime.now().year + 1), _use_json=False
290-
)
291-
wms.load_tiles()
296+
years = sg.maps.wms.JSON_YEARS
297+
wms = sg.NorgeIBilderWms(years=years, _use_json=False)
298+
wms.load_tiles(verbose=True)
299+
300+
with open(sg.maps.wms.JSON_PATH) as file:
301+
print(pd.DataFrame(file).year.value_counts())
302+
292303
try:
293304
os.remove(sg.maps.wms.JSON_PATH)
294305
except FileNotFoundError:
@@ -297,7 +308,7 @@ def not_test_wms_json():
297308
json.dump(
298309
[
299310
{
300-
key: value if key != "bbox" else value.wkt
311+
key: value if key not in ["bbox", "geometry"] else value.wkt
301312
for key, value in tile.items()
302313
}
303314
for tile in wms.tiles
@@ -306,16 +317,23 @@ def not_test_wms_json():
306317
ensure_ascii=False,
307318
)
308319

320+
wms = sg.NorgeIBilderWms(years=years)
321+
for tile in wms.tiles:
322+
bbox = sg.to_shapely(tile["bbox"])
323+
polygon = sg.to_shapely(tile["geometry"])
324+
assert bbox.intersects(polygon)
325+
309326

310327
def main():
311328

312329
from oslo import points_oslo
313330
from oslo import roads_oslo
314331

315-
not_test_wms_json()
332+
# not_test_wms_json()
316333

317334
test_explore(points_oslo(), roads_oslo())
318335
test_image_collection()
336+
319337
# not_test_explore(points_oslo(), roads_oslo())
320338

321339

0 commit comments

Comments
 (0)