pycoast 1.1.0__py3-none-any.whl → 1.8.0__py3-none-any.whl

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.
Files changed (175) hide show
  1. pycoast/__init__.py +30 -9
  2. pycoast/conftest.py +14 -0
  3. pycoast/cw_agg.py +761 -485
  4. pycoast/cw_base.py +1575 -785
  5. pycoast/cw_pil.py +581 -379
  6. pycoast/tests/__init__.py +1 -0
  7. pycoast/tests/brazil_shapefiles.png +0 -0
  8. pycoast/tests/brazil_shapefiles_agg.png +0 -0
  9. pycoast/tests/coasts_and_grid.ini +13 -0
  10. pycoast/tests/coasts_and_grid_agg.ini +17 -0
  11. pycoast/tests/contours_europe.png +0 -0
  12. pycoast/tests/contours_europe_agg.png +0 -0
  13. pycoast/tests/contours_europe_alpha.png +0 -0
  14. pycoast/tests/contours_geos.png +0 -0
  15. pycoast/tests/contours_geos_agg.png +0 -0
  16. pycoast/tests/dateline_boundary_cross.png +0 -0
  17. pycoast/tests/dateline_cross.png +0 -0
  18. pycoast/tests/eastern_shapes_agg.png +0 -0
  19. pycoast/tests/eastern_shapes_pil.png +0 -0
  20. pycoast/tests/grid_europe.png +0 -0
  21. pycoast/tests/grid_europe_agg.png +0 -0
  22. pycoast/tests/grid_europe_agg_txt.png +0 -0
  23. pycoast/tests/grid_from_dict_agg.png +0 -0
  24. pycoast/tests/grid_from_dict_pil.png +0 -0
  25. pycoast/tests/grid_geos.png +0 -0
  26. pycoast/tests/grid_geos_agg.png +0 -0
  27. pycoast/tests/grid_germ.png +0 -0
  28. pycoast/tests/grid_nh.png +0 -0
  29. pycoast/tests/grid_nh_agg.png +0 -0
  30. pycoast/tests/grid_nh_cfg_agg.png +0 -0
  31. pycoast/tests/lonlat_boundary_cross.png +0 -0
  32. pycoast/tests/nh_cities_agg.ini +26 -0
  33. pycoast/tests/nh_cities_agg.png +0 -0
  34. pycoast/tests/nh_cities_from_dict_agg.png +0 -0
  35. pycoast/tests/nh_cities_from_dict_pil.png +0 -0
  36. pycoast/tests/nh_cities_pil.ini +20 -0
  37. pycoast/tests/nh_cities_pil.png +0 -0
  38. pycoast/tests/nh_one_shapefile.ini +11 -0
  39. pycoast/tests/nh_points_agg.ini +24 -0
  40. pycoast/tests/nh_points_agg.png +0 -0
  41. pycoast/tests/nh_points_cfg_pil.png +0 -0
  42. pycoast/tests/nh_points_pil.ini +19 -0
  43. pycoast/tests/nh_points_pil.png +0 -0
  44. pycoast/tests/nh_polygons.png +0 -0
  45. pycoast/tests/nh_polygons_agg.png +0 -0
  46. pycoast/tests/no_h_scratch_agg.png +0 -0
  47. pycoast/tests/no_h_scratch_pil.png +0 -0
  48. pycoast/tests/no_v_scratch_agg.png +0 -0
  49. pycoast/tests/no_v_scratch_pil.png +0 -0
  50. pycoast/tests/one_shapefile_from_cfg_agg.png +0 -0
  51. pycoast/tests/one_shapefile_from_cfg_pil.png +0 -0
  52. pycoast/tests/test_data/DejaVuSerif.ttf +0 -0
  53. pycoast/tests/test_data/gshhs/CITIES/cities.txt +20 -0
  54. pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L1.dbf +0 -0
  55. pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L1.prj +1 -0
  56. pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L1.shp +0 -0
  57. pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L1.shx +0 -0
  58. pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L2.dbf +0 -0
  59. pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L2.prj +1 -0
  60. pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L2.shp +0 -0
  61. pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L2.shx +0 -0
  62. pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L3.dbf +0 -0
  63. pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L3.prj +1 -0
  64. pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L3.shp +0 -0
  65. pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L3.shx +0 -0
  66. pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L4.dbf +0 -0
  67. pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L4.prj +1 -0
  68. pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L4.shp +0 -0
  69. pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L4.shx +0 -0
  70. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L1.dbf +0 -0
  71. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L1.prj +1 -0
  72. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L1.shp +0 -0
  73. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L1.shx +0 -0
  74. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L2.dbf +0 -0
  75. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L2.prj +1 -0
  76. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L2.shp +0 -0
  77. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L2.shx +0 -0
  78. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L3.dbf +0 -0
  79. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L3.prj +1 -0
  80. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L3.shp +0 -0
  81. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L3.shx +0 -0
  82. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L01.dbf +0 -0
  83. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L01.prj +1 -0
  84. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L01.shp +0 -0
  85. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L01.shx +0 -0
  86. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L02.dbf +0 -0
  87. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L02.prj +1 -0
  88. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L02.shp +0 -0
  89. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L02.shx +0 -0
  90. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L03.dbf +0 -0
  91. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L03.prj +1 -0
  92. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L03.shp +0 -0
  93. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L03.shx +0 -0
  94. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L04.dbf +0 -0
  95. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L04.prj +1 -0
  96. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L04.shp +0 -0
  97. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L04.shx +0 -0
  98. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L05.dbf +0 -0
  99. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L05.prj +1 -0
  100. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L05.shp +0 -0
  101. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L05.shx +0 -0
  102. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L06.dbf +0 -0
  103. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L06.prj +1 -0
  104. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L06.shp +0 -0
  105. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L06.shx +0 -0
  106. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L07.dbf +0 -0
  107. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L07.prj +1 -0
  108. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L07.shp +0 -0
  109. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L07.shx +0 -0
  110. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L08.dbf +0 -0
  111. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L08.prj +1 -0
  112. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L08.shp +0 -0
  113. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L08.shx +0 -0
  114. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L09.dbf +0 -0
  115. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L09.prj +1 -0
  116. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L09.shp +0 -0
  117. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L09.shx +0 -0
  118. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L1.dbf +0 -0
  119. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L1.shp +0 -0
  120. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L1.shx +0 -0
  121. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L10.dbf +0 -0
  122. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L10.prj +1 -0
  123. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L10.shp +0 -0
  124. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L10.shx +0 -0
  125. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L11.dbf +0 -0
  126. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L11.prj +1 -0
  127. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L11.shp +0 -0
  128. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L11.shx +0 -0
  129. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L2.dbf +0 -0
  130. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L2.shp +0 -0
  131. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L2.shx +0 -0
  132. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L3.dbf +0 -0
  133. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L3.shp +0 -0
  134. pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L3.shx +0 -0
  135. pycoast/tests/test_data/shapes/Metareas.dbf +0 -0
  136. pycoast/tests/test_data/shapes/Metareas.mxd +0 -0
  137. pycoast/tests/test_data/shapes/Metareas.prj +1 -0
  138. pycoast/tests/test_data/shapes/Metareas.sbn +0 -0
  139. pycoast/tests/test_data/shapes/Metareas.sbx +0 -0
  140. pycoast/tests/test_data/shapes/Metareas.shp +0 -0
  141. pycoast/tests/test_data/shapes/Metareas.shx +0 -0
  142. pycoast/tests/test_data/shapes/README +3 -0
  143. pycoast/tests/test_data/shapes/divisao_politica/BRASIL.dbf +0 -0
  144. pycoast/tests/test_data/shapes/divisao_politica/BRASIL.shp +0 -0
  145. pycoast/tests/test_data/shapes/divisao_politica/BRASIL.shx +0 -0
  146. pycoast/tests/test_data/shapes/divisao_politica/BR_Capitais.dbf +0 -0
  147. pycoast/tests/test_data/shapes/divisao_politica/BR_Capitais.shp +0 -0
  148. pycoast/tests/test_data/shapes/divisao_politica/BR_Capitais.shx +0 -0
  149. pycoast/tests/test_data/shapes/divisao_politica/BR_Contorno.dbf +0 -0
  150. pycoast/tests/test_data/shapes/divisao_politica/BR_Contorno.shp +0 -0
  151. pycoast/tests/test_data/shapes/divisao_politica/BR_Contorno.shx +0 -0
  152. pycoast/tests/test_data/shapes/divisao_politica/BR_Regioes.dbf +0 -0
  153. pycoast/tests/test_data/shapes/divisao_politica/BR_Regioes.shp +0 -0
  154. pycoast/tests/test_data/shapes/divisao_politica/BR_Regioes.shx +0 -0
  155. pycoast/tests/test_data/shapes/divisao_politica/divisao_politica.txt +40 -0
  156. pycoast/tests/test_data/shapes/divisao_politica/leia.txt +9 -0
  157. pycoast/tests/test_data/shapes/metarea5.gsf +0 -0
  158. pycoast/tests/test_data/shapes/metarea5.tbl +21 -0
  159. pycoast/tests/test_data/shapes/metarea5.tbl.info +25 -0
  160. pycoast/tests/test_data/test_config.ini +12 -0
  161. pycoast/tests/test_pycoast.py +1913 -434
  162. pycoast/tests/two_shapefiles_agg.png +0 -0
  163. pycoast/tests/two_shapefiles_pil.png +0 -0
  164. pycoast/tests/western_shapes_agg.png +0 -0
  165. pycoast/tests/western_shapes_pil.png +0 -0
  166. pycoast/version.py +19 -17
  167. pycoast-1.8.0.dist-info/METADATA +107 -0
  168. pycoast-1.8.0.dist-info/RECORD +171 -0
  169. {pycoast-1.1.0.dist-info → pycoast-1.8.0.dist-info}/WHEEL +1 -1
  170. pycoast-1.8.0.dist-info/licenses/LICENSE.txt +201 -0
  171. pycoast-1.1.0.dist-info/DESCRIPTION.rst +0 -3
  172. pycoast-1.1.0.dist-info/METADATA +0 -24
  173. pycoast-1.1.0.dist-info/RECORD +0 -13
  174. pycoast-1.1.0.dist-info/metadata.json +0 -1
  175. {pycoast-1.1.0.dist-info → pycoast-1.8.0.dist-info}/top_level.txt +0 -0
pycoast/cw_base.py CHANGED
@@ -1,453 +1,191 @@
1
- #!/usr/bin/env python
2
- # -*- coding: utf-8 -*-
3
- # pycoast, Writing of coastlines, borders and rivers to images in Python
4
- #
5
- # Copyright (C) 2011-2015
6
- # Esben S. Nielsen
7
- # Hróbjartur Þorsteinsson
8
- # Stefano Cerino
9
- # Katja Hungershofer
10
- # Panu Lahtinen
11
- #
12
- # This program is free software: you can redistribute it and/or modify
13
- # it under the terms of the GNU General Public License as published by
14
- # the Free Software Foundation, either version 3 of the License, or
15
- # (at your option) any later version.
16
- #
17
- # This program is distributed in the hope that it will be useful,
18
- # but WITHOUT ANY WARRANTY; without even the implied warranty of
19
- # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20
- # GNU General Public License for more details.
21
- #
22
- # You should have received a copy of the GNU General Public License
23
- # along with this program. If not, see <http://www.gnu.org/licenses/>.
1
+ """Base class for contour writers."""
24
2
 
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import configparser
7
+ import hashlib
8
+ import json
9
+ import logging
10
+ import math
25
11
  import os
26
- import shapefile
12
+ from pathlib import Path
13
+ from typing import Generator
14
+
27
15
  import numpy as np
28
- from PIL import Image, ImageFont
29
- import pyproj
30
- import logging
16
+ import shapefile
17
+ from PIL import Image
18
+ from pyproj import CRS, Proj
31
19
 
32
20
  try:
33
- import configparser
21
+ from pyresample import AreaDefinition
34
22
  except ImportError:
35
- from six.moves import configparser
23
+ AreaDefinition = None
36
24
 
37
25
  logger = logging.getLogger(__name__)
38
26
 
39
27
 
40
- class ContourWriterBase(object):
28
+ def get_resolution_from_area(area_def):
29
+ """Get the best resolution for an area definition."""
30
+ x_size = area_def.width
31
+ y_size = area_def.height
32
+ x_resolution = abs(area_def.area_extent[2] - area_def.area_extent[0]) / x_size
33
+ y_resolution = abs(area_def.area_extent[3] - area_def.area_extent[1]) / y_size
34
+ res = min(x_resolution, y_resolution)
35
+ if "degree" in area_def.crs.axis_info[0].unit_name:
36
+ res = _estimate_metered_resolution_from_degrees(area_def.crs, res)
37
+
38
+ if res > 25000:
39
+ return "c"
40
+ elif res > 5000:
41
+ return "l"
42
+ elif res > 1000:
43
+ return "i"
44
+ elif res > 200:
45
+ return "h"
46
+ else:
47
+ return "f"
48
+
49
+
50
+ def _estimate_metered_resolution_from_degrees(crs: CRS, resolution_degrees: float) -> float:
51
+ major_radius = crs.datum.ellipsoid.semi_major_metre
52
+ # estimate by taking the arc length using the radius
53
+ return major_radius * math.radians(resolution_degrees)
54
+
41
55
 
