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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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.
@@ -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,2 @@
1
+ [console_scripts]
2
+ watercolourmap = watercolourmap.cli:main
@@ -0,0 +1,7 @@
1
+ geopandas>=1.0
2
+ pyogrio>=0.9
3
+ shapely>=2.0
4
+ rasterio>=1.3
5
+ scipy>=1.11
6
+ numpy>=1.26
7
+ matplotlib>=3.8
@@ -0,0 +1 @@
1
+ watercolourmap