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