56
+ class _CoordConverter:
57
+ """Convert coordinates from one space to in-bound image pixel column and row.
58
+
59
+ Convert the coordinate (x,y) in the coordinates
60
+ reference system ('lonlat' or 'image') into an image
61
+ x,y coordinate.
62
+ Uses the area_def methods if coord_ref is 'lonlat'.
63
+ Raises ValueError if pixel coordinates are outside the image bounds
64
+ defined by area_def.width and area_def.height.
65
+
66
+ """
67
+
68
+ def __init__(self, coord_ref: str, area_def: AreaDefinition):
69
+ self._area_def = self._check_area_def(area_def)
70
+ convert_methods = {
71
+ "lonlat": self._lonlat_to_pixels,
72
+ "image": self._image_to_pixels,
73
+ }
74
+ if coord_ref not in convert_methods:
75
+ pretty_coord_refs = [f"'{cr_name}'" for cr_name in sorted(convert_methods.keys())]
76
+ raise ValueError(f"'coord_ref' must be one of {pretty_coord_refs}.")
77
+ self._convert_method = convert_methods[coord_ref]
78
+
79
+ @staticmethod
80
+ def _check_area_def(area_def):
81
+ if AreaDefinition is None:
82
+ raise ImportError(
83
+ "Missing required 'pyresample' module, please "
84
+ "install it with 'pip install pyresample' or "
85
+ "'conda install pyresample'."
86
+ )
87
+ if not isinstance(area_def, AreaDefinition):
88
+ raise ValueError("'area_def' must be an instance of AreaDefinition")
89
+ return area_def
90
+
91
+ def __call__(self, x, y):
92
+ return self._convert_method(x, y)
93
+
94
+ def _lonlat_to_pixels(self, x, y):
95
+ return self._area_def.get_array_indices_from_lonlat(x, y)
96
+
97
+ def _image_to_pixels(self, x, y):
98
+ area_def = self._area_def
99
+ x, y = (int(x), int(y))
100
+ if x < 0:
101
+ x += area_def.width
102
+ if y < 0:
103
+ y += area_def.height
104
+ if x < 0 or y < 0 or x >= area_def.width or y >= area_def.height:
105
+ raise ValueError(
106
+ "Image pixel coords out of image bounds " f"(width={area_def.width}, height={area_def.height})."
107
+ )
108
+ return x, y
109
+
110
+
111
+ def hash_dict(dict_to_hash: dict) -> str:
112
+ """Hash dict object by serializing with json."""
113
+ dhash = hashlib.sha256()
114
+ encoded = json.dumps(dict_to_hash, sort_keys=True).encode()
115
+ dhash.update(encoded)
116
+ return dhash.hexdigest()
117
+
118
+
119
+ class ContourWriterBase(object):
42
120
  """Base class for contourwriters. Do not instantiate.
43
121
 
44
122
  :Parameters:
45
- db_root_path : str
46
- Path to root dir of GSHHS and WDBII shapefiles
123
+ db_root_path : str
124
+ Path to root dir of GSHHS and WDBII shapefiles
125
+
47
126
  """
48
127
 
49
- _draw_module = None
128
+ _draw_module = "FIXME"
50
129
  # This is a flag to make _add_grid aware of which draw.text
51
130
  # subroutine, from PIL, aggdraw or cairo is being used
52
131
  # (unfortunately they are not fully compatible).
53
132
 
54
133
  def __init__(self, db_root_path=None):
55
134
  if db_root_path is None:
56
- self.db_root_path = os.environ.get('GSHHS_DATA_ROOT')
135
+ self.db_root_path = os.environ.get("GSHHS_DATA_ROOT")
57
136
  else:
58
137
  self.db_root_path = db_root_path
59
138
 
60
- def _draw_text(self, draw, position, txt, font, align='cc', **kwargs):
61
- """Draw text with agg module
62
- """
63
- txt_width, txt_height = draw.textsize(txt, font)
139
+ @property
140
+ def is_agg(self) -> bool:
141
+ """Get if we are using the 'agg' backend."""
142
+ return self._draw_module == "AGG"
143
+
144
+ def _draw_text(self, draw, position, txt, font, align="cc", **kwargs):
145
+ """Draw text with agg module."""
146
+ if hasattr(draw, "textsize"):
147
+ txt_width, txt_height = draw.textsize(txt, font)
148
+ else:
149
+ left, top, right, bottom = draw.textbbox(position, txt, font)
150
+ # bbox is based on "left ascender" anchor for horizontal text
151
+ # but does not include the ascender to top distance.
152
+ # In order to include that additional distance we take height from
153
+ # anchor (`position`) to the bottom of the text. See:
154
+ # https://pillow.readthedocs.io/en/stable/handbook/text-anchors.html#text-anchors
155
+ txt_width, txt_height = right - left, bottom - position[1]
64
156
  x_pos, y_pos = position
65
157
  ax, ay = align.lower()
66
- if ax == 'r':
158
+ if ax == "r":
67
159
  x_pos = x_pos - txt_width
68
- elif ax == 'c':
160
+ elif ax == "c":
69
161
  x_pos = x_pos - txt_width / 2
70
162
 
71
- if ay == 'b':
163
+ if ay == "b":
72
164
  y_pos = y_pos - txt_height
73
- elif ay == 'c':
74
- y_pos = y_pos - txt_width / 2
165
+ elif ay == "c":
166
+ y_pos = y_pos - txt_height / 2
75
167
 
76
168
  self._engine_text_draw(draw, x_pos, y_pos, txt, font, **kwargs)
77
169
 
78
170
  def _engine_text_draw(self, draw, pos, txt, font, **kwargs):
79
- raise NotImplementedError('Text drawing undefined for render engine')
80
-
81
- def _draw_grid_labels(self, draw, xys, linetype, txt, font, **kwargs):
82
- """Draw text with default PIL module
83
- """
84
- placement_def = kwargs[linetype].lower()
85
- for xy in xys:
86
- # note xy[0] is xy coordinate pair,
87
- # xy[1] is required alignment e.g. 'tl','lr','lc','cc'...
88
- ax, ay = xy[1].lower()
89
- if ax in placement_def or ay in placement_def:
90
- self._draw_text(draw, xy[0], txt, font, align=xy[1], **kwargs)
91
-
92
- def _find_line_intercepts(self, xys, size, margins):
93
- """Finds intercepts of poly-line xys with image boundaries
94
- offset by margins and returns an array of coordintes"""
95
- x_size, y_size = size
96
-
97
- def is_in_box(x_y, extents):
98
- x, y = x_y
99
- xmin, xmax, ymin, ymax = extents
100
- if xmin < x < xmax and ymin < y < ymax:
101
- return True
102
- else:
103
- return False
104
-
105
- def crossing(x1, x2, lim):
106
- if (x1 < lim) != (x2 < lim):
107
- return True
108
- else:
109
- return False
110
-
111
- # set box limits
112
- xlim1 = margins[0]
113
- ylim1 = margins[1]
114
- xlim2 = x_size - margins[0]
115
- ylim2 = y_size - margins[0]
116
-
117
- # only consider crossing within a box a little bigger than grid
118
- # boundary
119
- search_box = (-10, x_size + 10, -10, y_size + 10)
120
-
121
- # loop trought line steps and detect crossings
122
- intercepts = []
123
- align_left = 'LC'
124
- align_right = 'RC'
125
- align_top = 'CT'
126
- align_bottom = 'CB'
127
- prev_xy = xys[0]
128
- for i in range(1, len(xys) - 1):
129
- xy = xys[i]
130
- if is_in_box(xy, search_box):
131
- # crossing LHS
132
- if crossing(prev_xy[0], xy[0], xlim1):
133
- x = xlim1
134
- y = xy[1]
135
- intercepts.append(((x, y), align_left))
136
- # crossing RHS
137
- elif crossing(prev_xy[0], xy[0], xlim2):
138
- x = xlim2
139
- y = xy[1]
140
- intercepts.append(((x, y), align_right))
141
- # crossing Top
142
- elif crossing(prev_xy[1], xy[1], ylim1):
143
- x = xy[0]
144
- y = ylim1
145
- intercepts.append(((x, y), align_top))
146
- # crossing Bottom
147
- elif crossing(prev_xy[1], xy[1], ylim2):
148
- x = xy[0] # - txt_width/2
149
- y = ylim2 # - txt_height
150
- intercepts.append(((x, y), align_bottom))
151
- prev_xy = xy
152
-
153
- return intercepts
154
-
155
- def _add_grid(self, image, area_def,
156
- Dlon, Dlat,
157
- dlon, dlat,
158
- font=None, write_text=True, **kwargs):
159
- """Add a lat lon grid to image
160
- """
161
-
162
- try:
163
- proj4_string = area_def.proj4_string
164
- area_extent = area_def.area_extent
165
- except AttributeError:
166
- proj4_string = area_def[0]
167
- area_extent = area_def[1]
168
-
171
+ raise NotImplementedError("Text drawing undefined for render engine")
172
+
173
+ def _add_grid(
174
+ self,
175
+ image,
176
+ area_def,
177
+ Dlon,
178
+ Dlat,
179
+ dlon,
180
+ dlat,
181
+ font=None,
182
+ write_text=True,
183
+ **kwargs,
184
+ ):
185
+ """Add a lat lon grid to image."""
169
186
  draw = self._get_canvas(image)
170
-
171
- is_agg = self._draw_module == "AGG"
172
-
173
- # use kwargs for major lines ... but reform for minor lines:
174
- minor_line_kwargs = kwargs.copy()
175
- minor_line_kwargs['outline'] = kwargs['minor_outline']
176
- if is_agg:
177
- minor_line_kwargs['outline_opacity'] = \
178
- kwargs['minor_outline_opacity']
179
- minor_line_kwargs['width'] = kwargs['minor_width']
180
-
181
- # set text fonts
182
- if font is None:
183
- font = ImageFont.load_default()
184
- # text margins (at sides of image frame)
185
- y_text_margin = 4
186
- x_text_margin = 4
187
-
188
- # Area and projection info
189
- x_size, y_size = image.size
190
- prj = pyproj.Proj(proj4_string)
191
-
192
- x_offset = 0
193
- y_offset = 0
194
-
195
- # Calculate min and max lons and lats of interest
196
- lon_min, lon_max, lat_min, lat_max = \
197
- _get_lon_lat_bounding_box(area_extent, x_size, y_size, prj)
198
-
199
- # Handle dateline crossing
200
- if lon_max < lon_min:
201
- lon_max = 360 + lon_max
202
-
203
- # Draw lonlat grid lines ...
204
- # create adjustment of line lengths to avoid cluttered pole lines
205
- if lat_max == 90.0:
206
- shorten_max_lat = Dlat
207
- else:
208
- shorten_max_lat = 0.0
209
-
210
- if lat_min == -90.0:
211
- increase_min_lat = Dlat
212
- else:
213
- increase_min_lat = 0.0
214
-
215
- # major lon lines
216
- round_lon_min = (lon_min - (lon_min % Dlon))
217
- maj_lons = np.arange(round_lon_min, lon_max, Dlon)
218
- maj_lons[maj_lons > 180] = maj_lons[maj_lons > 180] - 360
219
-
220
- # minor lon lines (ticks)
221
- min_lons = np.arange(round_lon_min, lon_max, dlon)
222
- min_lons[min_lons > 180] = min_lons[min_lons > 180] - 360
223
-
224
- # Get min_lons not in maj_lons
225
- min_lons = np.lib.arraysetops.setdiff1d(min_lons, maj_lons)
226
-
227
- # lats along major lon lines
228
- lin_lats = np.arange(lat_min + increase_min_lat,
229
- lat_max - shorten_max_lat,
230
- float(lat_max - lat_min) / y_size)
231
- # lin_lats in rather high definition so that it can be used to
232
- # posituion text labels near edges of image...
233
-
234
- # perhaps better to find the actual length of line in pixels...
235
-
236
- round_lat_min = (lat_min - (lat_min % Dlat))
237
-
238
- # major lat lines
239
- maj_lats = np.arange(round_lat_min + increase_min_lat, lat_max, Dlat)
240
-
241
- # minor lon lines (ticks)
242
- min_lats = np.arange(round_lat_min + increase_min_lat,
243
- lat_max - shorten_max_lat,
244
- dlat)
245
-
246
- # Get min_lats not in maj_lats
247
- min_lats = np.lib.arraysetops.setdiff1d(min_lats, maj_lats)
248
-
249
- # lons along major lat lines (extended slightly to avoid missing the
250
- # end)
251
- lin_lons = np.arange(lon_min, lon_max + Dlon / 5.0, Dlon / 10.0)
252
-
253
- # create dummpy shape object
254
- tmpshape = shapefile.Writer("")
255
-
256
- # MINOR LINES ######
257
- if not kwargs['minor_is_tick']:
258
- # minor lat lines
259
- for lat in min_lats:
260
- lonlats = [(x, lat) for x in lin_lons]
261
- tmpshape.points = lonlats
262
- index_arrays, is_reduced = _get_pixel_index(tmpshape,
263
- area_extent,
264
- x_size, y_size,
265
- prj,
266
- x_offset=x_offset,
267
- y_offset=y_offset)
268
- del is_reduced
269
- # Skip empty datasets
270
- if len(index_arrays) == 0:
271
- continue
272
- # make PIL draw the tick line...
273
- for index_array in index_arrays:
274
- self._draw_line(draw,
275
- index_array.flatten().tolist(),
276
- **minor_line_kwargs)
277
- # minor lon lines
278
- for lon in min_lons:
279
- lonlats = [(lon, x) for x in lin_lats]
280
- tmpshape.points = lonlats
281
- index_arrays, is_reduced = _get_pixel_index(tmpshape,
282
- area_extent,
283
- x_size, y_size,
284
- prj,
285
- x_offset=x_offset,
286
- y_offset=y_offset)
287
- # Skip empty datasets
288
- if len(index_arrays) == 0:
289
- continue
290
- # make PIL draw the tick line...
291
- for index_array in index_arrays:
292
- self._draw_line(draw,
293
- index_array.flatten().tolist(),
294
- **minor_line_kwargs)
295
-
296
- # MAJOR LINES AND MINOR TICKS ######
297
- # major lon lines and tick marks:
298
- for lon in maj_lons:
299
- # Draw 'minor' tick lines dlat separation along the lon
300
- if kwargs['minor_is_tick']:
301
- tick_lons = np.arange(lon - Dlon / 20.0,
302
- lon + Dlon / 20.0,
303
- Dlon / 50.0)
304
-
305
- for lat in min_lats:
306
- lonlats = [(x, lat) for x in tick_lons]
307
- tmpshape.points = lonlats
308
- index_arrays, is_reduced = \
309
- _get_pixel_index(tmpshape,
310
- area_extent,
311
- x_size, y_size,
312
- prj,
313
- x_offset=x_offset,
314
- y_offset=y_offset)
315
- # Skip empty datasets
316
- if len(index_arrays) == 0:
317
- continue
318
- # make PIL draw the tick line...
319
- for index_array in index_arrays:
320
- self._draw_line(draw,
321
- index_array.flatten().tolist(),
322
- **minor_line_kwargs)
323
-
324
- # Draw 'major' lines
325
- lonlats = [(lon, x) for x in lin_lats]
326
- tmpshape.points = lonlats
327
- index_arrays, is_reduced = _get_pixel_index(tmpshape, area_extent,
328
- x_size, y_size,
329
- prj,
330
- x_offset=x_offset,
331
- y_offset=y_offset)
332
- # Skip empty datasets
333
- if len(index_arrays) == 0:
334
- continue
335
-
336
- # make PIL draw the lines...
337
- for index_array in index_arrays:
338
- self._draw_line(draw,
339
- index_array.flatten().tolist(),
340
- **kwargs)
341
-
342
- # add lon text markings at each end of longitude line
343
- if write_text:
344
- if lon > 0.0:
345
- txt = "%.2dE" % (lon)
346
- else:
347
- txt = "%.2dW" % (-lon)
348
- xys = self._find_line_intercepts(index_array, image.size,
349
- (x_text_margin,
350
- y_text_margin))
351
-
352
- self._draw_grid_labels(draw, xys, 'lon_placement',
353
- txt, font, **kwargs)
354
-
355
- # major lat lines and tick marks:
356
- for lat in maj_lats:
357
- # Draw 'minor' tick dlon separation along the lat
358
- if kwargs['minor_is_tick']:
359
- tick_lats = np.arange(lat - Dlat / 20.0,
360
- lat + Dlat / 20.0,
361
- Dlat / 50.0)
362
- for lon in min_lons:
363
- lonlats = [(lon, x) for x in tick_lats]
364
- tmpshape.points = lonlats
365
- index_arrays, is_reduced = \
366
- _get_pixel_index(tmpshape, area_extent,
367
- x_size, y_size,
368
- prj,
369
- x_offset=x_offset,
370
- y_offset=y_offset)
371
- # Skip empty datasets
372
- if len(index_arrays) == 0:
373
- continue
374
- # make PIL draw the tick line...
375
- for index_array in index_arrays:
376
- self._draw_line(draw,
377
- index_array.flatten().tolist(),
378
- **minor_line_kwargs)
379
-
380
- # Draw 'major' lines
381
- lonlats = [(x, lat) for x in lin_lons]
382
- tmpshape.points = lonlats
383
- index_arrays, is_reduced = _get_pixel_index(tmpshape, area_extent,
384
- x_size, y_size,
385
- prj,
386
- x_offset=x_offset,
387
- y_offset=y_offset)
388
- # Skip empty datasets
389
- if len(index_arrays) == 0:
390
- continue
391
-
392
- # make PIL draw the lines...
393
- for index_array in index_arrays:
394
- self._draw_line(draw, index_array.flatten().tolist(), **kwargs)
395
-
396
- # add lat text markings at each end of parallels ...
397
- if write_text:
398
- if lat >= 0.0:
399
- txt = "%.2dN" % (lat)
400
- else:
401
- txt = "%.2dS" % (-lat)
402
- xys = self._find_line_intercepts(index_array, image.size,
403
- (x_text_margin,
404
- y_text_margin))
405
- self._draw_grid_labels(draw, xys, 'lat_placement',
406
- txt, font, **kwargs)
407
-
408
- # Draw cross on poles ...
409
- if lat_max == 90.0:
410
- crosslats = np.arange(90.0 - Dlat / 2.0, 90.0,
411
- float(lat_max - lat_min) / y_size)
412
- for lon in (0.0, 90.0, 180.0, -90.0):
413
- lonlats = [(lon, x) for x in crosslats]
414
- tmpshape.points = lonlats
415
- index_arrays, is_reduced = _get_pixel_index(tmpshape,
416
- area_extent,
417
- x_size, y_size,
418
- prj,
419
- x_offset=x_offset,
420
- y_offset=y_offset)
421
- # Skip empty datasets
422
- if len(index_arrays) == 0:
423
- continue
424
-
425
- # make PIL draw the lines...
426
- for index_array in index_arrays:
427
- self._draw_line(draw,
428
- index_array.flatten().tolist(),
429
- **kwargs)
430
- if lat_min == -90.0:
431
- crosslats = np.arange(-90.0, -90.0 + Dlat / 2.0,
432
- float(lat_max - lat_min) / y_size)
433
- for lon in (0.0, 90.0, 180.0, -90.0):
434
- lonlats = [(lon, x) for x in crosslats]
435
- tmpshape.points = lonlats
436
- index_arrays, is_reduced = _get_pixel_index(tmpshape,
437
- area_extent,
438
- x_size, y_size,
439
- prj,
440
- x_offset=x_offset,
441
- y_offset=y_offset)
442
- # Skip empty datasets
443
- if len(index_arrays) == 0:
444
- continue
445
-
446
- # make PIL draw the lines...
447
- for index_array in index_arrays:
448
- self._draw_line(draw,
449
- index_array.flatten().tolist(),
450
- **kwargs)
187
+ grid_drawer = _GridDrawer(self, draw, area_def, Dlon, Dlat, dlon, dlat, font, write_text, kwargs)
188
+ grid_drawer.draw_grid()
451
189
  self._finalize(draw)
