watercolourmap 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- watercolourmap-0.1.0/LICENSE +25 -0
- watercolourmap-0.1.0/PKG-INFO +104 -0
- watercolourmap-0.1.0/README.md +77 -0
- watercolourmap-0.1.0/pyproject.toml +42 -0
- watercolourmap-0.1.0/setup.cfg +4 -0
- watercolourmap-0.1.0/src/watercolourmap/__init__.py +23 -0
- watercolourmap-0.1.0/src/watercolourmap/brush.py +92 -0
- watercolourmap-0.1.0/src/watercolourmap/cli.py +72 -0
- watercolourmap-0.1.0/src/watercolourmap/fonts/FreeSerif-LICENSE.txt +99 -0
- watercolourmap-0.1.0/src/watercolourmap/fonts/FreeSerifItalic.ttf +0 -0
- watercolourmap-0.1.0/src/watercolourmap/fonts/Lora-Italic-Variable.ttf +0 -0
- watercolourmap-0.1.0/src/watercolourmap/fonts/Lora-OFL.txt +93 -0
- watercolourmap-0.1.0/src/watercolourmap/legends.py +59 -0
- watercolourmap-0.1.0/src/watercolourmap/poster.py +203 -0
- watercolourmap-0.1.0/src/watercolourmap/shapes.py +41 -0
- watercolourmap-0.1.0/src/watercolourmap.egg-info/PKG-INFO +104 -0
- watercolourmap-0.1.0/src/watercolourmap.egg-info/SOURCES.txt +19 -0
- watercolourmap-0.1.0/src/watercolourmap.egg-info/dependency_links.txt +1 -0
- watercolourmap-0.1.0/src/watercolourmap.egg-info/entry_points.txt +2 -0
- watercolourmap-0.1.0/src/watercolourmap.egg-info/requires.txt +7 -0
- watercolourmap-0.1.0/src/watercolourmap.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eriola Trungu Impersimi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
The fonts in src/watercolourmap/fonts are third-party and keep their own
|
|
24
|
+
licences: Lora (SIL Open Font License 1.1, see Lora-OFL.txt) and FreeSerif
|
|
25
|
+
(GNU GPL with font exception, see FreeSerif-LICENSE.txt).
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: watercolourmap
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Watercolour maps in Python, inspired by John Nelson's watercolour style for ArcGIS Pro
|
|
5
|
+
Author: Eriola Trungu Impersimi
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://etymo.co.uk/cartography/
|
|
8
|
+
Keywords: cartography,maps,watercolour,watercolor,geopandas,land cover,urban atlas,copernicus
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
12
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
License-File: src/watercolourmap/fonts/FreeSerif-LICENSE.txt
|
|
18
|
+
License-File: src/watercolourmap/fonts/Lora-OFL.txt
|
|
19
|
+
Requires-Dist: geopandas>=1.0
|
|
20
|
+
Requires-Dist: pyogrio>=0.9
|
|
21
|
+
Requires-Dist: shapely>=2.0
|
|
22
|
+
Requires-Dist: rasterio>=1.3
|
|
23
|
+
Requires-Dist: scipy>=1.11
|
|
24
|
+
Requires-Dist: numpy>=1.26
|
|
25
|
+
Requires-Dist: matplotlib>=3.8
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# watercolourmap
|
|
29
|
+
|
|
30
|
+
Watercolour maps in Python. Polygons are painted as soft washes on textured paper, with uneven pigment, darker edges and a thin line of bare paper between neighbouring shapes.
|
|
31
|
+
|
|
32
|
+
It's inspired by John Nelson's [watercolour style for ArcGIS Pro](https://www.esri.com/arcgis-blog/products/arcgis-pro/mapping/steal-this-watercolor-style-for-pro-please), recreated in code so it works with any GeoDataFrame. You can see example prints at [etymo.co.uk/cartography](https://etymo.co.uk/cartography/).
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install watercolourmap
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Paint any polygons
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import geopandas as gpd
|
|
44
|
+
import matplotlib.pyplot as plt
|
|
45
|
+
from watercolourmap import paint_gdf, PAPER
|
|
46
|
+
|
|
47
|
+
gdf = gpd.read_file("my_area.gpkg")
|
|
48
|
+
gdf = gdf.to_crs(gdf.estimate_utm_crs()) # needs a CRS in metres
|
|
49
|
+
|
|
50
|
+
img, extent = paint_gdf(
|
|
51
|
+
gdf, "landuse",
|
|
52
|
+
{"residential": "#c9a27e", "park": "#7a9a5a", "water": "#5f86b0"},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
fig, ax = plt.subplots(figsize=(8, 8), facecolor=PAPER)
|
|
56
|
+
ax.imshow(img, extent=extent)
|
|
57
|
+
ax.set_axis_off()
|
|
58
|
+
fig.savefig("map.png", dpi=300, facecolor=PAPER)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- Polygons whose value isn't in the colour dictionary are left as bare paper.
|
|
62
|
+
- `pixel_size` sets the resolution in metres per pixel. By default the longer side is about 3,000 pixels.
|
|
63
|
+
- `seed` changes the brushwork.
|
|
64
|
+
|
|
65
|
+
## Print posters from Copernicus Urban Atlas
|
|
66
|
+
|
|
67
|
+
The command line tool turns a [Copernicus Urban Atlas](https://land.copernicus.eu/en/products/urban-atlas) city file into a print-ready poster. You get a title, the painted map, a legend painted with the same brush, a scale bar and the data credit, saved as PDF and PNG.
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# An irregular circle, 1.8 km radius, with place labels
|
|
71
|
+
watercolourmap ELBASAN.fgb --center 20.097 41.106 --radius 1.8 \
|
|
72
|
+
--title "Elbasan Land Cover" \
|
|
73
|
+
--label "Kalaja|20.080|41.1125" --label "Lumi Shkumbin|20.0975|41.1006|-4" \
|
|
74
|
+
--paper A2 --out elbasan
|
|
75
|
+
|
|
76
|
+
# A 12 km rectangle with a hand-torn edge, and the sea painted in
|
|
77
|
+
watercolourmap ATHINA.fgb --center 23.688 37.958 --width 12 --sea \
|
|
78
|
+
--title "Athens Land Cover" --paper A2 --out athens
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Run `watercolourmap --help` for every option: `--bbox` or `--clip` for other areas, `--signature` and `--website` to sign your map, and `--dpi 100` for quick drafts.
|
|
82
|
+
|
|
83
|
+
Urban Atlas codes are grouped into seven classes: Blue, Green, Green-Grey and Grey Cover, Land Use Changing, Unknown and Other Natural. Roads and railways are left as bare paper. Urban Atlas maps land use rather than land cover, so some groupings are judgement calls. For example, marinas are coded as sports and leisure. You can see the full mapping in `urban_atlas_class()`.
|
|
84
|
+
|
|
85
|
+
Keep the credit on your maps. The tool adds it by default: *Contains modified Copernicus Land Monitoring Service information (Urban Atlas 2021) © European Union*.
|
|
86
|
+
|
|
87
|
+
## Building blocks
|
|
88
|
+
|
|
89
|
+
| Function | What it does |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `paint_gdf(gdf, column, colours)` | Paints any polygons and returns an image and its extent |
|
|
92
|
+
| `poster(gdf, legend, out, ...)` | Lays out a full poster from polygons with integer classes |
|
|
93
|
+
| `paint(classes, colours, pixel_size)` | Paints a raster of class ids |
|
|
94
|
+
| `irregular_circle(gdf, centre, radius)` | Keeps whole parcels inside a circle, for a ragged, parcel-shaped edge |
|
|
95
|
+
| `read_urban_atlas(path, bbox)` | Reads an Urban Atlas file and adds the legend class |
|
|
96
|
+
| `IGNITION` | The default seven-class legend and colours |
|
|
97
|
+
|
|
98
|
+
## Credits
|
|
99
|
+
|
|
100
|
+
- **Watercolour style:** inspired by John Nelson (Esri).
|
|
101
|
+
- **Legend:** from the University of Manchester IGNITION project land cover map.
|
|
102
|
+
- **Fonts:** Lora (SIL Open Font License) and FreeSerif (GPL with font exception) are bundled.
|
|
103
|
+
|
|
104
|
+
MIT licence © 2026 Eriola Trungu Impersimi
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# watercolourmap
|
|
2
|
+
|
|
3
|
+
Watercolour maps in Python. Polygons are painted as soft washes on textured paper, with uneven pigment, darker edges and a thin line of bare paper between neighbouring shapes.
|
|
4
|
+
|
|
5
|
+
It's inspired by John Nelson's [watercolour style for ArcGIS Pro](https://www.esri.com/arcgis-blog/products/arcgis-pro/mapping/steal-this-watercolor-style-for-pro-please), recreated in code so it works with any GeoDataFrame. You can see example prints at [etymo.co.uk/cartography](https://etymo.co.uk/cartography/).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install watercolourmap
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Paint any polygons
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import geopandas as gpd
|
|
17
|
+
import matplotlib.pyplot as plt
|
|
18
|
+
from watercolourmap import paint_gdf, PAPER
|
|
19
|
+
|
|
20
|
+
gdf = gpd.read_file("my_area.gpkg")
|
|
21
|
+
gdf = gdf.to_crs(gdf.estimate_utm_crs()) # needs a CRS in metres
|
|
22
|
+
|
|
23
|
+
img, extent = paint_gdf(
|
|
24
|
+
gdf, "landuse",
|
|
25
|
+
{"residential": "#c9a27e", "park": "#7a9a5a", "water": "#5f86b0"},
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
fig, ax = plt.subplots(figsize=(8, 8), facecolor=PAPER)
|
|
29
|
+
ax.imshow(img, extent=extent)
|
|
30
|
+
ax.set_axis_off()
|
|
31
|
+
fig.savefig("map.png", dpi=300, facecolor=PAPER)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- Polygons whose value isn't in the colour dictionary are left as bare paper.
|
|
35
|
+
- `pixel_size` sets the resolution in metres per pixel. By default the longer side is about 3,000 pixels.
|
|
36
|
+
- `seed` changes the brushwork.
|
|
37
|
+
|
|
38
|
+
## Print posters from Copernicus Urban Atlas
|
|
39
|
+
|
|
40
|
+
The command line tool turns a [Copernicus Urban Atlas](https://land.copernicus.eu/en/products/urban-atlas) city file into a print-ready poster. You get a title, the painted map, a legend painted with the same brush, a scale bar and the data credit, saved as PDF and PNG.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# An irregular circle, 1.8 km radius, with place labels
|
|
44
|
+
watercolourmap ELBASAN.fgb --center 20.097 41.106 --radius 1.8 \
|
|
45
|
+
--title "Elbasan Land Cover" \
|
|
46
|
+
--label "Kalaja|20.080|41.1125" --label "Lumi Shkumbin|20.0975|41.1006|-4" \
|
|
47
|
+
--paper A2 --out elbasan
|
|
48
|
+
|
|
49
|
+
# A 12 km rectangle with a hand-torn edge, and the sea painted in
|
|
50
|
+
watercolourmap ATHINA.fgb --center 23.688 37.958 --width 12 --sea \
|
|
51
|
+
--title "Athens Land Cover" --paper A2 --out athens
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Run `watercolourmap --help` for every option: `--bbox` or `--clip` for other areas, `--signature` and `--website` to sign your map, and `--dpi 100` for quick drafts.
|
|
55
|
+
|
|
56
|
+
Urban Atlas codes are grouped into seven classes: Blue, Green, Green-Grey and Grey Cover, Land Use Changing, Unknown and Other Natural. Roads and railways are left as bare paper. Urban Atlas maps land use rather than land cover, so some groupings are judgement calls. For example, marinas are coded as sports and leisure. You can see the full mapping in `urban_atlas_class()`.
|
|
57
|
+
|
|
58
|
+
Keep the credit on your maps. The tool adds it by default: *Contains modified Copernicus Land Monitoring Service information (Urban Atlas 2021) © European Union*.
|
|
59
|
+
|
|
60
|
+
## Building blocks
|
|
61
|
+
|
|
62
|
+
| Function | What it does |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `paint_gdf(gdf, column, colours)` | Paints any polygons and returns an image and its extent |
|
|
65
|
+
| `poster(gdf, legend, out, ...)` | Lays out a full poster from polygons with integer classes |
|
|
66
|
+
| `paint(classes, colours, pixel_size)` | Paints a raster of class ids |
|
|
67
|
+
| `irregular_circle(gdf, centre, radius)` | Keeps whole parcels inside a circle, for a ragged, parcel-shaped edge |
|
|
68
|
+
| `read_urban_atlas(path, bbox)` | Reads an Urban Atlas file and adds the legend class |
|
|
69
|
+
| `IGNITION` | The default seven-class legend and colours |
|
|
70
|
+
|
|
71
|
+
## Credits
|
|
72
|
+
|
|
73
|
+
- **Watercolour style:** inspired by John Nelson (Esri).
|
|
74
|
+
- **Legend:** from the University of Manchester IGNITION project land cover map.
|
|
75
|
+
- **Fonts:** Lora (SIL Open Font License) and FreeSerif (GPL with font exception) are bundled.
|
|
76
|
+
|
|
77
|
+
MIT licence © 2026 Eriola Trungu Impersimi
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "watercolourmap"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Watercolour maps in Python, inspired by John Nelson's watercolour style for ArcGIS Pro"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "Eriola Trungu Impersimi" }]
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE", "src/watercolourmap/fonts/*.txt"]
|
|
14
|
+
keywords = ["cartography", "maps", "watercolour", "watercolor", "geopandas", "land cover", "urban atlas", "copernicus"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Scientific/Engineering :: GIS",
|
|
19
|
+
"Topic :: Multimedia :: Graphics",
|
|
20
|
+
"Intended Audience :: Science/Research",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"geopandas>=1.0",
|
|
24
|
+
"pyogrio>=0.9",
|
|
25
|
+
"shapely>=2.0",
|
|
26
|
+
"rasterio>=1.3",
|
|
27
|
+
"scipy>=1.11",
|
|
28
|
+
"numpy>=1.26",
|
|
29
|
+
"matplotlib>=3.8",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://etymo.co.uk/cartography/"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
watercolourmap = "watercolourmap.cli:main"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
where = ["src"]
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.package-data]
|
|
42
|
+
watercolourmap = ["fonts/*"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""watercolourmap: watercolour maps in Python, inspired by John Nelson's watercolour style for ArcGIS Pro.
|
|
2
|
+
|
|
3
|
+
Quick use with any polygons:
|
|
4
|
+
|
|
5
|
+
import geopandas as gpd, matplotlib.pyplot as plt
|
|
6
|
+
from watercolourmap import paint_gdf
|
|
7
|
+
|
|
8
|
+
gdf = gpd.read_file("parks_and_water.gpkg")
|
|
9
|
+
gdf = gdf.to_crs(gdf.estimate_utm_crs())
|
|
10
|
+
img, extent = paint_gdf(gdf, "type", {"park": "#6b9954", "water": "#6b8cbd"})
|
|
11
|
+
plt.imshow(img, extent=extent); plt.axis("off"); plt.savefig("map.png", dpi=300)
|
|
12
|
+
|
|
13
|
+
Print posters from Copernicus Urban Atlas files: see `watercolourmap --help`.
|
|
14
|
+
"""
|
|
15
|
+
from .brush import paint, swatch, deckle, PAPER
|
|
16
|
+
from .legends import IGNITION, URBAN_ATLAS_CREDIT, urban_atlas_class, read_urban_atlas
|
|
17
|
+
from .poster import paint_gdf, poster, map_box_aspect
|
|
18
|
+
from .shapes import local_crs, irregular_circle, rectangle, wobbly_circle
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
__all__ = ["paint", "swatch", "deckle", "PAPER", "IGNITION", "URBAN_ATLAS_CREDIT",
|
|
22
|
+
"urban_atlas_class", "read_urban_atlas", "paint_gdf", "poster", "map_box_aspect",
|
|
23
|
+
"local_crs", "irregular_circle", "rectangle", "wobbly_circle"]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""The watercolour engine: turns a raster of class ids into a painted RGB image."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
from scipy import ndimage as ndi
|
|
4
|
+
|
|
5
|
+
PAPER = np.array([0.985, 0.972, 0.925], np.float32)
|
|
6
|
+
EMPTY = 255 # class id for "no data": stays bare paper
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def fbm(shape, rng, scales=(160, 60, 22, 8), weights=(0.45, 0.3, 0.17, 0.08)):
|
|
10
|
+
"""Smooth multi-octave noise in [0, 1], made at low resolution and upsampled."""
|
|
11
|
+
h, w = shape
|
|
12
|
+
out = np.zeros(shape, np.float32)
|
|
13
|
+
for s, wt in zip(scales, weights):
|
|
14
|
+
lh, lw = max(2, h // s + 2), max(2, w // s + 2)
|
|
15
|
+
n = rng.standard_normal((lh, lw)).astype(np.float32)
|
|
16
|
+
n = ndi.zoom(n, (h / lh * 1.0001, w / lw * 1.0001), order=3)[:h, :w]
|
|
17
|
+
out += wt * n
|
|
18
|
+
out -= out.min()
|
|
19
|
+
out /= max(out.max(), 1e-6)
|
|
20
|
+
return out
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def paint(classes, colours, pixel_size=2.5, seed=7, paper=PAPER):
|
|
24
|
+
"""Paint a class raster as watercolour.
|
|
25
|
+
|
|
26
|
+
classes 2-D integer array of class ids (EMPTY = bare paper)
|
|
27
|
+
colours {class_id: (r, g, b)} with values 0-1
|
|
28
|
+
pixel_size ground size of a pixel in metres; effects are scaled to it so
|
|
29
|
+
the brush looks the same at any resolution
|
|
30
|
+
Returns an (h, w, 3) float array in 0-1.
|
|
31
|
+
"""
|
|
32
|
+
rng = np.random.default_rng(seed)
|
|
33
|
+
h, w = classes.shape
|
|
34
|
+
k = max(1.0, 2.5 / pixel_size)
|
|
35
|
+
|
|
36
|
+
# paper with a faint fibre texture
|
|
37
|
+
fibre = fbm((h, w), rng, scales=(40, 6, 2), weights=(0.3, 0.4, 0.3))
|
|
38
|
+
img = np.empty((h, w, 3), np.float32)
|
|
39
|
+
img[:] = paper
|
|
40
|
+
img *= (0.985 + 0.03 * fibre)[..., None]
|
|
41
|
+
|
|
42
|
+
# one shared wobble field, so neighbouring washes keep their gaps consistent
|
|
43
|
+
dx = (fbm((h, w), rng, scales=(90, 30)) - 0.5) * 6 * k
|
|
44
|
+
dy = (fbm((h, w), rng, scales=(90, 30)) - 0.5) * 6 * k
|
|
45
|
+
yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
|
|
46
|
+
coords = np.array([yy + dy, xx + dx])
|
|
47
|
+
del yy, xx
|
|
48
|
+
|
|
49
|
+
for cid, col in colours.items():
|
|
50
|
+
m = (classes == cid).astype(np.float32)
|
|
51
|
+
if not m.any():
|
|
52
|
+
continue
|
|
53
|
+
m = ndi.map_coordinates(m, coords, order=1, mode="nearest") # hand-drawn edge
|
|
54
|
+
m = ndi.gaussian_filter(m, 0.9 * k)
|
|
55
|
+
m = np.clip((m - 0.35) / 0.4, 0, 1) # slight shrink: bare paper between parcels
|
|
56
|
+
edge = np.clip(m - ndi.gaussian_filter(m, 3.0 * k), 0, 1) # pigment pooling at edges
|
|
57
|
+
wash = fbm((h, w), rng, scales=(220, 70, 18)) # uneven wash
|
|
58
|
+
gran = fbm((h, w), rng, scales=(5, 2), weights=(0.6, 0.4)) # granulation
|
|
59
|
+
alpha = np.clip(m * (0.50 + 0.38 * wash) * (0.88 + 0.24 * gran) + 1.6 * edge, 0, 0.97)
|
|
60
|
+
colv = np.asarray(col, np.float32)
|
|
61
|
+
img *= 1 - alpha[..., None] * (1 - colv) # multiply glaze, like pigment on paper
|
|
62
|
+
return np.clip(img, 0, 1)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def swatch(colour, seed=1, size=(40, 110)):
|
|
66
|
+
"""A small painted rectangle in one colour, for legends."""
|
|
67
|
+
c = np.full(size, EMPTY, np.uint8)
|
|
68
|
+
c[6:size[0] - 6, 6:size[1] - 6] = 1
|
|
69
|
+
return paint(c, {1: colour}, pixel_size=2.0, seed=seed)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def deckle(img, pixel_size, seed=3, paper=PAPER):
|
|
73
|
+
"""Fade the outer edge of a rectangular painting into a torn, hand-painted edge."""
|
|
74
|
+
h, w = img.shape[:2]
|
|
75
|
+
rng = np.random.default_rng(seed)
|
|
76
|
+
d = np.minimum.outer(np.minimum(np.arange(h), np.arange(h)[::-1]),
|
|
77
|
+
np.minimum(np.arange(w), np.arange(w)[::-1])).astype(np.float32)
|
|
78
|
+
k = max(1.0, 2.5 / pixel_size)
|
|
79
|
+
n = fbm((h, w), rng, scales=(120, 35, 10), weights=(0.5, 0.35, 0.15))
|
|
80
|
+
fe = np.clip((d - 28 * k * n) / (5 * k), 0, 1)[..., None]
|
|
81
|
+
return paper + (img - paper) * fe
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def fade_paper(img, classes, paper=PAPER):
|
|
85
|
+
"""Fade the paper fibre out towards the frame where there is no paint."""
|
|
86
|
+
h, w = classes.shape
|
|
87
|
+
ramp = max(1, int(max(h, w) * 0.03))
|
|
88
|
+
fy = np.clip(np.minimum(np.arange(h), np.arange(h)[::-1]) / ramp, 0, 1)
|
|
89
|
+
fx = np.clip(np.minimum(np.arange(w), np.arange(w)[::-1]) / ramp, 0, 1)
|
|
90
|
+
f = np.minimum.outer(fy, fx)[..., None]
|
|
91
|
+
outside = (classes == EMPTY)[..., None]
|
|
92
|
+
return np.where(outside, paper + (img - paper) * f, img)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Command line: watercolour posters from Copernicus Urban Atlas files."""
|
|
2
|
+
import argparse
|
|
3
|
+
|
|
4
|
+
import geopandas as gpd
|
|
5
|
+
from shapely.geometry import box
|
|
6
|
+
|
|
7
|
+
from .legends import IGNITION, URBAN_ATLAS_CREDIT, read_urban_atlas
|
|
8
|
+
from .poster import poster, map_box_aspect
|
|
9
|
+
from .shapes import local_crs, irregular_circle, rectangle
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_label(spec):
|
|
13
|
+
parts = spec.split("|")
|
|
14
|
+
return (parts[0], float(parts[1]), float(parts[2]), float(parts[3]) if len(parts) > 3 else 0)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(argv=None):
|
|
18
|
+
ap = argparse.ArgumentParser(prog="watercolourmap",
|
|
19
|
+
description="Watercolour land cover poster from a Copernicus Urban Atlas file.")
|
|
20
|
+
ap.add_argument("data", help="Urban Atlas .fgb or .gpkg")
|
|
21
|
+
ap.add_argument("--layer")
|
|
22
|
+
where = ap.add_argument_group("area (pick one)")
|
|
23
|
+
where.add_argument("--center", nargs=2, type=float, metavar=("LON", "LAT"))
|
|
24
|
+
where.add_argument("--radius", type=float, help="with --center: irregular circle, km")
|
|
25
|
+
where.add_argument("--width", type=float, default=8, help="with --center: rectangle width, km")
|
|
26
|
+
where.add_argument("--bbox", nargs=4, type=float, metavar=("MINLON", "MINLAT", "MAXLON", "MAXLAT"))
|
|
27
|
+
where.add_argument("--clip", help="any polygon file")
|
|
28
|
+
ap.add_argument("--landscape", action="store_true")
|
|
29
|
+
ap.add_argument("--sea", action="store_true", help="paint everything outside the data as Blue Cover")
|
|
30
|
+
ap.add_argument("--label", action="append", default=[], metavar='"Name|lon|lat[|angle]"')
|
|
31
|
+
ap.add_argument("--title", default="Land Cover")
|
|
32
|
+
ap.add_argument("--subtitle", default="")
|
|
33
|
+
ap.add_argument("--signature", default="", help="e.g. your name or handle, top right")
|
|
34
|
+
ap.add_argument("--website", default="", help="small line under the signature")
|
|
35
|
+
ap.add_argument("--credit", default=URBAN_ATLAS_CREDIT)
|
|
36
|
+
ap.add_argument("--paper", default="A2", choices=["A1", "A2", "A3", "A4"])
|
|
37
|
+
ap.add_argument("--dpi", type=int, default=300)
|
|
38
|
+
ap.add_argument("--out", default="landcover")
|
|
39
|
+
a = ap.parse_args(argv)
|
|
40
|
+
|
|
41
|
+
bounds, landscape, torn = None, None, False
|
|
42
|
+
if a.center and a.radius:
|
|
43
|
+
crs, c = local_crs(*a.center)
|
|
44
|
+
R = a.radius * 1000
|
|
45
|
+
read = tuple(gpd.GeoSeries([c.buffer(R * 1.6)], crs=crs).to_crs(4326).total_bounds)
|
|
46
|
+
gdf = read_urban_atlas(a.data, read, a.layer).to_crs(crs)
|
|
47
|
+
gdf = irregular_circle(gdf, c, R)
|
|
48
|
+
landscape = False
|
|
49
|
+
elif a.center:
|
|
50
|
+
crs, c = local_crs(*a.center)
|
|
51
|
+
landscape = a.landscape
|
|
52
|
+
rect = rectangle(c, a.width * 1000, map_box_aspect(a.paper, landscape))
|
|
53
|
+
read = tuple(gpd.GeoSeries([rect], crs=crs).to_crs(4326).total_bounds)
|
|
54
|
+
gdf = gpd.clip(read_urban_atlas(a.data, read, a.layer).to_crs(crs), rect)
|
|
55
|
+
bounds, torn = rect.bounds, True
|
|
56
|
+
else:
|
|
57
|
+
gdf = read_urban_atlas(a.data, a.bbox, a.layer)
|
|
58
|
+
gdf = gdf.to_crs(gdf.estimate_utm_crs())
|
|
59
|
+
if a.clip:
|
|
60
|
+
gdf = gpd.clip(gdf, gpd.read_file(a.clip).to_crs(gdf.crs).union_all())
|
|
61
|
+
elif a.bbox:
|
|
62
|
+
gdf = gpd.clip(gdf, gpd.GeoSeries([box(*a.bbox)], crs=4326).to_crs(gdf.crs).union_all())
|
|
63
|
+
gdf = gdf[gdf.cls >= 0] # class 0 (roads) is burnt in as bare paper, so --sea can't fill it
|
|
64
|
+
|
|
65
|
+
poster(gdf, IGNITION, a.out, bounds=bounds, paper=a.paper, landscape=landscape, dpi=a.dpi,
|
|
66
|
+
title=a.title, subtitle=a.subtitle, signature=a.signature, website=a.website,
|
|
67
|
+
credit=a.credit, labels=[parse_label(s) for s in a.label],
|
|
68
|
+
sea_class=1 if a.sea else None, torn_edge=torn)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
main()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
|
2
|
+
Upstream-Name: GNU FreeFont
|
|
3
|
+
Upstream-Contact: Steve White <Stevan.White@gmail.com>
|
|
4
|
+
Source: https://savannah.gnu.org/projects/freefont/
|
|
5
|
+
|
|
6
|
+
Files: *
|
|
7
|
+
Copyright: 2002-2012, GNU Freefont contributors
|
|
8
|
+
2009-2012, Steve White <Stevan.White@gmail.com>
|
|
9
|
+
2011, Emmanuel Vallois
|
|
10
|
+
License: GPL-3+
|
|
11
|
+
|
|
12
|
+
Files: */*.ttf */*.otf */*.woff
|
|
13
|
+
Copyright: 2002-2012, GNU Freefont contributors
|
|
14
|
+
License: GPL-3+ with Special Font Exception
|
|
15
|
+
|
|
16
|
+
Files: debian/*
|
|
17
|
+
Copyright: 2002-2003, Peter Hawkins <peterh@debian.org>
|
|
18
|
+
2004, Josh Triplett <josh.trip@verizon.net>
|
|
19
|
+
2004, Panayotis Katsaloulis <panayotis@panayotis.com>
|
|
20
|
+
2005-2006, Konstantinos Margaritis <markos@debian.org>
|
|
21
|
+
2006-2014, Christian Perrier <bubulle@debian.org>
|
|
22
|
+
2007-2011, Davide Viti <zinosat@tiscali.it>
|
|
23
|
+
2016-2023, Fabian Greffrath <fabian@debian.org>
|
|
24
|
+
2016, Adam Borowski <kilobyte@angband.pl>
|
|
25
|
+
License: GPL-3+
|
|
26
|
+
|
|
27
|
+
License: GPL-3+
|
|
28
|
+
GNU FreeFont is free software; you can redistribute it and/or modify it
|
|
29
|
+
under the terms of the GNU General Public License as published by the
|
|
30
|
+
Free Software Foundation; either version 3 of the License, or (at your option)
|
|
31
|
+
any later version.
|
|
32
|
+
.
|
|
33
|
+
The fonts are distributed in the hope that they will be useful, but
|
|
34
|
+
WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
35
|
+
or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
|
36
|
+
for more details.
|
|
37
|
+
.
|
|
38
|
+
You should have received a copy of the GNU General Public License along
|
|
39
|
+
with this program; if not, write to the Free Software Foundation, Inc.,
|
|
40
|
+
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
41
|
+
.
|
|
42
|
+
On Debian systems, the complete text of the GNU General
|
|
43
|
+
Public License version 3 can be found in "/usr/share/common-licenses/GPL-3".
|
|
44
|
+
|
|
45
|
+
License: GPL-3+ with Special Font Exception
|
|
46
|
+
GNU FreeFont is free software; you can redistribute it and/or modify it
|
|
47
|
+
under the terms of the GNU General Public License as published by the
|
|
48
|
+
Free Software Foundation; either version 3 of the License, or (at your option)
|
|
49
|
+
any later version.
|
|
50
|
+
.
|
|
51
|
+
The fonts are distributed in the hope that they will be useful, but
|
|
52
|
+
WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
53
|
+
or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
|
54
|
+
for more details.
|
|
55
|
+
.
|
|
56
|
+
You should have received a copy of the GNU General Public License along
|
|
57
|
+
with this program; if not, write to the Free Software Foundation, Inc.,
|
|
58
|
+
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
59
|
+
.
|
|
60
|
+
* Regarding the use of built font files.
|
|
61
|
+
.
|
|
62
|
+
As a special exception, if you create a document which uses this font, and
|
|
63
|
+
embed this font or unaltered portions of this font into the document, this
|
|
64
|
+
font does not by itself cause the resulting document to be covered by the
|
|
65
|
+
GNU General Public License. This exception does not however invalidate any
|
|
66
|
+
other reasons why the document might be covered by the GNU General Public
|
|
67
|
+
License. If you modify this font, you may extend this exception to your
|
|
68
|
+
version of the font, but you are not obligated to do so. If you do not
|
|
69
|
+
wish to do so, delete this exception statement from your version.
|
|
70
|
+
.
|
|
71
|
+
* Clarification of exception, regarding intended use of embedded fonts
|
|
72
|
+
.
|
|
73
|
+
The exception to the GNU General Public License for the GNU FreeFont
|
|
74
|
+
software exempts a user that embeds part or all of the GNU FreeFont software
|
|
75
|
+
in a document, as well as subsequent users and distributors of the document,
|
|
76
|
+
from complying with the GNU GPL under some circumstances.
|
|
77
|
+
.
|
|
78
|
+
Specifically, this exemption applies when the embedder has not modified
|
|
79
|
+
the GNU FreeFont software before embedding it. This is true even though
|
|
80
|
+
portions of the software become part of the document.
|
|
81
|
+
.
|
|
82
|
+
Therefore users of fonts released with this exception need not concern
|
|
83
|
+
themselves with the license terms to:
|
|
84
|
+
.
|
|
85
|
+
Create a printable file using the fonts.
|
|
86
|
+
- Publish such a file.
|
|
87
|
+
- Redistribute such a file.
|
|
88
|
+
- Publicly display such a file.
|
|
89
|
+
- Print such a file or display it on the screen.
|
|
90
|
+
.
|
|
91
|
+
However anyone distributing the font software as software separately from
|
|
92
|
+
the document, even if the way the font software was obtained was to extract
|
|
93
|
+
it from a document, would have to comply with the GNU GPL.
|
|
94
|
+
.
|
|
95
|
+
A modified version of the GNU FreeFont software must comply with the GNU GPL.
|
|
96
|
+
It may optionally carry the same exception as the GNU FreeFont software
|
|
97
|
+
itself, and we recommend releasing modified versions that way. If it does,
|
|
98
|
+
then users will likewise be permitted to embed the modified version in
|
|
99
|
+
documents and be exempt from the GNU GPL requirements on those documents.
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
|
|
2
|
+
|
|
3
|
+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
4
|
+
This license is copied below, and is also available with a FAQ at:
|
|
5
|
+
https://scripts.sil.org/OFL
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
-----------------------------------------------------------
|
|
9
|
+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
10
|
+
-----------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
PREAMBLE
|
|
13
|
+
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
14
|
+
development of collaborative font projects, to support the font creation
|
|
15
|
+
efforts of academic and linguistic communities, and to provide a free and
|
|
16
|
+
open framework in which fonts may be shared and improved in partnership
|
|
17
|
+
with others.
|
|
18
|
+
|
|
19
|
+
The OFL allows the licensed fonts to be used, studied, modified and
|
|
20
|
+
redistributed freely as long as they are not sold by themselves. The
|
|
21
|
+
fonts, including any derivative works, can be bundled, embedded,
|
|
22
|
+
redistributed and/or sold with any software provided that any reserved
|
|
23
|
+
names are not used by derivative works. The fonts and derivatives,
|
|
24
|
+
however, cannot be released under any other type of license. The
|
|
25
|
+
requirement for fonts to remain under this license does not apply
|
|
26
|
+
to any document created using the fonts or their derivatives.
|
|
27
|
+
|
|
28
|
+
DEFINITIONS
|
|
29
|
+
"Font Software" refers to the set of files released by the Copyright
|
|
30
|
+
Holder(s) under this license and clearly marked as such. This may
|
|
31
|
+
include source files, build scripts and documentation.
|
|
32
|
+
|
|
33
|
+
"Reserved Font Name" refers to any names specified as such after the
|
|
34
|
+
copyright statement(s).
|
|
35
|
+
|
|
36
|
+
"Original Version" refers to the collection of Font Software components as
|
|
37
|
+
distributed by the Copyright Holder(s).
|
|
38
|
+
|
|
39
|
+
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
40
|
+
or substituting -- in part or in whole -- any of the components of the
|
|
41
|
+
Original Version, by changing formats or by porting the Font Software to a
|
|
42
|
+
new environment.
|
|
43
|
+
|
|
44
|
+
"Author" refers to any designer, engineer, programmer, technical
|
|
45
|
+
writer or other person who contributed to the Font Software.
|
|
46
|
+
|
|
47
|
+
PERMISSION & CONDITIONS
|
|
48
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
49
|
+
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
50
|
+
redistribute, and sell modified and unmodified copies of the Font
|
|
51
|
+
Software, subject to the following conditions:
|
|
52
|
+
|
|
53
|
+
1) Neither the Font Software nor any of its individual components,
|
|
54
|
+
in Original or Modified Versions, may be sold by itself.
|
|
55
|
+
|
|
56
|
+
2) Original or Modified Versions of the Font Software may be bundled,
|
|
57
|
+
redistributed and/or sold with any software, provided that each copy
|
|
58
|
+
contains the above copyright notice and this license. These can be
|
|
59
|
+
included either as stand-alone text files, human-readable headers or
|
|
60
|
+
in the appropriate machine-readable metadata fields within text or
|
|
61
|
+
binary files as long as those fields can be easily viewed by the user.
|
|
62
|
+
|
|
63
|
+
3) No Modified Version of the Font Software may use the Reserved Font
|
|
64
|
+
Name(s) unless explicit written permission is granted by the corresponding
|
|
65
|
+
Copyright Holder. This restriction only applies to the primary font name as
|
|
66
|
+
presented to the users.
|
|
67
|
+
|
|
68
|
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
69
|
+
Software shall not be used to promote, endorse or advertise any
|
|
70
|
+
Modified Version, except to acknowledge the contribution(s) of the
|
|
71
|
+
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
72
|
+
permission.
|
|
73
|
+
|
|
74
|
+
5) The Font Software, modified or unmodified, in part or in whole,
|
|
75
|
+
must be distributed entirely under this license, and must not be
|
|
76
|
+
distributed under any other license. The requirement for fonts to
|
|
77
|
+
remain under this license does not apply to any document created
|
|
78
|
+
using the Font Software.
|
|
79
|
+
|
|
80
|
+
TERMINATION
|
|
81
|
+
This license becomes null and void if any of the above conditions are
|
|
82
|
+
not met.
|
|
83
|
+
|
|
84
|
+
DISCLAIMER
|
|
85
|
+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
86
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
87
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
88
|
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
89
|
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
90
|
+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
91
|
+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
92
|
+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
93
|
+
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Legends: the IGNITION land cover palette and the Urban Atlas reclassification."""
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
import geopandas as gpd
|
|
5
|
+
import pyogrio
|
|
6
|
+
from shapely.geometry import box
|
|
7
|
+
|
|
8
|
+
# Muted watercolour pigments, close to the Gorton and Abbey Hey map (IGNITION legend)
|
|
9
|
+
IGNITION = {
|
|
10
|
+
1: ("Blue Cover", (0.42, 0.55, 0.74)),
|
|
11
|
+
2: ("Green Cover", (0.42, 0.60, 0.33)),
|
|
12
|
+
3: ("Green-Grey Cover", (0.55, 0.66, 0.66)),
|
|
13
|
+
4: ("Grey Cover", (0.66, 0.70, 0.76)),
|
|
14
|
+
5: ("Land Use Changing", (0.80, 0.38, 0.38)),
|
|
15
|
+
6: ("Unknown", (0.82, 0.63, 0.50)),
|
|
16
|
+
7: ("Other Natural", (0.83, 0.77, 0.62)),
|
|
17
|
+
}
|
|
18
|
+
BARE = 0 # roads and rail: left as bare paper
|
|
19
|
+
|
|
20
|
+
URBAN_ATLAS_CREDIT = ("Contains modified Copernicus Land Monitoring Service "
|
|
21
|
+
"information (Urban Atlas 2021) © European Union")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def urban_atlas_class(code):
|
|
25
|
+
"""Copernicus Urban Atlas code -> IGNITION legend class id."""
|
|
26
|
+
c = int(code)
|
|
27
|
+
if c in (11100, 11210, 12100, 12300, 12400): return 4 # grey
|
|
28
|
+
if c in (11220, 11230, 11240, 11300): return 3 # green-grey
|
|
29
|
+
if c in (12210, 12220, 12230): return BARE
|
|
30
|
+
if c in (13100, 13300): return 5 # changing
|
|
31
|
+
if c == 13400: return 6 # unknown / vacant
|
|
32
|
+
if c in (14100, 14110, 14120, 14130): return 2 # urban green (2021 splits by access)
|
|
33
|
+
if c == 14200: return 3 # sports and leisure, incl. marinas
|
|
34
|
+
if 31000 <= c <= 32000 or 40000 <= c < 50000: return 2 # forest, grassland, wetland
|
|
35
|
+
if 20000 <= c < 30000: return 7 # agriculture
|
|
36
|
+
if c == 33000: return 7 # bare ground, beaches
|
|
37
|
+
if c == 50000: return 1 # water
|
|
38
|
+
return 6
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def read_urban_atlas(path, bbox=None, layer=None, verbose=True):
|
|
42
|
+
"""Read an Urban Atlas file (gpkg or fgb) and add a 'cls' column with legend ids.
|
|
43
|
+
|
|
44
|
+
bbox optional (minlon, minlat, maxlon, maxlat) to read only part of the city
|
|
45
|
+
"""
|
|
46
|
+
layers = [l[0] for l in pyogrio.list_layers(path)]
|
|
47
|
+
if layer is None:
|
|
48
|
+
cand = [l for l in layers if not re.search(r"bound|core|fua_area", l, re.I)]
|
|
49
|
+
layer = (cand or layers)[0]
|
|
50
|
+
kw = {}
|
|
51
|
+
if bbox:
|
|
52
|
+
crs = pyogrio.read_info(path, layer=layer)["crs"]
|
|
53
|
+
kw["bbox"] = tuple(gpd.GeoSeries([box(*bbox)], crs=4326).to_crs(crs).total_bounds)
|
|
54
|
+
gdf = gpd.read_file(path, layer=layer, **kw)
|
|
55
|
+
codecol = next(c for c in gdf.columns if re.fullmatch(r"code_20\d\d", c, re.I))
|
|
56
|
+
gdf["cls"] = gdf[codecol].map(urban_atlas_class)
|
|
57
|
+
if verbose:
|
|
58
|
+
print(f"layer={layer} code column={codecol} features={len(gdf)}")
|
|
59
|
+
return gdf
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Painting GeoDataFrames and laying out print-ready posters."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import geopandas as gpd
|
|
6
|
+
from rasterio.features import rasterize
|
|
7
|
+
from rasterio.transform import from_origin
|
|
8
|
+
from shapely.geometry import Point
|
|
9
|
+
import matplotlib
|
|
10
|
+
import matplotlib.pyplot as plt
|
|
11
|
+
import matplotlib.patheffects as pe
|
|
12
|
+
from matplotlib import font_manager as fm
|
|
13
|
+
|
|
14
|
+
from .brush import PAPER, EMPTY, paint, swatch, deckle, fade_paper
|
|
15
|
+
|
|
16
|
+
FONTS = Path(__file__).resolve().parent / "fonts"
|
|
17
|
+
PAPER_SIZES = {"A1": (594, 841), "A2": (420, 594), "A3": (297, 420), "A4": (210, 297)}
|
|
18
|
+
INK = (0.25, 0.25, 0.25)
|
|
19
|
+
MAX_PIXELS = 60_000_000 # roughly 5 GB of working memory while painting
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def font(name="Lora-Italic-Variable.ttf"):
|
|
23
|
+
return fm.FontProperties(fname=str(FONTS / name))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ------------------------------------------------------------------ painting
|
|
27
|
+
def rasterise(gdf, column, bounds, pixel_size):
|
|
28
|
+
"""Burn polygons into a class raster. Larger polygons first, small ones on top."""
|
|
29
|
+
minx, miny, maxx, maxy = bounds
|
|
30
|
+
W = int(np.ceil((maxx - minx) / pixel_size))
|
|
31
|
+
H = int(np.ceil((maxy - miny) / pixel_size))
|
|
32
|
+
if W * H > MAX_PIXELS:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
f"The map would be {W} x {H} pixels, too big to paint in memory. "
|
|
35
|
+
"Clip the data to a smaller area or use a larger pixel_size / lower dpi. "
|
|
36
|
+
"Is the data in a metric CRS (metres)?")
|
|
37
|
+
order = gdf.assign(_a=gdf.area).sort_values("_a", ascending=False)
|
|
38
|
+
return rasterize(((g, int(c)) for g, c in zip(order.geometry, order[column])),
|
|
39
|
+
out_shape=(H, W), transform=from_origin(minx, maxy, pixel_size, pixel_size),
|
|
40
|
+
fill=EMPTY, dtype="uint8")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def paint_gdf(gdf, column, colours, pixel_size=None, bounds=None, seed=7):
|
|
44
|
+
"""Paint any polygon GeoDataFrame as watercolour.
|
|
45
|
+
|
|
46
|
+
gdf polygons in a metric CRS (use gdf.to_crs(gdf.estimate_utm_crs()))
|
|
47
|
+
column column holding a category for each polygon
|
|
48
|
+
colours {category: (r, g, b)} (0-1 floats or '#rrggbb' strings)
|
|
49
|
+
pixel_size metres per pixel; default makes the longer side about 3000 px
|
|
50
|
+
Returns (image, extent) ready for ax.imshow(image, extent=extent).
|
|
51
|
+
"""
|
|
52
|
+
# any category type -> small integer ids for the raster
|
|
53
|
+
cats = list(colours)
|
|
54
|
+
ids = {c: i + 1 for i, c in enumerate(cats)}
|
|
55
|
+
g = gdf[gdf[column].isin(ids)].assign(_id=lambda d: d[column].map(ids))
|
|
56
|
+
if g.empty:
|
|
57
|
+
raise ValueError(f"No polygons have a {column!r} value listed in colours")
|
|
58
|
+
bounds = bounds if bounds is not None else tuple(g.total_bounds)
|
|
59
|
+
if pixel_size is None:
|
|
60
|
+
pixel_size = max(bounds[2] - bounds[0], bounds[3] - bounds[1]) / 3000
|
|
61
|
+
ras = rasterise(g, "_id", bounds, pixel_size)
|
|
62
|
+
rgb = {ids[c]: matplotlib.colors.to_rgb(colours[c]) for c in cats}
|
|
63
|
+
img = fade_paper(paint(ras, rgb, pixel_size, seed), ras)
|
|
64
|
+
minx, miny, maxx, maxy = bounds
|
|
65
|
+
H, W = ras.shape
|
|
66
|
+
return img, (minx, minx + W * pixel_size, maxy - H * pixel_size, maxy)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------ posters
|
|
70
|
+
def _page(paper, landscape):
|
|
71
|
+
pw, ph = PAPER_SIZES[paper]
|
|
72
|
+
if landscape:
|
|
73
|
+
pw, ph = ph, pw
|
|
74
|
+
win, hin = pw / 25.4, ph / 25.4
|
|
75
|
+
base = 10.5 * np.sqrt((pw * ph) / (297 * 420)) # pt, scales with paper
|
|
76
|
+
m_in = min(win, hin) * 0.06
|
|
77
|
+
title_in = base * 4.2 / 72
|
|
78
|
+
panel_in = base * 15 / 72
|
|
79
|
+
gap = base * 1.6 / 72
|
|
80
|
+
if landscape: # legend panel on the left
|
|
81
|
+
box_in = (m_in + panel_in, m_in, win - 2 * m_in - panel_in, hin - 2 * m_in - title_in - gap)
|
|
82
|
+
else: # legend panel along the bottom
|
|
83
|
+
box_in = (m_in, m_in + panel_in * 0.75 + gap, win - 2 * m_in,
|
|
84
|
+
hin - 2 * m_in - title_in - panel_in * 0.75 - 2 * gap)
|
|
85
|
+
return dict(win=win, hin=hin, base=base, m_in=m_in, panel_in=panel_in, box_in=box_in)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def map_box_aspect(paper="A2", landscape=False):
|
|
89
|
+
"""Height / width of the map area on a page, for shaping a rectangle to fill it."""
|
|
90
|
+
b = _page(paper, landscape)["box_in"]
|
|
91
|
+
return b[3] / b[2]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def poster(gdf, legend, out, *, column="cls", bounds=None, paper="A2", landscape=None, dpi=300,
|
|
95
|
+
title="", subtitle="", signature="", website="", credit="", labels=(),
|
|
96
|
+
legend_title="Land Use Types", sea_class=None, torn_edge=False, seed=7,
|
|
97
|
+
formats=("pdf", "png")):
|
|
98
|
+
"""Paint a poster: title, map, painted legend, scale bar and credits.
|
|
99
|
+
|
|
100
|
+
gdf polygons in a metric CRS with an integer class column
|
|
101
|
+
legend {class_id: (name, (r, g, b))}, in legend order
|
|
102
|
+
bounds map extent (minx, miny, maxx, maxy); default is the data extent
|
|
103
|
+
landscape None picks it from the shape of the area
|
|
104
|
+
labels [(name, lon, lat, angle), ...] small italic place labels
|
|
105
|
+
sea_class paint everything outside the data with this class (e.g. sea)
|
|
106
|
+
torn_edge hand-torn edge for rectangular maps
|
|
107
|
+
"""
|
|
108
|
+
minx, miny, maxx, maxy = bounds if bounds is not None else gdf.total_bounds
|
|
109
|
+
if landscape is None:
|
|
110
|
+
landscape = (maxx - minx) > (maxy - miny)
|
|
111
|
+
p = _page(paper, landscape)
|
|
112
|
+
win, hin, base, m_in, panel_in, box_in = (p[k] for k in ("win", "hin", "base", "m_in", "panel_in", "box_in"))
|
|
113
|
+
span = max((maxx - minx) / box_in[2], (maxy - miny) / box_in[3]) # metres per inch on paper
|
|
114
|
+
px = span / dpi
|
|
115
|
+
ras = rasterise(gdf, column, (minx, miny, maxx, maxy), px)
|
|
116
|
+
H, W = ras.shape
|
|
117
|
+
print(f"raster {W}x{H}px at {px:.2f} m/px, page {paper} {'landscape' if landscape else 'portrait'}")
|
|
118
|
+
if sea_class is not None:
|
|
119
|
+
ras[ras == EMPTY] = sea_class
|
|
120
|
+
img = paint(ras, {k: v[1] for k, v in legend.items()}, px, seed)
|
|
121
|
+
if torn_edge:
|
|
122
|
+
img = deckle(img, px)
|
|
123
|
+
img = fade_paper(img, ras)
|
|
124
|
+
|
|
125
|
+
f = font()
|
|
126
|
+
fig = plt.figure(figsize=(win, hin), dpi=dpi)
|
|
127
|
+
fig.patch.set_facecolor(PAPER)
|
|
128
|
+
X = lambda i: i / win
|
|
129
|
+
Y = lambda i: i / hin
|
|
130
|
+
mw, mh = (maxx - minx) / span, (maxy - miny) / span
|
|
131
|
+
ax = fig.add_axes([X(box_in[0] + (box_in[2] - mw) / 2), Y(box_in[1] + (box_in[3] - mh) / 2), X(mw), Y(mh)])
|
|
132
|
+
ax.imshow(img, extent=(minx, minx + W * px, maxy - H * px, maxy), interpolation="lanczos")
|
|
133
|
+
ax.set_xlim(minx, maxx); ax.set_ylim(miny, maxy)
|
|
134
|
+
ax.set_aspect("equal"); ax.axis("off")
|
|
135
|
+
|
|
136
|
+
for lab in labels:
|
|
137
|
+
name, lon, lat = lab[:3]
|
|
138
|
+
ang = lab[3] if len(lab) > 3 else 0
|
|
139
|
+
pt = gpd.GeoSeries([Point(lon, lat)], crs=4326).to_crs(gdf.crs).iloc[0]
|
|
140
|
+
ax.text(pt.x, pt.y, name, fontproperties=f, fontsize=base * 1.05, color=(0.22,) * 3,
|
|
141
|
+
ha="center", va="center", rotation=ang, rotation_mode="anchor",
|
|
142
|
+
path_effects=[pe.withStroke(linewidth=base * 0.35, foreground=(*PAPER, 0.75))])
|
|
143
|
+
|
|
144
|
+
if title:
|
|
145
|
+
fig.text(X(m_in), 1 - Y(m_in), title, fontproperties=f, fontsize=base * 2.6, color=INK, va="top")
|
|
146
|
+
if subtitle: # Lora has no Greek glyphs: fall back to FreeSerif Italic
|
|
147
|
+
sf = f if all(ord(ch) < 0x370 for ch in subtitle) else font("FreeSerifItalic.ttf")
|
|
148
|
+
fig.text(X(m_in), 1 - Y(m_in + base * 3.3 / 72), subtitle, fontproperties=sf,
|
|
149
|
+
fontsize=base * 1.05, color=(0.42,) * 3, va="top")
|
|
150
|
+
|
|
151
|
+
# legend, painted with the same brush
|
|
152
|
+
present = [c for c in legend if (ras == c).any()]
|
|
153
|
+
row = base * 2.0 / 72
|
|
154
|
+
lx = m_in
|
|
155
|
+
ly = (m_in + panel_in * 0.75 + len(present) * row) if landscape else box_in[1] - base * 2.6 / 72 - row
|
|
156
|
+
if legend_title:
|
|
157
|
+
fig.text(X(lx), Y(ly + row * 0.9), legend_title, fontproperties=f, fontsize=base * 1.15, color=INK)
|
|
158
|
+
per = int(np.ceil(len(present) / (1 if landscape else 2))) or 1
|
|
159
|
+
for i, cid in enumerate(present):
|
|
160
|
+
cx = lx + (i // per) * base * 13 / 72
|
|
161
|
+
y = ly - row * (i % per)
|
|
162
|
+
sw, sh = base * 2.2 / 72, base * 0.9 / 72
|
|
163
|
+
sax = fig.add_axes([X(cx), Y(y - sh / 2), X(sw), Y(sh)])
|
|
164
|
+
sax.imshow(swatch(legend[cid][1], cid), aspect="auto"); sax.axis("off")
|
|
165
|
+
fig.text(X(cx + sw + base * 0.6 / 72), Y(y), legend[cid][0], fontproperties=f,
|
|
166
|
+
fontsize=base * 0.95, color=INK, va="center")
|
|
167
|
+
|
|
168
|
+
# scale bar, true to map scale
|
|
169
|
+
target = panel_in * 0.7 * span
|
|
170
|
+
total = 10 ** np.floor(np.log10(target))
|
|
171
|
+
for mult in (5, 2, 1):
|
|
172
|
+
if total * mult <= target:
|
|
173
|
+
total *= mult
|
|
174
|
+
break
|
|
175
|
+
bar_in = total / span
|
|
176
|
+
sy = (m_in + base * 2.2 / 72) if landscape else (m_in + base * 0.8 / 72)
|
|
177
|
+
sx = m_in if landscape else win - m_in - bar_in - base * 10 / 72
|
|
178
|
+
sbx = fig.add_axes([X(sx), Y(sy), X(bar_in), Y(base * 0.5 / 72)])
|
|
179
|
+
sbx.set_xlim(0, total); sbx.set_ylim(0, 1); sbx.axis("off")
|
|
180
|
+
sbx.plot([0, total], [0, 0], color=INK, lw=0.8, clip_on=False)
|
|
181
|
+
for t in np.linspace(0, total, 11):
|
|
182
|
+
sbx.plot([t, t], [0, 0.55 if t in (0, total / 2, total) else 0.3], color=INK, lw=0.6, clip_on=False)
|
|
183
|
+
unit = 1000 if total >= 1000 else 1
|
|
184
|
+
for t in (0, total / 2, total):
|
|
185
|
+
sbx.text(t, 1.0, f"{t / unit:g}" + ((" km" if unit == 1000 else " m") if t == total else ""),
|
|
186
|
+
ha="center", va="bottom", fontsize=base * 0.75, color=INK, fontproperties=f)
|
|
187
|
+
|
|
188
|
+
if signature:
|
|
189
|
+
fig.text(1 - X(m_in), 1 - Y(m_in + base * 0.9 / 72), signature, ha="right", va="top",
|
|
190
|
+
fontproperties=f, fontsize=base * 1.5, color=INK)
|
|
191
|
+
if website:
|
|
192
|
+
fig.text(1 - X(m_in), 1 - Y(m_in + base * 3.3 / 72), website, ha="right", va="top",
|
|
193
|
+
fontproperties=f, fontsize=base * 0.8, color=(0.45,) * 3)
|
|
194
|
+
if credit:
|
|
195
|
+
fig.text(1 - X(m_in), Y(m_in * 0.55), credit, ha="right", fontsize=base * 0.6,
|
|
196
|
+
color=(0.45,) * 3, fontproperties=f)
|
|
197
|
+
|
|
198
|
+
out = Path(out)
|
|
199
|
+
for ext in formats:
|
|
200
|
+
fig.savefig(out.with_suffix(f".{ext}"), dpi=dpi, facecolor=PAPER)
|
|
201
|
+
plt.close(fig)
|
|
202
|
+
print("saved", out)
|
|
203
|
+
return out
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Map shapes: irregular circles and page-filling rectangles."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
import geopandas as gpd
|
|
4
|
+
from shapely.geometry import Point, Polygon, box
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def local_crs(lon, lat):
|
|
8
|
+
"""A metric (UTM) CRS for a point, and the point in that CRS."""
|
|
9
|
+
s = gpd.GeoSeries([Point(lon, lat)], crs=4326)
|
|
10
|
+
crs = s.estimate_utm_crs()
|
|
11
|
+
return crs, s.to_crs(crs).iloc[0]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def wobbly_circle(centre, radius, seed=11, n=1440):
|
|
15
|
+
"""A slightly irregular circle polygon around a projected point."""
|
|
16
|
+
rng = np.random.default_rng(seed)
|
|
17
|
+
t = np.linspace(0, 2 * np.pi, n, endpoint=False)
|
|
18
|
+
wob = sum(rng.uniform(0.008, 0.03) * np.sin(f * t + rng.uniform(0, 6.3)) for f in (3, 5, 8, 13, 21))
|
|
19
|
+
return Polygon([(centre.x + radius * (1 + w) * np.cos(q), centre.y + radius * (1 + w) * np.sin(q))
|
|
20
|
+
for q, w in zip(t, wob)])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def irregular_circle(gdf, centre, radius, seed=11):
|
|
24
|
+
"""Keep whole parcels whose centre is within `radius` of `centre`.
|
|
25
|
+
|
|
26
|
+
This gives the ragged, parcel-shaped edge. Very large parcels (a river,
|
|
27
|
+
a forest, the road network) are trimmed with a wobbly circle so they
|
|
28
|
+
don't run off the page. gdf must be in a metric CRS.
|
|
29
|
+
"""
|
|
30
|
+
inside = gdf.representative_point().distance(centre) <= radius
|
|
31
|
+
edge = wobbly_circle(centre, radius * 1.08, seed)
|
|
32
|
+
big = gdf.intersects(centre.buffer(radius)) & ~gdf.within(edge)
|
|
33
|
+
keep = gdf[inside | big].copy()
|
|
34
|
+
keep["geometry"] = keep.geometry.intersection(edge)
|
|
35
|
+
return keep[~keep.geometry.is_empty]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def rectangle(centre, width, aspect):
|
|
39
|
+
"""A rectangle `width` metres wide, height = width * aspect, around a projected point."""
|
|
40
|
+
hw, hh = width / 2, width * aspect / 2
|
|
41
|
+
return box(centre.x - hw, centre.y - hh, centre.x + hw, centre.y + hh)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: watercolourmap
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Watercolour maps in Python, inspired by John Nelson's watercolour style for ArcGIS Pro
|
|
5
|
+
Author: Eriola Trungu Impersimi
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://etymo.co.uk/cartography/
|
|
8
|
+
Keywords: cartography,maps,watercolour,watercolor,geopandas,land cover,urban atlas,copernicus
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
12
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
License-File: src/watercolourmap/fonts/FreeSerif-LICENSE.txt
|
|
18
|
+
License-File: src/watercolourmap/fonts/Lora-OFL.txt
|
|
19
|
+
Requires-Dist: geopandas>=1.0
|
|
20
|
+
Requires-Dist: pyogrio>=0.9
|
|
21
|
+
Requires-Dist: shapely>=2.0
|
|
22
|
+
Requires-Dist: rasterio>=1.3
|
|
23
|
+
Requires-Dist: scipy>=1.11
|
|
24
|
+
Requires-Dist: numpy>=1.26
|
|
25
|
+
Requires-Dist: matplotlib>=3.8
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# watercolourmap
|
|
29
|
+
|
|
30
|
+
Watercolour maps in Python. Polygons are painted as soft washes on textured paper, with uneven pigment, darker edges and a thin line of bare paper between neighbouring shapes.
|
|
31
|
+
|
|
32
|
+
It's inspired by John Nelson's [watercolour style for ArcGIS Pro](https://www.esri.com/arcgis-blog/products/arcgis-pro/mapping/steal-this-watercolor-style-for-pro-please), recreated in code so it works with any GeoDataFrame. You can see example prints at [etymo.co.uk/cartography](https://etymo.co.uk/cartography/).
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install watercolourmap
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Paint any polygons
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import geopandas as gpd
|
|
44
|
+
import matplotlib.pyplot as plt
|
|
45
|
+
from watercolourmap import paint_gdf, PAPER
|
|
46
|
+
|
|
47
|
+
gdf = gpd.read_file("my_area.gpkg")
|
|
48
|
+
gdf = gdf.to_crs(gdf.estimate_utm_crs()) # needs a CRS in metres
|
|
49
|
+
|
|
50
|
+
img, extent = paint_gdf(
|
|
51
|
+
gdf, "landuse",
|
|
52
|
+
{"residential": "#c9a27e", "park": "#7a9a5a", "water": "#5f86b0"},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
fig, ax = plt.subplots(figsize=(8, 8), facecolor=PAPER)
|
|
56
|
+
ax.imshow(img, extent=extent)
|
|
57
|
+
ax.set_axis_off()
|
|
58
|
+
fig.savefig("map.png", dpi=300, facecolor=PAPER)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- Polygons whose value isn't in the colour dictionary are left as bare paper.
|
|
62
|
+
- `pixel_size` sets the resolution in metres per pixel. By default the longer side is about 3,000 pixels.
|
|
63
|
+
- `seed` changes the brushwork.
|
|
64
|
+
|
|
65
|
+
## Print posters from Copernicus Urban Atlas
|
|
66
|
+
|
|
67
|
+
The command line tool turns a [Copernicus Urban Atlas](https://land.copernicus.eu/en/products/urban-atlas) city file into a print-ready poster. You get a title, the painted map, a legend painted with the same brush, a scale bar and the data credit, saved as PDF and PNG.
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# An irregular circle, 1.8 km radius, with place labels
|
|
71
|
+
watercolourmap ELBASAN.fgb --center 20.097 41.106 --radius 1.8 \
|
|
72
|
+
--title "Elbasan Land Cover" \
|
|
73
|
+
--label "Kalaja|20.080|41.1125" --label "Lumi Shkumbin|20.0975|41.1006|-4" \
|
|
74
|
+
--paper A2 --out elbasan
|
|
75
|
+
|
|
76
|
+
# A 12 km rectangle with a hand-torn edge, and the sea painted in
|
|
77
|
+
watercolourmap ATHINA.fgb --center 23.688 37.958 --width 12 --sea \
|
|
78
|
+
--title "Athens Land Cover" --paper A2 --out athens
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Run `watercolourmap --help` for every option: `--bbox` or `--clip` for other areas, `--signature` and `--website` to sign your map, and `--dpi 100` for quick drafts.
|
|
82
|
+
|
|
83
|
+
Urban Atlas codes are grouped into seven classes: Blue, Green, Green-Grey and Grey Cover, Land Use Changing, Unknown and Other Natural. Roads and railways are left as bare paper. Urban Atlas maps land use rather than land cover, so some groupings are judgement calls. For example, marinas are coded as sports and leisure. You can see the full mapping in `urban_atlas_class()`.
|
|
84
|
+
|
|
85
|
+
Keep the credit on your maps. The tool adds it by default: *Contains modified Copernicus Land Monitoring Service information (Urban Atlas 2021) © European Union*.
|
|
86
|
+
|
|
87
|
+
## Building blocks
|
|
88
|
+
|
|
89
|
+
| Function | What it does |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `paint_gdf(gdf, column, colours)` | Paints any polygons and returns an image and its extent |
|
|
92
|
+
| `poster(gdf, legend, out, ...)` | Lays out a full poster from polygons with integer classes |
|
|
93
|
+
| `paint(classes, colours, pixel_size)` | Paints a raster of class ids |
|
|
94
|
+
| `irregular_circle(gdf, centre, radius)` | Keeps whole parcels inside a circle, for a ragged, parcel-shaped edge |
|
|
95
|
+
| `read_urban_atlas(path, bbox)` | Reads an Urban Atlas file and adds the legend class |
|
|
96
|
+
| `IGNITION` | The default seven-class legend and colours |
|
|
97
|
+
|
|
98
|
+
## Credits
|
|
99
|
+
|
|
100
|
+
- **Watercolour style:** inspired by John Nelson (Esri).
|
|
101
|
+
- **Legend:** from the University of Manchester IGNITION project land cover map.
|
|
102
|
+
- **Fonts:** Lora (SIL Open Font License) and FreeSerif (GPL with font exception) are bundled.
|
|
103
|
+
|
|
104
|
+
MIT licence © 2026 Eriola Trungu Impersimi
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/watercolourmap/__init__.py
|
|
5
|
+
src/watercolourmap/brush.py
|
|
6
|
+
src/watercolourmap/cli.py
|
|
7
|
+
src/watercolourmap/legends.py
|
|
8
|
+
src/watercolourmap/poster.py
|
|
9
|
+
src/watercolourmap/shapes.py
|
|
10
|
+
src/watercolourmap.egg-info/PKG-INFO
|
|
11
|
+
src/watercolourmap.egg-info/SOURCES.txt
|
|
12
|
+
src/watercolourmap.egg-info/dependency_links.txt
|
|
13
|
+
src/watercolourmap.egg-info/entry_points.txt
|
|
14
|
+
src/watercolourmap.egg-info/requires.txt
|
|
15
|
+
src/watercolourmap.egg-info/top_level.txt
|
|
16
|
+
src/watercolourmap/fonts/FreeSerif-LICENSE.txt
|
|
17
|
+
src/watercolourmap/fonts/FreeSerifItalic.ttf
|
|
18
|
+
src/watercolourmap/fonts/Lora-Italic-Variable.ttf
|
|
19
|
+
src/watercolourmap/fonts/Lora-OFL.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
watercolourmap
|