452
190
 
453
191
  def _find_bounding_box(self, xys):
@@ -455,67 +193,96 @@ class ContourWriterBase(object):
455
193
  lats = [y for (x, y) in xys]
456
194
  return [min(lons), min(lats), max(lons), max(lats)]
457
195
 
458
- def _add_shapefile_shapes(self, image, area_def, filename,
459
- feature_type=None, **kwargs):
460
- """ for drawing all shapes (polygon/poly-lines) from a custom shape
461
- file onto a PIL image
462
- """
196
+ def _add_shapefile_shapes(self, image, area_def, filename, feature_type=None, **kwargs):
197
+ """Draw all shapes (polygon/poly-lines) from a shape file onto a PIL Image."""
463
198
  sf = shapefile.Reader(filename)
464
- return self.add_shapes(image, area_def, feature_type, sf.shapes(), **kwargs)
199
+ return self.add_shapes(image, area_def, sf.shapes(), feature_type=feature_type, **kwargs)
200
+
201
+ def _add_shapefile_shape(self, image, area_def, filename, shape_id, feature_type=None, **kwargs):
202
+ """Draw a single shape (polygon/poly-line) definition.
203
+
204
+ Accesses single shape using shape_id from a custom shape file.
465
205
 
466
- def _add_shapefile_shape(self, image, area_def, filename, shape_id,
467
- feature_type=None, **kwargs):
468
- """ for drawing a single shape (polygon/poly-line) definiton with id,
469
- shape_id from a custom shape file onto a PIL image
470
206
  """
471
207
  sf = shapefile.Reader(filename)
472
208
  shape = sf.shape(shape_id)
473
- return self.add_shapes(image, area_def, feature_type, [shape], **kwargs)
209
+ return self.add_shapes(image, area_def, [shape], feature_type=feature_type, **kwargs)
474
210
 
475
211
  def _add_line(self, image, area_def, lonlats, **kwargs):
476
- """ For drawing a custom polyline. Lon and lat coordinates given by the
477
- list lonlat.
212
+ """Draw a custom polyline.
213
+
214
+ Lon and lat coordinates given by the list lonlat.
215
+
478
216
  """
479
217
  # create dummpy shapelike object
480
218
  shape = type("", (), {})()
481
219
  shape.points = lonlats
482
220
  shape.parts = [0]
483
221
  shape.bbox = self._find_bounding_box(lonlats)
484
- self.add_shapes(image, area_def, "line", [shape], **kwargs)
222
+ self.add_shapes(image, area_def, [shape], feature_type="line", **kwargs)
485
223
 
486
224
  def _add_polygon(self, image, area_def, lonlats, **kwargs):
487
- """ For drawing a custom polygon. Lon and lat coordinates given by the
488
- list lonlat.
225
+ """Draw a custom polygon.
226
+
227
+ Lon and lat coordinates given by the list lonlat.
228
+
489
229
  """
490
230
  # create dummpy shapelike object
491
231
  shape = type("", (), {})()
492
232
  shape.points = lonlats
493
233
  shape.parts = [0]
494
234
  shape.bbox = self._find_bounding_box(lonlats)
495
- self.add_shapes(image, area_def, "polygon", [shape], **kwargs)
235
+ self.add_shapes(image, area_def, [shape], feature_type="polygon", **kwargs)
236
+
237
+ def add_shapes(
238
+ self,
239
+ image,
240
+ area_def,
241
+ shapes,
242
+ feature_type=None,
243
+ x_offset=0,
244
+ y_offset=0,
245
+ **kwargs,
246
+ ):
247
+ """Draw shape objects to PIL image.
248
+
249
+ :Parameters:
250
+ image : Image
251
+ PIL Image to draw shapes on
252
+ area_def : (proj_str, area_extent) or AreaDefinition
253
+ Geolocation information for the provided image
254
+ shapes: iterable
255
+ Series of shape objects from pyshp. Can also be a series
256
+ of 2-element tuples where the first element is the shape
257
+ object and the second is a dictionary of additional drawing
258
+ parameters for this shape.
259
+ feature_type: str
260
+ 'polygon' or 'line' or None for what to draw shapes as.
261
+ Default is to draw the shape with the type in the shapefile.
262
+ kwargs:
263
+ Extra drawing keyword arguments for all shapes
264
+
265
+ .. versionchanged: 1.2.0
266
+
267
+ Interface changed to have `shapes` before `feature_type` to allow
268
+ `feature_type` to be optional and default to `None`.
496
269
 
497
- def add_shapes(self, image, area_def, feature_type, shapes,
498
- x_offset=0, y_offset=0, **kwargs):
499
- """ For drawing shape objects to PIL image - better code reuse of
500
- drawing shapes - should be used in _add_feature and other methods of
501
- adding shapes including manually.
502
270
  """
503
271
  try:
504
- proj4_string = area_def.proj4_string
272
+ proj_def = area_def.crs if hasattr(area_def, "crs") else area_def.proj_dict
505
273
  area_extent = area_def.area_extent
506
274
  except AttributeError:
507
- proj4_string = area_def[0]
275
+ proj_def = area_def[0]
508
276
  area_extent = area_def[1]
509
277
 
510
278
  draw = self._get_canvas(image)
511
279
 
512
280
  # Area and projection info
513
281
  x_size, y_size = image.size
514
- prj = pyproj.Proj(proj4_string)
282
+ prj = Proj(proj_def)
515
283
 
516
284
  # Calculate min and max lons and lats of interest
517
- lon_min, lon_max, lat_min, lat_max = \
518
- _get_lon_lat_bounding_box(area_extent, x_size, y_size, prj)
285
+ lon_min, lon_max, lat_min, lat_max = _get_lon_lat_bounding_box(area_extent, x_size, y_size, prj)
519
286
 
520
287
  # Iterate through shapes
521
288
  for shape in shapes:
@@ -527,283 +294,401 @@ class ContourWriterBase(object):
527
294
  else:
528
295
  new_kwargs = kwargs
529
296
 
530
- if feature_type is None:
531
- if shape.shapeType == 3:
532
- ftype = "line"
533
- elif shape.shapeType == 5:
534
- ftype = "polygon"
535
- else:
536
- raise ValueError("Unsupported shape type: " +
537
- str(shape.shapeType))
538
- else:
539
- ftype = feature_type.lower()
540
-
541
- # Check if polygon is possibly relevant
542
- s_lon_ll, s_lat_ll, s_lon_ur, s_lat_ur = shape.bbox
543
- if lon_min > lon_max:
544
- pass
545
- elif (lon_max < s_lon_ll or lon_min > s_lon_ur or
546
- lat_max < s_lat_ll or lat_min > s_lat_ur):
547
- # Polygon is irrelevant
297
+ if self._polygon_is_irrelevant(lon_min, lon_max, lat_min, lat_max, shape):
548
298
  continue
549
299
 
550
- # iterate over shape parts (some shapes split into parts)
551
- # dummy shape part object
552
- shape_part = type("", (), {})()
553
- parts = list(shape.parts) + [len(shape.points)]
554
- for i in range(len(parts) - 1):
555
- shape_part.points = shape.points[parts[i]:parts[i + 1]]
556
-
557
- # Get pixel index coordinates of shape
558
- index_arrays, is_reduced = _get_pixel_index(shape_part,
559
- area_extent,
560
- x_size, y_size,
561
- prj,
562
- x_offset=x_offset,
563
- y_offset=y_offset)
564
-
565
- # Skip empty datasets
566
- if len(index_arrays) == 0:
567
- continue
568
-
569
- # Make PIL draw the polygon or line
570
- for index_array in index_arrays:
571
- if ftype == 'polygon' and not is_reduced:
572
- # Draw polygon if dataset has not been reduced
573
- self._draw_polygon(draw,
574
- index_array.flatten().tolist(),
575
- **new_kwargs)
576
- elif ftype.lower() == 'line' or is_reduced:
577
- # Draw line
578
- self._draw_line(draw,
579
- index_array.flatten().tolist(),
580
- **new_kwargs)
581
- else:
582
- raise ValueError('Unknown contour type: %s' % ftype)
583
-
300
+ self._add_shape(
301
+ shape,
302
+ feature_type,
303
+ area_extent,
304
+ x_size,
305
+ y_size,
306
+ prj,
307
+ x_offset,
308
+ y_offset,
309
+ draw,
310
+ new_kwargs,
311
+ )
584
312
  self._finalize(draw)
585
313
 
586
- def _add_feature(self, image, area_def, feature_type,
587
- db_name, tag=None, zero_pad=False, resolution='c',
588
- level=1, x_offset=0, y_offset=0, db_root_path=None,
589
- **kwargs):
590
- """Add a contour feature to image
591
- """
592
- shape_generator = self._iterate_db(
593
- db_name, tag, resolution, level, zero_pad,
594
- db_root_path=db_root_path
595
- )
314
+ @staticmethod
315
+ def _polygon_is_irrelevant(lon_min, lon_max, lat_min, lat_max, shape):
316
+ # Check if polygon is possibly relevant
317
+ s_lon_ll, s_lat_ll, s_lon_ur, s_lat_ur = shape.bbox
318
+ if lon_min <= lon_max:
319
+ # Area_extent west or east of dateline
320
+ shape_is_outside_lon = lon_max < s_lon_ll or lon_min > s_lon_ur
321
+ else:
322
+ # Area_extent spans over dateline
323
+ shape_is_outside_lon = lon_max < s_lon_ll and lon_min > s_lon_ur
324
+ shape_is_outside_lat = lat_max < s_lat_ll or lat_min > s_lat_ur
325
+ return shape_is_outside_lon or shape_is_outside_lat
326
+
327
+ def _add_shape(
328
+ self,
329
+ shape,
330
+ feature_type,
331
+ area_extent,
332
+ x_size,
333
+ y_size,
334
+ prj,
335
+ x_offset,
336
+ y_offset,
337
+ draw,
338
+ new_kwargs,
339
+ ):
340
+ ftype = self._feature_type_for_shape(shape, feature_type)
341
+
342
+ # iterate over shape parts (some shapes split into parts)
343
+ # dummy shape part object
344
+ parts = list(shape.parts) + [len(shape.points)]
345
+ for i in range(len(parts) - 1):
346
+ # Get pixel index coordinates of shape
347
+ points = shape.points[parts[i] : parts[i + 1]]
348
+ index_arrays, is_reduced = _get_pixel_index(
349
+ points,
350
+ area_extent,
351
+ x_size,
352
+ y_size,
353
+ prj,
354
+ x_offset=x_offset,
355
+ y_offset=y_offset,
356
+ )
357
+
358
+ # Skip empty datasets
359
+ if len(index_arrays) == 0:
360
+ return
596
361
 
597
- return self.add_shapes(image, area_def, feature_type, shape_generator,
598
- x_offset=x_offset, y_offset=y_offset, **kwargs)
362
+ # Make PIL draw the polygon or line
363
+ for index_array in index_arrays:
364
+ if ftype == "polygon" and not is_reduced:
365
+ # Draw polygon if dataset has not been reduced
366
+ self._draw_polygon(draw, index_array.flatten().tolist(), **new_kwargs)
367
+ elif ftype == "line" or is_reduced:
368
+ # Draw line
369
+ self._draw_line(draw, index_array.flatten().tolist(), **new_kwargs)
370
+ else:
371
+ raise ValueError("Unknown contour type: %s" % ftype)
372
+
373
+ @staticmethod
374
+ def _feature_type_for_shape(shape, feature_type):
375
+ if feature_type is not None:
376
+ return feature_type.lower()
377
+ if shape.shapeType == shapefile.POLYLINE:
378
+ ftype = "line"
379
+ elif shape.shapeType == shapefile.POLYGON:
380
+ ftype = "polygon"
381
+ else:
382
+ raise ValueError("Unsupported shape type: " + str(shape.shapeType))
383
+ return ftype
384
+
385
+ def _add_feature(
386
+ self,
387
+ image,
388
+ area_def,
389
+ feature_type,
390
+ db_name,
391
+ tag=None,
392
+ zero_pad=False,
393
+ resolution="c",
394
+ level=1,
395
+ x_offset=0,
396
+ y_offset=0,
397
+ db_root_path=None,
398
+ **kwargs,
399
+ ):
400
+ """Add a contour feature to image."""
401
+ shape_generator = self._iterate_db(db_name, tag, resolution, level, zero_pad, db_root_path=db_root_path)
402
+
403
+ return self.add_shapes(
404
+ image,
405
+ area_def,
406
+ shape_generator,
407
+ feature_type=feature_type,
408
+ x_offset=x_offset,
409
+ y_offset=y_offset,
410
+ **kwargs,
411
+ )
599
412
 
600
413
  def _iterate_db(self, db_name, tag, resolution, level, zero_pad, db_root_path=None):
601
- """Iterate through datasets
602
- """
414
+ """Iterate through datasets."""
603
415
  if db_root_path is None:
604
416
  db_root_path = self.db_root_path
605
417
  if db_root_path is None:
606
418
  raise ValueError("'db_root_path' must be specified to use this method")
607
-
608
- format_string = '%s_%s_'
609
- if tag is not None:
610
- format_string += '%s_'
611
-
612
- if zero_pad:
613
- format_string += 'L%02i.shp'
614
- else:
615
- format_string += 'L%s.shp'
616
-
617
- if type(level) not in (list,):
618
- level = range(1,level+1)
619
-
620
- for i in level:
621
-
622
- # One shapefile per level
623
- if tag is None:
624
- shapefilename = \
625
- os.path.join(db_root_path, '%s_shp' % db_name,
626
- resolution, format_string %
627
- (db_name, resolution, i))
628
- else:
629
- shapefilename = \
630
- os.path.join(db_root_path, '%s_shp' % db_name,
631
- resolution, format_string %
632
- (db_name, tag, resolution, i))
419
+ levels = range(1, level + 1) if not isinstance(level, list) else level
420
+ format_string, format_params = self._get_db_shapefile_format_and_params(db_name, resolution, tag, zero_pad)
421
+ shapefile_root_dir = os.path.join(db_root_path, f"{db_name}_shp", resolution)
422
+ for i in levels:
423
+ level_format_params = format_params + (i,)
424
+ shapefilename = os.path.join(shapefile_root_dir, format_string % level_format_params)
633
425
  try:
634
426
  s = shapefile.Reader(shapefilename)
635
427
  shapes = s.shapes()
636
428
  except AttributeError:
637
- raise ValueError('Could not find shapefile %s'
638
- % shapefilename)
639
-
640
- for shape in shapes:
641
- yield shape
429
+ raise ValueError("Could not find shapefile %s" % shapefilename)
642
430
 
643
- def _finalize(self, draw):
644
- """Do any need finalization of the drawing
645
- """
431
+ yield from shapes
646
432
 
647
- pass
433
+ @staticmethod
434
+ def _get_db_shapefile_format_and_params(db_name, resolution, tag, zero_pad):
435
+ format_string = "%s_%s_"
436
+ format_params = (db_name, resolution)
437
+ if tag is not None:
438
+ format_string += "%s_"
439
+ format_params = (db_name, tag, resolution)
648
440
 
649
- def add_overlay_from_config(self, config_file, area_def):
650
- """Create and return a transparent image adding all the
651
- overlays contained in a configuration file.
441
+ if zero_pad:
442
+ format_string += "L%02i.shp"
443
+ else:
444
+ format_string += "L%s.shp"
445
+ return format_string, format_params
652
446
 
653
- :Parameters:
654
- config_file : str
655
- Configuration file name
656
- area_def : object
657
- Area Definition of the creating image
658
- """
447
+ def _finalize(self, draw):
448
+ """Do any need finalization of the drawing."""
449
+ pass
659
450
 
451
+ def _config_to_dict(self, config_file):
452
+ """Convert a config file to a dict."""
660
453
  config = configparser.ConfigParser()
661
454
  try:
662
- with open(config_file, 'r'):
455
+ with open(config_file, "r"):
663
456
  logger.info("Overlays config file %s found", str(config_file))
664
457
  config.read(config_file)
665
458
  except IOError:
666
- logger.error("Overlays config file %s does not exist!",
667
- str(config_file))
459
+ logger.error("Overlays config file %s does not exist!", str(config_file))
668
460
  raise
669
461
  except configparser.NoSectionError:
670
462
  logger.error("Error in %s", str(config_file))
671
463
  raise
672
464
 
673
- # Cache management
674
- cache_file = None
675
- if config.has_section('cache'):
676
- config_file_name, config_file_extention = \
677
- os.path.splitext(config_file)
678
- cache_file = (config.get('cache', 'file') + '_' +
679
- area_def.area_id + '.png')
680
-
681
- try:
682
- configTime = os.path.getmtime(config_file)
683
- cacheTime = os.path.getmtime(cache_file)
684
- # Cache file will be used only if it's newer than config file
685
- if configTime < cacheTime:
686
- foreground = Image.open(cache_file)
687
- logger.info('Using image in cache %s', cache_file)
688
- return foreground
689
- else:
690
- logger.info("Cache file is not used "
691
- "because config file has changed")
692
- except OSError:
693
- logger.info("New overlay image will be saved in cache")
694
-
695
- x_size = area_def.x_size
696
- y_size = area_def.y_size
697
- foreground = Image.new('RGBA', (x_size, y_size), (0, 0, 0, 0))
698
-
699
- # Lines (coasts, rivers, borders) management
700
- prj = pyproj.Proj(area_def.proj4_string)
701
- if prj.is_latlong():
702
- x_ll, y_ll = prj(area_def.area_extent[0], area_def.area_extent[1])
703
- x_ur, y_ur = prj(area_def.area_extent[2], area_def.area_extent[3])
704
- x_resolution = (x_ur - x_ll) / x_size
705
- y_resolution = (y_ur - y_ll) / y_size
706
- else:
707
- x_resolution = ((area_def.area_extent[2] -
708
- area_def.area_extent[0]) /
709
- x_size)
710
- y_resolution = ((area_def.area_extent[3] -
711
- area_def.area_extent[1]) /
712
- y_size)
713
- res = min(x_resolution, y_resolution)
714
-
715
- if res > 25000:
716
- default_resolution = "c"
717
- elif res > 5000:
718
- default_resolution = "l"
719
- elif res > 1000:
720
- default_resolution = "i"
721
- elif res > 200:
722
- default_resolution = "h"
723
- else:
724
- default_resolution = "f"
725
-
726
- DEFAULT = {'level': 1,
727
- 'outline': 'white',
728
- 'width': 1,
729
- 'fill': None,
730
- 'fill_opacity': 255,
731
- 'outline_opacity': 255,
732
- 'x_offset': 0,
733
- 'y_offset': 0,
734
- 'resolution': default_resolution}
735
-
736
- SECTIONS = ['coasts', 'rivers', 'borders', 'cities']
465
+ SECTIONS = [
466
+ "cache",
467
+ "coasts",
468
+ "rivers",
469
+ "borders",
470
+ "shapefiles",
471
+ "grid",
472
+ "cities",
473
+ "points",
474
+ ]
737
475
  overlays = {}
738
-
739
476
  for section in config.sections():
740
477
  if section in SECTIONS:
741
478
  overlays[section] = {}
742
479
  for option in config.options(section):
743
- overlays[section][option] = config.get(section, option)
744
-
745
- is_agg = self._draw_module == "AGG"
746
-
747
- # Coasts
748
- for section, fun in zip(['coasts', 'rivers', 'borders'],
749
- [self.add_coastlines,
750
- self.add_rivers,
751
- self.add_borders]):
752
-
753
- if section in overlays:
754
-
755
- params = DEFAULT.copy()
756
- params.update(overlays[section])
757
-
758
- params['level'] = int(params['level'])
759
- params['x_offset'] = float(params['x_offset'])
760
- params['y_offset'] = float(params['y_offset'])
761
- params['width'] = float(params['width'])
762
- params['outline_opacity'] = int(params['outline_opacity'])
763
- params['fill_opacity'] = int(params['fill_opacity'])
764
-
765
- if section != "coasts":
766
- params.pop('fill_opacity', None)
767
- params.pop('fill', None)
768
-
769
- if not is_agg:
770
- for key in ['width', 'outline_opacity', 'fill_opacity']:
771
- params.pop(key, None)
772
-
773
- fun(foreground, area_def, **params)
774
- logger.info("%s added", section.capitalize())
775
-
776
- # Cities management
777
- if 'cities' in overlays:
778
- DEFAULT_FONT_SIZE = 12
779
- DEFAULT_OUTLINE = "yellow"
780
-
781
- citylist = [s.lstrip()
782
- for s in overlays['cities']['list'].split(',')]
783
- font_file = overlays['cities']['font']
784
- font_size = int(overlays['cities'].get('font_size',
785
- DEFAULT_FONT_SIZE))
786
- outline = overlays['cities'].get('outline', DEFAULT_OUTLINE)
787
- pt_size = int(overlays['cities'].get('pt_size', None))
788
- box_outline = overlays['cities'].get('box_outline', None)
789
- box_opacity = int(overlays['cities'].get('box_opacity', 255))
790
-
791
- self.add_cities(foreground, area_def, citylist, font_file,
792
- font_size, pt_size, outline, box_outline,
793
- box_opacity)
794
-
795
- if cache_file is not None:
796
- try:
797
- foreground.save(cache_file)
798
- except IOError as e:
799
- logger.error("Can't save cache: %s", str(e))
480
+ val = config.get(section, option)
481
+ try:
482
+ overlays[section][option] = ast.literal_eval(val)
483
+ except ValueError:
484
+ overlays[section][option] = val
485
+ return overlays
486
+
487
+ def add_overlay_from_dict(self, overlays, area_def, cache_epoch=None, background=None):
488
+ """Create and return a transparent image adding all the overlays contained in the ``overlays`` dict.
489
+
490
+ Optionally caches overlay results for faster rendering of images with
491
+ the same provided AreaDefinition and parameters. Cached results are
492
+ identified by hashing the AreaDefinition and the ``overlays`` dictionary.
493
+
494
+ Note that if ``background`` is provided and caching is not used, the
495
+ result will be the final result of applying the overlays onto the
496
+ background. This is due to an optimization step avoiding creating a
497
+ separate overlay image in memory when it isn't needed.
498
+
499
+ .. warning::
500
+
501
+ Font objects are ignored in parameter hashing as they can't be easily hashed.
502
+ Therefore, font changes will not trigger a new rendering for the cache.
503
+
504
+ :Parameters:
505
+ overlays : dict
506
+ overlays configuration
507
+ area_def : object
508
+ Area Definition of the creating image
509
+ cache_epoch: seconds since epoch
510
+ The latest time allowed for cache the cache file. If the cache
511
+ file is older than this (mtime), the cache should be
512
+ regenerated. Defaults to 0 meaning always reuse the cached
513
+ file if it exists. Requires "cache" to be configured in the
514
+ provided dictionary (see below).
515
+ background: pillow image instance
516
+ The image on which to write the overlays on. If it's None (default),
517
+ a new image is created, otherwise the provided background is
518
+ used and changed *in place*.
519
+
520
+ The keys in ``overlays`` that will be taken into account are:
521
+ cache, coasts, rivers, borders, shapefiles, grid, cities, points
522
+
523
+ For all of them except ``cache``, the items are the same as the
524
+ corresponding functions in pycoast, so refer to the docstrings of
525
+ these functions (add_coastlines, add_rivers, add_borders,
526
+ add_shapefile_shapes, add_grid, add_cities, add_points).
527
+ For cache, two parameters are configurable:
528
+
529
+ - `file`:
530
+ specify the directory and the prefix
531
+ of the file to save the caches decoration to (for example
532
+ /var/run/black_coasts_red_borders)
533
+ - `regenerate`:
534
+ True or False (default) to force the overwriting
535
+ of an already cached file.
536
+
537
+ :Returns: PIL.Image.Image
538
+
539
+ Resulting overlays as an Image object. If caching was used then
540
+ the Image wraps an open file and should be closed by the caller.
541
+ If caching was not used or the cached image was recreated then
542
+ this is an in-memory Image object. Regardless, it can be closed
543
+ by calling the ``.close()`` method of the Image.
544
+
545
+ """
546
+ overlay_helper = _OverlaysFromDict(self, overlays, area_def, cache_epoch, background)
547
+ return overlay_helper.apply_overlays()
548
+
549
+ def add_overlay_from_config(self, config_file, area_def, background=None):
550
+ """Create and return a transparent image adding all the overlays contained in a configuration file.
551
+
552
+ See :meth:`add_overlay_from_dict` for more information.
800
553
 
801
- return foreground
554
+ :Parameters:
555
+ config_file : str
556
+ Configuration file name
557
+ area_def : object
558
+ Area Definition of the creating image
559
+
560
+ :Returns: PIL.Image.Image
561
+
562
+ Resulting overlays as an Image object. If caching was used then
563
+ the Image wraps an open file and should be closed by the caller.
564
+ If caching was not used or the cached image was recreated then
565
+ this is an in-memory Image object. Regardless, it can be closed
566
+ by calling the ``.close()`` method of the Image.
802
567
 
803
- def add_cities(self, image, area_def, citylist, font_file, font_size,
804
- ptsize, outline, box_outline, box_opacity, db_root_path=None):
805
- """Add cities (point and name) to a PIL image object
568
+ """
569
+ overlays = self._config_to_dict(config_file)
570
+ return self.add_overlay_from_dict(overlays, area_def, os.path.getmtime(config_file), background)
571
+
572
+ def draw_star(self, draw, symbol, x, y, ptsize, **kwargs):
573
+ # 5 <= n <= 8, symbol = string in ['star8', 'star7', 'star6', 'star5']
574
+ n = int(symbol[4])
575
+ alpha2 = math.pi / n
576
+ # r1 = outer radius (defaults to 0.5 * ptsize), r1 > r2 = inner radius
577
+ r1 = 0.5 * ptsize
578
+ r2 = r1 / (math.cos(alpha2) + math.sin(alpha2) * math.tan(2.0 * alpha2))
579
+ xy = []
580
+ alpha = 0.0
581
+ # Walk from star top ray CW around the symbol
582
+ for i in range(2 * n):
583
+ if (i % 2) == 0:
584
+ xy.append(x + r1 * math.sin(alpha))
585
+ xy.append(y - r1 * math.cos(alpha))
586
+ else:
587
+ xy.append(x + r2 * math.sin(alpha))
588
+ xy.append(y - r2 * math.cos(alpha))
589
+ alpha += alpha2
590
+ self._draw_polygon(draw, xy, **kwargs)
591
+
592
+ def draw_hexagon(self, draw, x, y, ptsize, **kwargs):
593
+ xy = [
594
+ x + 0.25 * ptsize,
595
+ y - 0.4330127 * ptsize,
596
+ x + 0.50 * ptsize,
597
+ y,
598
+ x + 0.25 * ptsize,
599
+ y + 0.4330127 * ptsize,
600
+ x - 0.25 * ptsize,
601
+ y + 0.4330127 * ptsize,
602
+ x - 0.50 * ptsize,
603
+ y,
604
+ x - 0.25 * ptsize,
605
+ y - 0.4330127 * ptsize,
606
+ ]
607
+ self._draw_polygon(draw, xy, **kwargs)
608
+
609
+ def draw_pentagon(self, draw, x, y, ptsize, **kwargs):
610
+ xy = [
611
+ x,
612
+ y - 0.5 * ptsize,
613
+ x + 0.4755283 * ptsize,
614
+ y - 0.1545085 * ptsize,
615
+ x + 0.2938926 * ptsize,
616
+ y + 0.4045085 * ptsize,
617
+ x - 0.2938926 * ptsize,
618
+ y + 0.4045085 * ptsize,
619
+ x - 0.4755283 * ptsize,
620
+ y - 0.1545085 * ptsize,
621
+ ]
622
+ self._draw_polygon(draw, xy, **kwargs)
623
+
624
+ def draw_triangle(self, draw, x, y, ptsize, **kwargs):
625
+ xy = [
626
+ x,
627
+ y - 0.5 * ptsize,
628
+ x + 0.4330127 * ptsize,
629
+ y + 0.25 * ptsize,
630
+ x - 0.4330127 * ptsize,
631
+ y + 0.25 * ptsize,
632
+ ]
633
+ self._draw_polygon(draw, xy, **kwargs)
634
+
635
+ def add_cities(
636
+ self,
637
+ image,
638
+ area_def,
639
+ cities_list,
640
+ font_file,
641
+ font_size=12,
642
+ symbol="circle",
643
+ ptsize=6,
644
+ outline="black",
645
+ fill="white",
646
+ db_root_path=None,
647
+ **kwargs,
648
+ ):
649
+ """Add cities (symbol and UTF-8 names as description) to a PIL image object.
806
650
 
651
+ :Parameters:
652
+ image : object
653
+ PIL image object
654
+ area_def : object
655
+ Area Definition of the provided image
656
+ cities_list : list of city names ['City1', 'City2', City3, ..., 'CityN']
657
+ | a list of UTF-8 or ASCII strings. If either of these strings is found
658
+ | in file db_root_path/CITIES/cities.red, longitude and latitude is read
659
+ | and the city is added like a point with its UTF-8 name as description
660
+ | e.g. cities_list = ['Zurich', 'Oslo'] will add cities 'Zürich', 'Oslo'.
661
+ | Check the README_PyCoast.txt in archive cities2022.zip for more details.
662
+ font_file : str
663
+ Path to font file
664
+ font_size : int
665
+ Size of font
666
+ symbol : string
667
+ type of symbol, one of the elelments from the list
668
+ ['circle', 'hexagon', 'pentagon', 'square', 'triangle',
669
+ 'star8', 'star7', 'star6', 'star5', 'asterisk']
670
+ ptsize : int
671
+ Size of the point.
672
+ outline : str or (R, G, B), optional
673
+ Line color of the symbol
674
+ fill : str or (R, G, B), optional
675
+ Filling color of the symbol
676
+
677
+ :Optional keyword arguments:
678
+ width : float
679
+ Line width of the symbol
680
+ outline_opacity : int, optional {0; 255}
681
+ Opacity of the line of the symbol.
682
+ fill_opacity : int, optional {0; 255}
683
+ Opacity of the filling of the symbol
684
+ box_outline : str or (R, G, B), optional
685
+ Line color of the textbox borders.
686
+ box_linewidth : float
687
+ Line width of the the borders of the textbox
688
+ box_fill : str or (R, G, B), optional
689
+ Filling color of the background of the textbox.
690
+ box_opacity : int, optional {0; 255}
691
+ Opacity of the background filling of the textbox.
807
692
  """
808
693
  if db_root_path is None:
809
694
  db_root_path = self.db_root_path
@@ -811,128 +696,339 @@ class ContourWriterBase(object):
811
696
  raise ValueError("'db_root_path' must be specified to use this method")
812
697
 
813
698
  try:
814
- proj4_string = area_def.proj4_string
815
- area_extent = area_def.area_extent
816
- except AttributeError:
817
- proj4_string = area_def[0]
818
- area_extent = area_def[1]
699
+ from pyresample.geometry import AreaDefinition
700
+ except ImportError:
701
+ raise ImportError("Missing required 'pyresample' module, please install it.")
702
+
703
+ if not isinstance(area_def, AreaDefinition):
704
+ raise ValueError("Expected 'area_def' is an instance of AreaDefinition object")
819
705
 
820
706
  draw = self._get_canvas(image)
821
707
 
822
- # Area and projection info
823
- x_size, y_size = image.size
824
- prj = pyproj.Proj(proj4_string)
825
-
826
- # read shape file with points
827
- # Sc-Kh shapefilename = os.path.join(self.db_root_path,
828
- # "cities_15000_alternativ.shp")
829
- shapefilename = os.path.join(
830
- db_root_path, os.path.join("CITIES",
831
- "cities_15000_alternativ.shp"))
832
- try:
833
- s = shapefile.Reader(shapefilename)
834
- shapes = s.shapes()
835
- except AttributeError:
836
- raise ValueError('Could not find shapefile %s'
837
- % shapefilename)
708
+ # cities.red is a reduced version of the files avalable at http://download.geonames.org
709
+ # Fields: 0=name (UTF-8), 1=asciiname, 2=longitude [°E], 3=latitude [°N], 4=countrycode
710
+ cities_filename = os.path.join(db_root_path, os.path.join("CITIES", "cities.txt"))
711
+ for city_name, lon, lat in iter_cities_names_lon_lat(cities_filename, cities_list):
712
+ try:
713
+ x, y = area_def.get_array_indices_from_lonlat(lon, lat)
714
+ except ValueError:
715
+ logger.info(
716
+ "City %s is out of the area, it will not be added to the image.",
717
+ city_name + " " + str((lon, lat)),
718
+ )
719
+ continue
720
+ # add symbol
721
+ if ptsize != 0:
722
+ half_ptsize = int(round(ptsize / 2.0))
723
+ dot_box = [
724
+ x - half_ptsize,
725
+ y - half_ptsize,
726
+ x + half_ptsize,
727
+ y + half_ptsize,
728
+ ]
729
+
730
+ width = kwargs.get("width", 1.0)
731
+ outline_opacity = kwargs.get("outline_opacity", 255)
732
+ fill_opacity = kwargs.get("fill_opacity", 255)
733
+ self._draw_point_element(
734
+ draw,
735
+ symbol,
736
+ dot_box,
737
+ x,
738
+ y,
739
+ width,
740
+ ptsize,
741
+ outline,
742
+ outline_opacity,
743
+ fill,
744
+ fill_opacity,
745
+ )
746
+ text_position = [x + ptsize, y]
747
+ else:
748
+ text_position = [x, y]
749
+
750
+ font = self._get_font(outline, font_file, font_size)
751
+ new_kwargs = kwargs.copy()
752
+ box_outline = new_kwargs.pop("box_outline", "white")
753
+ box_opacity = new_kwargs.pop("box_opacity", 0)
754
+
755
+ # add text_box
756
+ self._draw_text_box(
757
+ draw,
758
+ text_position,
759
+ city_name,
760
+ font,
761
+ outline,
762
+ box_outline,
763
+ box_opacity,
764
+ **new_kwargs,
765
+ )
766
+ logger.info("%s added", city_name + " " + str((lon, lat)))
767
+ self._finalize(draw)
838
768
 
839
- font = self._get_font(outline, font_file, font_size)
769
+ def add_points(
770
+ self,
771
+ image,
772
+ area_def,
773
+ points_list,
774
+ font_file,
775
+ font_size=12,
776
+ symbol="circle",
777
+ ptsize=6,
778
+ outline="black",
779
+ fill="white",
780
+ coord_ref="lonlat",
781
+ **kwargs,
782
+ ):
783
+ """Add a symbol and/or text at the point(s) of interest to a PIL image object.
840
784
 
841
- # Iterate through shapes
842
- for i, shape in enumerate(shapes):
843
- # Select cities with name
844
- record = s.record(i)
845
- if record[3] in citylist:
846
-
847
- city_name = record[3]
848
-
849
- # use only parts of _get_pixel_index
850
- # Get shape data as array and reproject
851
- shape_data = np.array(shape.points)
852
- lons = shape_data[:, 0][0]
853
- lats = shape_data[:, 1][0]
854
-
855
- try:
856
- (x, y) = area_def.get_xy_from_lonlat(lons, lats)
857
- except ValueError as exc:
858
- logger.debug("Point not added (%s)", str(exc))
859
- else:
785
+ :Parameters:
786
+ image : object
787
+ PIL image object
788
+ area_def : object
789
+ Area Definition of the provided image
790
+ points_list : list [((x, y), desc)]
791
+ | a list of points defined with (x, y) in float and a desc in string
792
+ | [((x1, y1), desc1), ((x2, y2), desc2)]
793
+ | See coord_ref (below) for the meaning of x, y.
794
+ | x : float
795
+ | longitude or pixel x of a point
796
+ | y : float
797
+ | latitude or pixel y of a point
798
+ | desc : str
799
+ | description of a point
800
+ font_file : str
801
+ Path to font file
802
+ font_size : int
803
+ Size of font
804
+ symbol : string
805
+ type of symbol, one of the elelments from the list
806
+ ['circle', 'hexagon', 'pentagon', 'square', 'triangle',
807
+ 'star8', 'star7', 'star6', 'star5, 'asterisk']
808
+ ptsize : int
809
+ Size of the point (should be zero if symbol:None).
810
+ outline : str or (R, G, B), optional
811
+ Line color of the symbol
812
+ fill : str or (R, G, B), optional
813
+ Filling color of the symbol
814
+
815
+ :Optional keyword arguments:
816
+ coord_ref : string
817
+ The interpretation of x,y in points_list:
818
+ 'lonlat' (the default: x is degrees E, y is degrees N),
819
+ or 'image' (x is pixels right, y is pixels down).
820
+ If image coordinates are negative they are interpreted
821
+ relative to the end of the dimension like standard Python
822
+ indexing.
823
+ width : float
824
+ Line width of the symbol
825
+ outline_opacity : int, optional {0; 255}
826
+ Opacity of the line of the symbol.
827
+ fill_opacity : int, optional {0; 255}
828
+ Opacity of the filling of the symbol
829
+ box_outline : str or (R, G, B), optional
830
+ Line color of the textbox borders.
831
+ box_linewidth : float
832
+ Line width of the the borders of the textbox
833
+ box_fill : str or (R, G, B), optional
834
+ Filling color of the background of the textbox.
835
+ box_opacity : int, optional {0; 255}
836
+ Opacity of the background filling of the textbox.
837
+ """
838
+ coord_converter = _CoordConverter(coord_ref, area_def)
839
+ draw = self._get_canvas(image)
840
+
841
+ # Iterate through points list
842
+ for point in points_list:
843
+ (x, y), desc = point
844
+ try:
845
+ x, y = coord_converter(x, y)
846
+ except ValueError:
847
+ logger.info(f"Point ({x}, {y}) is out of the image area, it will not be added to the image.")
848
+ continue
849
+ if ptsize != 0:
850
+ half_ptsize = int(round(ptsize / 2.0))
851
+ dot_box = [
852
+ x - half_ptsize,
853
+ y - half_ptsize,
854
+ x + half_ptsize,
855
+ y + half_ptsize,
856
+ ]
857
+
858
+ width = kwargs.get("width", 1.0)
859
+ outline_opacity = kwargs.get("outline_opacity", 255)
860
+ fill_opacity = kwargs.get("fill_opacity", 255)
861
+ self._draw_point_element(
862
+ draw,
863
+ symbol,
864
+ dot_box,
865
+ x,
866
+ y,
867
+ width,
868
+ ptsize,
869
+ outline,
870
+ outline_opacity,
871
+ fill,
872
+ fill_opacity,
873
+ )
874
+ elif desc is None:
875
+ logger.error("'ptsize' is 0 and 'desc' is None, nothing will be added to the image.")
876
+
877
+ if desc is not None:
878
+ text_position = [
879
+ x + ptsize,
880
+ y,
881
+ ] # draw the text box next to the point
882
+ font = self._get_font(outline, font_file, font_size)
883
+
884
+ new_kwargs = kwargs.copy()
860
885
 
861
- # add_dot
862
- if ptsize is not None:
863
- dot_box = [x - ptsize, y - ptsize,
864
- x + ptsize, y + ptsize]
865
- self._draw_ellipse(
866
- draw, dot_box, fill=outline, outline=outline)
867
- text_position = [x + 9, y - 5] # FIX ME
868
- else:
869
- text_position = [x, y]
886
+ box_outline = new_kwargs.pop("box_outline", "white")
887
+ box_opacity = new_kwargs.pop("box_opacity", 0)
870
888
 
871
889
  # add text_box
872
- self._draw_text_box(draw, text_position, city_name, font,
873
- outline, box_outline, box_opacity)
874
- logger.info("%s added", str(city_name))
890
+ self._draw_text_box(
891
+ draw,
892
+ text_position,
893
+ desc,
894
+ font,
895
+ outline,
896
+ box_outline,
897
+ box_opacity,
898
+ **new_kwargs,
899
+ )
900
+
901
+ logger.debug("Point %s has been added to the image", str((x, y)))
875
902
 
876
903
  self._finalize(draw)
877
904
 
905
+ def _draw_point_element(
906
+ self,
907
+ draw,
908
+ symbol,
909
+ dot_box,
910
+ x,
911
+ y,
912
+ width,
913
+ ptsize,
914
+ outline,
915
+ outline_opacity,
916
+ fill,
917
+ fill_opacity,
918
+ ):
919
+ if symbol == "circle":
920
+ # a 'circle' or a 'dot' i.e. circle with fill
921
+ self._draw_ellipse(
922
+ draw,
923
+ dot_box,
924
+ outline=outline,
925
+ width=width,
926
+ outline_opacity=outline_opacity,
927
+ fill=fill,
928
+ fill_opacity=fill_opacity,
929
+ )
930
+ # All regular polygons are drawn horizontally based
931
+ elif symbol == "hexagon":
932
+ self.draw_hexagon(
933
+ draw,
934
+ x,
935
+ y,
936
+ ptsize,
937
+ outline=outline,
938
+ width=width,
939
+ outline_opacity=outline_opacity,
940
+ fill=fill,
941
+ fill_opacity=fill_opacity,
942
+ )
943
+ elif symbol == "pentagon":
944
+ self.draw_pentagon(
945
+ draw,
946
+ x,
947
+ y,
948
+ ptsize,
949
+ outline=outline,
950
+ width=width,
951
+ outline_opacity=outline_opacity,
952
+ fill=fill,
953
+ fill_opacity=fill_opacity,
954
+ )
955
+ elif symbol == "square":
956
+ self._draw_rectangle(
957
+ draw,
958
+ dot_box,
959
+ outline=outline,
960
+ width=width,
961
+ outline_opacity=outline_opacity,
962
+ fill=fill,
963
+ fill_opacity=fill_opacity,
964
+ )
965
+ elif symbol == "triangle":
966
+ self.draw_triangle(
967
+ draw,
968
+ x,
969
+ y,
970
+ ptsize,
971
+ outline=outline,
972
+ width=width,
973
+ outline_opacity=outline_opacity,
974
+ fill=fill,
975
+ fill_opacity=fill_opacity,
976
+ )
977
+ # All stars are drawn with one vertical ray on top
978
+ elif symbol in ["star8", "star7", "star6", "star5"]:
979
+ self.draw_star(
980
+ draw,
981
+ symbol,
982
+ x,
983
+ y,
984
+ ptsize,
985
+ outline=outline,
986
+ width=width,
987
+ outline_opacity=outline_opacity,
988
+ fill=fill,
989
+ fill_opacity=fill_opacity,
990
+ )
991
+ elif symbol == "asterisk": # an '*' sign
992
+ self._draw_asterisk(
993
+ draw,
994
+ ptsize,
995
+ (x, y),
996
+ outline=outline,
997
+ width=width,
998
+ outline_opacity=outline_opacity,
999
+ )
1000
+ elif symbol:
1001
+ raise ValueError("Unsupported symbol type: " + str(symbol))
878
1002
 
879
- def _get_lon_lat_bounding_box(area_extent, x_size, y_size, prj):
880
- """Get extreme lon and lat values
881
- """
882
1003
 
883
- x_ll, y_ll, x_ur, y_ur = area_extent
884
- x_range = np.linspace(x_ll, x_ur, num=x_size)
885
- y_range = np.linspace(y_ll, y_ur, num=y_size)
886
-
887
- if prj.is_latlong():
888
- lons_s1, lats_s1 = x_ll * np.ones(y_range.size), y_range
889
- lons_s2, lats_s2 = x_range, y_ur * np.ones(x_range.size)
890
- lons_s3, lats_s3 = x_ur * np.ones(y_range.size), y_range
891
- lons_s4, lats_s4 = x_range, y_ll * np.ones(x_range.size)
892
- else:
893
- lons_s1, lats_s1 = prj(np.ones(y_range.size) * x_ll, y_range,
894
- inverse=True)
895
- lons_s2, lats_s2 = prj(x_range, np.ones(x_range.size) * y_ur,
896
- inverse=True)
897
- lons_s3, lats_s3 = prj(np.ones(y_range.size) * x_ur, y_range,
898
- inverse=True)
899
- lons_s4, lats_s4 = prj(x_range, np.ones(x_range.size) * y_ll,
900
- inverse=True)
901
-
902
- angle_sum = 0
903
- prev = None
904
- for lon in np.concatenate((lons_s1, lons_s2,
905
- lons_s3[::-1], lons_s4[::-1])):
906
- if prev is not None:
907
- delta = lon - prev
908
- if abs(delta) > 180:
909
- delta = (abs(delta) - 360) * np.sign(delta)
910
- angle_sum += delta
911
- prev = lon
1004
+ def _get_lon_lat_bounding_box(area_extent, x_size, y_size, prj):
1005
+ """Get extreme lon and lat values."""
1006
+ bbox_lons, bbox_lats = _get_bounding_box_lonlat_sides(area_extent, x_size, y_size, prj)
1007
+ lons_s1, lons_s2, lons_s3, lons_s4 = bbox_lons
1008
+ lats_s1, lats_s2, lats_s3, lats_s4 = bbox_lats
1009
+ angle_sum = _get_angle_sum(lons_s1, lons_s2, lons_s3, lons_s4)
912
1010
 
913
1011
  if round(angle_sum) == -360:
914
1012
  # Covers NP
915
- lat_min = min(lats_s1.min(), lats_s2.min(),
916
- lats_s3.min(), lats_s4.min())
1013
+ lat_min = min(lats_s1.min(), lats_s2.min(), lats_s3.min(), lats_s4.min())
917
1014
  lat_max = 90
918
1015
  lon_min = -180
919
1016
  lon_max = 180
920
1017
  elif round(angle_sum) == 360:
921
1018
  # Covers SP
922
1019
  lat_min = -90
923
- lat_max = max(lats_s1.max(), lats_s2.max(),
924
- lats_s3.max(), lats_s4.max())
1020
+ lat_max = max(lats_s1.max(), lats_s2.max(), lats_s3.max(), lats_s4.max())
925
1021
  lon_min = -180
926
1022
  lon_max = 180
927
1023
  elif round(angle_sum) == 0:
928
1024
  # Covers no poles
929
- if np.sign(lons_s1[0]) * np.sign(lons_s1[-1]) == -1:
1025
+ if np.sign(lons_s1[0]) * np.sign(lons_s1[-1]) == -1 and lons_s1.min() * lons_s1.max() < -25000:
930
1026
  # End points of left side on different side of dateline
931
1027
  lon_min = lons_s1[lons_s1 > 0].min()
932
1028
  else:
933
1029
  lon_min = lons_s1.min()
934
1030
 
935
- if np.sign(lons_s3[0]) * np.sign(lons_s3[-1]) == -1:
1031
+ if np.sign(lons_s3[0]) * np.sign(lons_s3[-1]) == -1 and lons_s3.min() * lons_s3.max() < -25000:
936
1032
  # End points of right side on different side of dateline
937
1033
  lon_max = lons_s3[lons_s3 < 0].max()
938
1034
  else:
@@ -947,6 +1043,7 @@ def _get_lon_lat_bounding_box(area_extent, x_size, y_size, prj):
947
1043
  lon_min = -180
948
1044
  lon_max = 180
949
1045
 
1046
+ # Catch inf/1e30 or other invalid values
950
1047
  if not (-180 <= lon_min <= 180):
951
1048
  lon_min = -180
952
1049
  if not (-180 <= lon_max <= 180):
@@ -959,37 +1056,56 @@ def _get_lon_lat_bounding_box(area_extent, x_size, y_size, prj):
959
1056
  return lon_min, lon_max, lat_min, lat_max
960
1057
 
961
1058
 
962
- def _get_pixel_index(shape, area_extent, x_size, y_size, prj,
963
- x_offset=0, y_offset=0):
964
- """Map coordinates of shape to image coordinates
965
- """
1059
+ def _get_bounding_box_lonlat_sides(area_extent, x_size, y_size, prj):
1060
+ x_ll, y_ll, x_ur, y_ur = area_extent
1061
+ x_range = np.linspace(x_ll, x_ur, num=x_size)
1062
+ y_range = np.linspace(y_ll, y_ur, num=y_size)
966
1063
 
1064
+ lons_s1, lats_s1 = prj(np.ones(y_range.size) * x_ll, y_range, inverse=True)
1065
+ lons_s2, lats_s2 = prj(x_range, np.ones(x_range.size) * y_ur, inverse=True)
1066
+ lons_s3, lats_s3 = prj(np.ones(y_range.size) * x_ur, y_range, inverse=True)
1067
+ lons_s4, lats_s4 = prj(x_range, np.ones(x_range.size) * y_ll, inverse=True)
1068
+ return (lons_s1, lons_s2, lons_s3, lons_s4), (lats_s1, lats_s2, lats_s3, lats_s4)
1069
+
1070
+
1071
+ def _get_angle_sum(lons_s1, lons_s2, lons_s3, lons_s4):
1072
+ angle_sum = 0
1073
+ prev = None
1074
+ for lon in np.concatenate((lons_s1, lons_s2, lons_s3[::-1], lons_s4[::-1])):
1075
+ if not np.isfinite(lon):
1076
+ continue
1077
+ if prev is not None:
1078
+ delta = lon - prev
1079
+ if abs(delta) > 180:
1080
+ delta = (abs(delta) - 360) * np.sign(delta)
1081
+ angle_sum += delta
1082
+ prev = lon
1083
+ return angle_sum
1084
+
1085
+
1086
+ def _get_pixel_index(shape, area_extent, x_size, y_size, prj, x_offset=0, y_offset=0):
1087
+ """Map coordinates of shape to image coordinates."""
967
1088
  # Get shape data as array and reproject
968
- shape_data = np.array(shape.points)
1089
+ shape_data = np.array(shape.points if hasattr(shape, "points") else shape)
969
1090
  lons = shape_data[:, 0]
970
1091
  lats = shape_data[:, 1]
971
-
972
- if prj.is_latlong():
973
- x_ll, y_ll = prj(area_extent[0], area_extent[1])
974
- x_ur, y_ur = prj(area_extent[2], area_extent[3])
975
- else:
976
- x_ll, y_ll, x_ur, y_ur = area_extent
1092
+ x_ll, y_ll, x_ur, y_ur = area_extent
977
1093
 
978
1094
  x, y = prj(lons, lats)
979
1095
 
980
1096
  # Handle out of bounds
981
1097
  i = 0
982
1098
  segments = []
983
- if 1e30 in x or 1e30 in y:
1099
+ if (x >= 1e30).any() or (y >= 1e30).any():
984
1100
  # Split polygon in line segments within projection
985
1101
  is_reduced = True
986
- if x[0] == 1e30 or y[0] == 1e30:
1102
+ if x[0] >= 1e30 or y[0] >= 1e30:
987
1103
  in_segment = False
988
1104
  else:
989
1105
  in_segment = True
990
1106
 
991
1107
  for j in range(x.size):
992
- if (x[j] == 1e30 or y[j] == 1e30):
1108
+ if x[j] >= 1e30 or y[j] >= 1e30:
993
1109
  if in_segment:
994
1110
  segments.append((x[i:j], y[i:j]))
995
1111
  in_segment = False
@@ -1016,3 +1132,677 @@ def _get_pixel_index(shape, area_extent, x_size, y_size, prj,
1016
1132
  index_arrays.append(index_array)
1017
1133
 
1018
1134
  return index_arrays, is_reduced
1135
+
1136
+
1137
+ def iter_cities_names_lon_lat(
1138
+ cities_filename: str | Path, cities_list: list[str]
1139
+ ) -> Generator[tuple[str, float, float], None, None]:
1140
+ """Iterate over citiesN.txt files from GeoNames.org."""
1141
+ with open(cities_filename, mode="r", encoding="utf-8") as cities_file:
1142
+ for city_row in cities_file:
1143
+ city_info = city_row.split("\t")
1144
+ if not city_info or not (city_info[1] in cities_list or city_info[2] in cities_list):
1145
+ continue
1146
+ city_name, lon, lat = city_info[1], float(city_info[5]), float(city_info[4])
1147
+ yield city_name, lon, lat
1148
+
1149
+
1150
+ class _OverlaysFromDict:
1151
+ """Helper for drawing overlays from a dictionary of parameters."""
1152
+
1153
+ def __init__(self, contour_writer, overlays, area_def, cache_epoch, background):
1154
+ self._cw = contour_writer
1155
+ self._overlays = overlays
1156
+ self._is_cached = False
1157
+ self._cache_filename = None
1158
+ self._background = background
1159
+ self._area_def = area_def
1160
+
1161
+ foreground = None
1162
+ if "cache" in overlays:
1163
+ cache_filename, foreground = self._get_cached_filename_and_foreground(cache_epoch)
1164
+ self._cache_filename = cache_filename
1165
+ self._is_cached = foreground is not None
1166
+
1167
+ if foreground is None:
1168
+ if self._cache_filename is None and self._background is not None:
1169
+ foreground = self._background
1170
+ else:
1171
+ x_size = area_def.width
1172
+ y_size = area_def.height
1173
+ foreground = Image.new("RGBA", (x_size, y_size), (0, 0, 0, 0))
1174
+
1175
+ self._foreground = foreground
1176
+
1177
+ def _get_cached_filename_and_foreground(self, cache_epoch):
1178
+ cache_file = self._generate_cache_filename(
1179
+ self._overlays["cache"]["file"],
1180
+ self._area_def,
1181
+ self._overlays,
1182
+ )
1183
+ regenerate = self._overlays["cache"].get("regenerate", False)
1184
+ foreground = self._apply_cached_image(cache_file, cache_epoch, self._background, regenerate=regenerate)
1185
+ return cache_file, foreground
1186
+
1187
+ @staticmethod
1188
+ def _apply_cached_image(cache_file, cache_epoch, background, regenerate=False):
1189
+ try:
1190
+ config_time = cache_epoch or 0
1191
+ cache_time = os.path.getmtime(cache_file)
1192
+ # Cache file will be used only if it's newer than config file
1193
+ if config_time is not None and config_time < cache_time and not regenerate:
1194
+ foreground = Image.open(cache_file)
1195
+ logger.info("Using image in cache %s", cache_file)
1196
+ if background is not None:
1197
+ _apply_cached_foreground_on_background(background, foreground)
1198
+ return foreground
1199
+ logger.info("Regenerating cache file.")
1200
+ except OSError:
1201
+ logger.info("No overlay image found, new overlay image will be saved in cache.")
1202
+ return None
1203
+
1204
+ def _write_and_apply_new_cached_image(self):
1205
+ try:
1206
+ self._foreground.save(self._cache_filename)
1207
+ except IOError as e:
1208
+ logger.error("Can't save cache: %s", str(e))
1209
+ if self._background is not None:
1210
+ _apply_cached_foreground_on_background(self._background, self._foreground)
1211
+
1212
+ def _generate_cache_filename(self, cache_prefix, area_def, overlays_dict):
1213
+ area_hash = hash(area_def)
1214
+ base_dir, file_prefix = os.path.split(cache_prefix)
1215
+ params_to_hash = self._prepare_hashable_dict(overlays_dict)
1216
+ param_hash = hash_dict(params_to_hash)
1217
+ return os.path.join(base_dir, f"{file_prefix}_{area_hash}_{param_hash}.png")
1218
+
1219
+ @staticmethod
1220
+ def _prepare_hashable_dict(nonhashable_dict):
1221
+ params_to_hash = {}
1222
+ # avoid wasteful deep copy by only doing two levels of copying
1223
+ for overlay_name, overlay_dict in nonhashable_dict.items():
1224
+ if overlay_name == "cache":
1225
+ continue
1226
+ params_to_hash[overlay_name] = overlay_dict.copy()
1227
+ # font objects are not hashable
1228
+ for font_cat in ("cities", "points", "grid"):
1229
+ if font_cat in params_to_hash:
1230
+ params_to_hash[font_cat].pop("font", None)
1231
+ return params_to_hash
1232
+
1233
+ def apply_overlays(self):
1234
+ if self._is_cached:
1235
+ return self._foreground
1236
+
1237
+ overlays = self._overlays
1238
+ self._add_coasts_rivers_borders_from_dict(overlays)
1239
+ if "shapefiles" in overlays:
1240
+ self._add_shapefiles_from_dict(overlays["shapefiles"])
1241
+ if "grid" in overlays:
1242
+ self._add_grid_from_dict(overlays["grid"])
1243
+ if "cities" in overlays:
1244
+ self._add_cities_from_dict(overlays["cities"])
1245
+ for param_key in ["points", "text"]:
1246
+ if param_key not in overlays:
1247
+ continue
1248
+ self._add_points_from_dict(overlays[param_key])
1249
+
1250
+ if self._cache_filename is not None:
1251
+ self._write_and_apply_new_cached_image()
1252
+ return self._foreground
1253
+
1254
+ def _add_coasts_rivers_borders_from_dict(self, overlays):
1255
+ default_resolution = get_resolution_from_area(self._area_def)
1256
+ DEFAULT = {
1257
+ "level": 1,
1258
+ "outline": "white",
1259
+ "width": 1,
1260
+ "fill": None,
1261
+ "fill_opacity": 255,
1262
+ "outline_opacity": 255,
1263
+ "x_offset": 0,
1264
+ "y_offset": 0,
1265
+ "resolution": default_resolution,
1266
+ }
1267
+
1268
+ for section, fun in zip(
1269
+ ["coasts", "rivers", "borders"],
1270
+ [self._cw.add_coastlines, self._cw.add_rivers, self._cw.add_borders],
1271
+ ):
1272
+ if section not in overlays:
1273
+ continue
1274
+ params = DEFAULT.copy()
1275
+ params.update(overlays[section])
1276
+
1277
+ if section != "coasts":
1278
+ params.pop("fill_opacity", None)
1279
+ params.pop("fill", None)
1280
+
1281
+ if not self._cw.is_agg:
1282
+ for key in ["width", "outline_opacity", "fill_opacity"]:
1283
+ params.pop(key, None)
1284
+
1285
+ fun(self._foreground, self._area_def, **params)
1286
+ logger.info("%s added", section.capitalize())
1287
+
1288
+ def _add_shapefiles_from_dict(self, shapefiles):
1289
+ # Backward compatibility and config.ini
1290
+ if isinstance(shapefiles, dict):
1291
+ shapefiles = [shapefiles]
1292
+
1293
+ DEFAULT_FILENAME = None
1294
+ DEFAULT_OUTLINE = "white"
1295
+ DEFAULT_FILL = None
1296
+ for params in shapefiles:
1297
+ params = params.copy() # don't modify the user's dictionary
1298
+ params.setdefault("filename", DEFAULT_FILENAME)
1299
+ params.setdefault("outline", DEFAULT_OUTLINE)
1300
+ params.setdefault("fill", DEFAULT_FILL)
1301
+ if not self._cw.is_agg:
1302
+ for key in ["width", "outline_opacity", "fill_opacity"]:
1303
+ params.pop(key, None)
1304
+ self._cw.add_shapefile_shapes(
1305
+ self._foreground,
1306
+ self._area_def,
1307
+ feature_type=None,
1308
+ x_offset=0,
1309
+ y_offset=0,
1310
+ **params,
1311
+ )
1312
+
1313
+ def _add_grid_from_dict(self, grid_dict):
1314
+ if "major_lonlat" in grid_dict or "minor_lonlat" in grid_dict:
1315
+ Dlonlat = grid_dict.get("major_lonlat", (10.0, 10.0))
1316
+ dlonlat = grid_dict.get("minor_lonlat", (2.0, 2.0))
1317
+ else:
1318
+ Dlonlat = (
1319
+ grid_dict.get("lon_major", 10.0),
1320
+ grid_dict.get("lat_major", 10.0),
1321
+ )
1322
+ dlonlat = (
1323
+ grid_dict.get("lon_minor", 2.0),
1324
+ grid_dict.get("lat_minor", 2.0),
1325
+ )
1326
+ outline = grid_dict.get("outline", "white")
1327
+ write_text = grid_dict.get("write_text", True)
1328
+ if isinstance(write_text, str):
1329
+ write_text = write_text.lower() in ["true", "yes", "1", "on"]
1330
+ font = grid_dict.get("font", None)
1331
+ font_size = int(grid_dict.get("font_size", 10))
1332
+ fill = grid_dict.get("fill", outline)
1333
+ fill_opacity = grid_dict.get("fill_opacity", 255)
1334
+ if isinstance(font, str):
1335
+ if self._cw.is_agg:
1336
+ from aggdraw import Font
1337
+
1338
+ font = Font(fill, font, opacity=fill_opacity, size=font_size)
1339
+ else:
1340
+ from PIL.ImageFont import truetype
1341
+
1342
+ font = truetype(font, font_size)
1343
+ minor_outline = grid_dict.get("minor_outline", "white")
1344
+ minor_is_tick = grid_dict.get("minor_is_tick", True)
1345
+ if isinstance(minor_is_tick, str):
1346
+ minor_is_tick = minor_is_tick.lower() in ["true", "yes", "1"]
1347
+ lon_placement = grid_dict.get("lon_placement", "tb")
1348
+ lat_placement = grid_dict.get("lat_placement", "lr")
1349
+
1350
+ grid_kwargs = {}
1351
+ if self._cw.is_agg:
1352
+ width = float(grid_dict.get("width", 1.0))
1353
+ minor_width = float(grid_dict.get("minor_width", 0.5))
1354
+ outline_opacity = grid_dict.get("outline_opacity", 255)
1355
+ minor_outline_opacity = grid_dict.get("minor_outline_opacity", 255)
1356
+ grid_kwargs["width"] = width
1357
+ grid_kwargs["minor_width"] = minor_width
1358
+ grid_kwargs["outline_opacity"] = outline_opacity
1359
+ grid_kwargs["minor_outline_opacity"] = minor_outline_opacity
1360
+
1361
+ self._cw.add_grid(
1362
+ self._foreground,
1363
+ self._area_def,
1364
+ Dlonlat,
1365
+ dlonlat,
1366
+ font=font,
1367
+ write_text=write_text,
1368
+ fill=fill,
1369
+ outline=outline,
1370
+ minor_outline=minor_outline,
1371
+ minor_is_tick=minor_is_tick,
1372
+ lon_placement=lon_placement,
1373
+ lat_placement=lat_placement,
1374
+ **grid_kwargs,
1375
+ )
1376
+
1377
+ def _add_cities_from_dict(self, cities_dict):
1378
+ # Backward compatibility and config.ini
1379
+ if isinstance(cities_dict, dict):
1380
+ cities_dict = [cities_dict]
1381
+
1382
+ DEFAULT_FONTSIZE = 12
1383
+ DEFAULT_SYMBOL = "circle"
1384
+ DEFAULT_PTSIZE = 6
1385
+ DEFAULT_OUTLINE = "black"
1386
+ DEFAULT_FILL = "white"
1387
+
1388
+ for params in cities_dict:
1389
+ params = params.copy()
1390
+ cities_list = params.pop("cities_list")
1391
+ font_file = params.pop("font")
1392
+ font_size = int(params.pop("font_size", DEFAULT_FONTSIZE))
1393
+ symbol = params.pop("symbol", DEFAULT_SYMBOL)
1394
+ ptsize = int(params.pop("ptsize", DEFAULT_PTSIZE))
1395
+ outline = params.pop("outline", DEFAULT_OUTLINE)
1396
+ fill = params.pop("fill", DEFAULT_FILL)
1397
+
1398
+ self._cw.add_cities(
1399
+ self._foreground,
1400
+ self._area_def,
1401
+ cities_list,
1402
+ font_file,
1403
+ font_size,
1404
+ symbol,
1405
+ ptsize,
1406
+ outline,
1407
+ fill,
1408
+ **params,
1409
+ )
1410
+
1411
+ def _add_points_from_dict(self, points_dict):
1412
+ DEFAULT_FONTSIZE = 12
1413
+ DEFAULT_SYMBOL = "circle"
1414
+ DEFAULT_PTSIZE = 6
1415
+ DEFAULT_OUTLINE = "black"
1416
+ DEFAULT_FILL = "white"
1417
+
1418
+ params = points_dict.copy()
1419
+ points_list = list(params.pop("points_list"))
1420
+ font_file = params.pop("font")
1421
+ font_size = int(params.pop("font_size", DEFAULT_FONTSIZE))
1422
+ symbol = params.pop("symbol", DEFAULT_SYMBOL)
1423
+ ptsize = int(params.pop("ptsize", DEFAULT_PTSIZE))
1424
+ outline = params.pop("outline", DEFAULT_OUTLINE)
1425
+ fill = params.pop("fill", DEFAULT_FILL)
1426
+
1427
+ self._cw.add_points(
1428
+ self._foreground,
1429
+ self._area_def,
1430
+ points_list,
1431
+ font_file,
1432
+ font_size,
1433
+ symbol,
1434
+ ptsize,
1435
+ outline,
1436
+ fill,
1437
+ **params,
1438
+ )
1439
+
1440
+
1441
+ def _apply_cached_foreground_on_background(background, foreground):
1442
+ premult_foreground = foreground.convert("RGBa")
1443
+ if background.mode == "RGBA":
1444
+ # Cached foreground and background are both RGBA, no extra conversions needed
1445
+ background.paste(premult_foreground, mask=premult_foreground)
1446
+ return
1447
+ background_rgba = background.convert("RGBA")
1448
+ background_rgba.paste(premult_foreground, mask=premult_foreground)
1449
+ # overwrite background image in place
1450
+ background.paste(background_rgba)
1451
+
1452
+
1453
+ class _GridDrawer:
1454
+ """Helper for drawing graticule/grid lines."""
1455
+
1456
+ def __init__(
1457
+ self,
1458
+ contour_writer,
1459
+ draw,
1460
+ area_def,
1461
+ Dlon,
1462
+ Dlat,
1463
+ dlon,
1464
+ dlat,
1465
+ font,
1466
+ write_text,
1467
+ kwargs,
1468
+ ):
1469
+ self._cw = contour_writer
1470
+ self._draw = draw
1471
+
1472
+ try:
1473
+ proj_def = area_def.crs if hasattr(area_def, "crs") else area_def.proj_dict
1474
+ area_extent = area_def.area_extent
1475
+ except AttributeError:
1476
+ proj_def = area_def[0]
1477
+ area_extent = area_def[1]
1478
+ self._proj_def = proj_def
1479
+ self._area_extent = area_extent
1480
+ self._prj = Proj(proj_def)
1481
+
1482
+ # use kwargs for major lines ... but reform for minor lines:
1483
+ minor_line_kwargs = kwargs.copy()
1484
+ minor_line_kwargs["outline"] = kwargs["minor_outline"]
1485
+ if self._cw.is_agg:
1486
+ minor_line_kwargs["outline_opacity"] = kwargs["minor_outline_opacity"]
1487
+ minor_line_kwargs["width"] = kwargs["minor_width"]
1488
+
1489
+ # PIL ImageDraw objects versus aggdraw Draw objects
1490
+ self._x_size = draw.im.size[0] if hasattr(draw, "im") else draw.size[0]
1491
+ self._y_size = draw.im.size[1] if hasattr(draw, "im") else draw.size[1]
1492
+
1493
+ # Calculate min and max lons and lats of interest
1494
+ lon_min, lon_max, lat_min, lat_max = _get_lon_lat_bounding_box(
1495
+ area_extent, self._x_size, self._y_size, self._prj
1496
+ )
1497
+
1498
+ # Handle dateline crossing
1499
+ if lon_max < lon_min:
1500
+ lon_max = 360 + lon_max
1501
+
1502
+ # Draw lonlat grid lines ...
1503
+ # create adjustment of line lengths to avoid cluttered pole lines
1504
+ if lat_max == 90.0:
1505
+ shorten_max_lat = Dlat
1506
+ else:
1507
+ shorten_max_lat = 0.0
1508
+
1509
+ if lat_min == -90.0:
1510
+ increase_min_lat = Dlat
1511
+ else:
1512
+ increase_min_lat = 0.0
1513
+
1514
+ # major lon lines
1515
+ round_lon_min = lon_min - (lon_min % Dlon)
1516
+ maj_lons = np.arange(round_lon_min, lon_max, Dlon)
1517
+ maj_lons[maj_lons > 180] = maj_lons[maj_lons > 180] - 360
1518
+
1519
+ # minor lon lines (ticks)
1520
+ min_lons = np.arange(round_lon_min, lon_max, dlon)
1521
+ min_lons[min_lons > 180] = min_lons[min_lons > 180] - 360
1522
+
1523
+ # Get min_lons not in maj_lons
1524
+ min_lons = np.setdiff1d(min_lons, maj_lons)
1525
+
1526
+ # lats along major lon lines
1527
+ lin_lats = np.arange(
1528
+ lat_min + increase_min_lat,
1529
+ lat_max - shorten_max_lat,
1530
+ float(lat_max - lat_min) / self._y_size,
1531
+ )
1532
+ # lin_lats in rather high definition so that it can be used to
1533
+ # position text labels near edges of image...
1534
+
1535
+ # perhaps better to find the actual length of line in pixels...
1536
+ round_lat_min = lat_min - (lat_min % Dlat)
1537
+
1538
+ # major lat lines
1539
+ maj_lats = np.arange(round_lat_min + increase_min_lat, lat_max, Dlat)
1540
+
1541
+ # minor lon lines (ticks)
1542
+ min_lats = np.arange(round_lat_min + increase_min_lat, lat_max - shorten_max_lat, dlat)
1543
+
1544
+ # Get min_lats not in maj_lats
1545
+ min_lats = np.setdiff1d(min_lats, maj_lats)
1546
+
1547
+ # lons along major lat lines (extended slightly to avoid missing the end)
1548
+ lin_lons = np.linspace(lon_min, lon_max + Dlon / 5.0, max(self._x_size, self._y_size) // 5)
1549
+
1550
+ self._min_lons = min_lons
1551
+ self._min_lats = min_lats
1552
+ self._lin_lons = lin_lons
1553
+ self._lin_lats = lin_lats
1554
+ self._maj_lons = maj_lons
1555
+ self._maj_lats = maj_lats
1556
+ self._kwargs = kwargs
1557
+ self._minor_line_kwargs = minor_line_kwargs
1558
+ self._x_offset = 0
1559
+ self._y_offset = 0
1560
+ # text margins (at sides of image frame)
1561
+ self._y_text_margin = 4
1562
+ self._x_text_margin = 4
1563
+ self._write_text = write_text
1564
+ self._font = font
1565
+ self._Dlon = Dlon
1566
+ self._Dlat = Dlat
1567
+ self._dlon = dlon
1568
+ self._dlat = dlat
1569
+ self._lat_max = lat_max
1570
+ self._lat_min = lat_min
1571
+
1572
+ def draw_grid(self):
1573
+ if self._kwargs["minor_is_tick"]:
1574
+ self._draw_minor_ticks()
1575
+ else:
1576
+ self._draw_minor_lines()
1577
+ self._draw_major_lon_lines()
1578
+ self._draw_major_lat_lines()
1579
+ self._draw_pole_crosses()
1580
+
1581
+ def _draw_minor_lines(self):
1582
+ minor_lat_lines = self._get_minor_lat_lines()
1583
+ minor_lon_lines = self._get_minor_lon_lines()
1584
+ self._draw_minor_grid_lines(
1585
+ minor_lat_lines + minor_lon_lines,
1586
+ self._minor_line_kwargs,
1587
+ )
1588
+
1589
+ def _get_minor_lat_lines(self):
1590
+ return [[(x, lat) for x in self._lin_lons] for lat in self._min_lats]
1591
+
1592
+ def _get_minor_lon_lines(self):
1593
+ return [[(lon, x) for x in self._lin_lats] for lon in self._min_lons]
1594
+
1595
+ def _draw_minor_ticks(self):
1596
+ # minor tick lines on major lines
1597
+ # Draw 'minor' tick lines dlat separation along the lon
1598
+ minor_tick_lon_lines = self._get_minor_lon_tick_lines()
1599
+ # Draw 'minor' tick dlon separation along the lat
1600
+ minor_tick_lat_lines = self._get_minor_lat_tick_lines()
1601
+ self._draw_minor_grid_lines(
1602
+ minor_tick_lon_lines + minor_tick_lat_lines,
1603
+ self._minor_line_kwargs,
1604
+ )
1605
+
1606
+ def _get_minor_lon_tick_lines(self):
1607
+ minor_tick_lon_lines = []
1608
+ for lon in self._maj_lons:
1609
+ tick_lons = np.linspace(lon - self._Dlon / 20.0, lon + self._Dlon / 20.0, 5)
1610
+ minor_tick_lines = [[(x, lat) for x in tick_lons] for lat in self._min_lats]
1611
+ minor_tick_lon_lines.extend(minor_tick_lines)
1612
+ return minor_tick_lon_lines
1613
+
1614
+ def _get_minor_lat_tick_lines(self):
1615
+ minor_tick_lat_lines = []
1616
+ for lat in self._maj_lats:
1617
+ tick_lats = np.linspace(lat - self._Dlat / 20.0, lat + self._Dlat / 20.0, 5)
1618
+ minor_tick_lines = [[(lon, x) for x in tick_lats] for lon in self._min_lons]
1619
+ minor_tick_lat_lines.extend(minor_tick_lines)
1620
+ return minor_tick_lat_lines
1621
+
1622
+ def _draw_minor_grid_lines(
1623
+ self,
1624
+ minor_lines,
1625
+ kwargs,
1626
+ ):
1627
+ index_arrays = self._grid_line_index_array_generator(minor_lines)
1628
+ for index_array in index_arrays:
1629
+ self._cw._draw_line(self._draw, index_array.flatten().tolist(), **kwargs)
1630
+
1631
+ def _draw_major_lon_lines(self):
1632
+ def _lon_label_from_line_lonlats(lonlats):
1633
+ return self._grid_lon_label(lonlats[0][0])
1634
+
1635
+ major_lon_lines = [[(lon, x) for x in self._lin_lats] for lon in self._maj_lons]
1636
+ label_placement = self._kwargs["lon_placement"].lower()
1637
+ self._draw_major_lines(
1638
+ major_lon_lines,
1639
+ _lon_label_from_line_lonlats,
1640
+ label_placement,
1641
+ )
1642
+
1643
+ def _draw_major_lat_lines(self):
1644
+ def _lat_label_from_line_lonlats(lonlats):
1645
+ return self._grid_lat_label(lonlats[0][1])
1646
+
1647
+ major_lat_lines = [[(x, lat) for x in self._lin_lons] for lat in self._maj_lats]
1648
+ label_placement = self._kwargs["lat_placement"].lower()
1649
+ self._draw_major_lines(
1650
+ major_lat_lines,
1651
+ _lat_label_from_line_lonlats,
1652
+ label_placement,
1653
+ )
1654
+
1655
+ def _draw_major_lines(self, major_lines_lonlats, label_gen_func, label_placement_definition):
1656
+ for lonlats in major_lines_lonlats:
1657
+ index_arrays = self._grid_line_index_array_generator(
1658
+ [lonlats],
1659
+ )
1660
+ index_array = None
1661
+ for index_array in index_arrays:
1662
+ self._cw._draw_line(self._draw, index_array.flatten().tolist(), **self._kwargs)
1663
+
1664
+ # add lon text markings at each end of longitude line
1665
+ if self._write_text and index_array is not None:
1666
+ txt = label_gen_func(lonlats)
1667
+ xys = _find_line_intercepts(
1668
+ index_array,
1669
+ (self._x_size, self._y_size),
1670
+ (self._x_text_margin, self._y_text_margin),
1671
+ )
1672
+
1673
+ self._draw_grid_labels(
1674
+ self._draw,
1675
+ xys,
1676
+ label_placement_definition,
1677
+ txt,
1678
+ self._font,
1679
+ **self._kwargs,
1680
+ )
1681
+
1682
+ def _grid_line_index_array_generator(
1683
+ self,
1684
+ grid_lines,
1685
+ ):
1686
+ for grid_line_lonlats in grid_lines:
1687
+ index_arrays, is_reduced = _get_pixel_index(
1688
+ grid_line_lonlats,
1689
+ self._area_extent,
1690
+ self._x_size,
1691
+ self._y_size,
1692
+ self._prj,
1693
+ x_offset=self._x_offset,
1694
+ y_offset=self._y_offset,
1695
+ )
1696
+ # Skip empty datasets
1697
+ if not index_arrays:
1698
+ continue
1699
+ yield from index_arrays
1700
+
1701
+ def _grid_lon_label(self, lon):
1702
+ # FIXME: Use f-strings or just pass the direction
1703
+ if lon > 0.0:
1704
+ txt = "%.2dE" % (lon)
1705
+ else:
1706
+ txt = "%.2dW" % (-lon)
1707
+ return txt
1708
+
1709
+ def _grid_lat_label(self, lat):
1710
+ if lat >= 0.0:
1711
+ txt = "%.2dN" % (lat)
1712
+ else:
1713
+ txt = "%.2dS" % (-lat)
1714
+ return txt
1715
+
1716
+ def _draw_grid_labels(self, draw, xys, placement_def, txt, font, **kwargs):
1717
+ """Draw text with default PIL module."""
1718
+ if font is None:
1719
+ # NOTE: Default font does not use font size in PIL writer
1720
+ font = self._cw._get_font(kwargs.get("fill", "black"), font, 12)
1721
+ for xy in xys:
1722
+ # note xy[0] is xy coordinate pair,
1723
+ # xy[1] is required alignment e.g. 'tl','lr','lc','cc'...
1724
+ ax, ay = xy[1].lower()
1725
+ if ax in placement_def or ay in placement_def:
1726
+ self._cw._draw_text(draw, xy[0], txt, font, align=xy[1], **kwargs)
1727
+
1728
+ def _draw_pole_crosses(self):
1729
+ cross_lats_interval = float(self._lat_max - self._lat_min) / self._y_size
1730
+ if self._lat_max == 90.0:
1731
+ crosslats = np.arange(
1732
+ 90.0 - self._Dlat / 2.0,
1733
+ 90.0,
1734
+ cross_lats_interval,
1735
+ )
1736
+ self._add_pole_crosslats(crosslats)
1737
+
1738
+ if self._lat_min == -90.0:
1739
+ crosslats = np.arange(-90.0, -90.0 + self._Dlat / 2.0, cross_lats_interval)
1740
+ self._add_pole_crosslats(crosslats)
1741
+
1742
+ def _add_pole_crosslats(self, crosslats):
1743
+ cross_lines = [[(lon, x) for x in crosslats] for lon in (0.0, 90.0, 180.0, -90.0)]
1744
+ self._draw_minor_grid_lines(
1745
+ cross_lines,
1746
+ self._kwargs,
1747
+ )
1748
+
1749
+
1750
+ def _find_line_intercepts(xys, size, margins):
1751
+ """Find intercepts of poly-line xys with image boundaries offset by margins.
1752
+
1753
+ Returns an array of coordinates.
1754
+
1755
+ """
1756
+ x_size, y_size = size
1757
+
1758
+ def is_in_box(x_y, extents):
1759
+ x, y = x_y
1760
+ xmin, xmax, ymin, ymax = extents
1761
+ return xmin < x < xmax and ymin < y < ymax
1762
+
1763
+ def crossing(x1, x2, lim):
1764
+ return (x1 < lim) != (x2 < lim)
1765
+
1766
+ # set box limits
1767
+ xlim1 = margins[0]
1768
+ ylim1 = margins[1]
1769
+ xlim2 = x_size - margins[0]
1770
+ ylim2 = y_size - margins[1]
1771
+
1772
+ # only consider crossing within a box a little bigger than grid boundary
1773
+ search_box = (-10, x_size + 10, -10, y_size + 10)
1774
+
1775
+ # loop through line steps and detect crossings
1776
+ intercepts = []
1777
+ align_left = "LC"
1778
+ align_right = "RC"
1779
+ align_top = "CT"
1780
+ align_bottom = "CB"
1781
+ prev_xy = xys[0]
1782
+ for xy in xys[1:]:
1783
+ if not is_in_box(xy, search_box):
1784
+ prev_xy = xy
1785
+ continue
1786
+ # crossing LHS
1787
+ if crossing(prev_xy[0], xy[0], xlim1):
1788
+ x = xlim1
1789
+ y = xy[1]
1790
+ intercepts.append(((x, y), align_left))
1791
+ # crossing RHS
1792
+ elif crossing(prev_xy[0], xy[0], xlim2):
1793
+ x = xlim2
1794
+ y = xy[1]
1795
+ intercepts.append(((x, y), align_right))
1796
+ # crossing Top
1797
+ elif crossing(prev_xy[1], xy[1], ylim1):
1798
+ x = xy[0]
1799
+ y = ylim1
1800
+ intercepts.append(((x, y), align_top))
1801
+ # crossing Bottom
1802
+ elif crossing(prev_xy[1], xy[1], ylim2):
1803
+ x = xy[0] # - txt_width/2
1804
+ y = ylim2 # - txt_height
1805
+ intercepts.append(((x, y), align_bottom))
1806
+ prev_xy = xy
1807
+
1808
+ return intercepts