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.
- pycoast/__init__.py +29 -9
- pycoast/conftest.py +14 -0
- pycoast/cw_agg.py +762 -468
- pycoast/cw_base.py +1575 -768
- pycoast/cw_pil.py +583 -363
- pycoast/tests/__init__.py +19 -0
- pycoast/tests/brazil_shapefiles.png +0 -0
- pycoast/tests/brazil_shapefiles_agg.png +0 -0
- pycoast/tests/coasts_and_grid.ini +13 -0
- pycoast/tests/coasts_and_grid_agg.ini +17 -0
- pycoast/tests/contours_europe.png +0 -0
- pycoast/tests/contours_europe_agg.png +0 -0
- pycoast/tests/contours_europe_alpha.png +0 -0
- pycoast/tests/contours_geos.png +0 -0
- pycoast/tests/contours_geos_agg.png +0 -0
- pycoast/tests/dateline_boundary_cross.png +0 -0
- pycoast/tests/dateline_cross.png +0 -0
- pycoast/tests/eastern_shapes_agg.png +0 -0
- pycoast/tests/eastern_shapes_pil.png +0 -0
- pycoast/tests/grid_europe.png +0 -0
- pycoast/tests/grid_europe_agg.png +0 -0
- pycoast/tests/grid_europe_agg_txt.png +0 -0
- pycoast/tests/grid_from_dict_agg.png +0 -0
- pycoast/tests/grid_from_dict_pil.png +0 -0
- pycoast/tests/grid_geos.png +0 -0
- pycoast/tests/grid_geos_agg.png +0 -0
- pycoast/tests/grid_germ.png +0 -0
- pycoast/tests/grid_nh.png +0 -0
- pycoast/tests/grid_nh_agg.png +0 -0
- pycoast/tests/grid_nh_cfg_agg.png +0 -0
- pycoast/tests/lonlat_boundary_cross.png +0 -0
- pycoast/tests/nh_cities_agg.ini +26 -0
- pycoast/tests/nh_cities_agg.png +0 -0
- pycoast/tests/nh_cities_from_dict_agg.png +0 -0
- pycoast/tests/nh_cities_from_dict_pil.png +0 -0
- pycoast/tests/nh_cities_pil.ini +20 -0
- pycoast/tests/nh_cities_pil.png +0 -0
- pycoast/tests/nh_one_shapefile.ini +11 -0
- pycoast/tests/nh_points_agg.ini +24 -0
- pycoast/tests/nh_points_agg.png +0 -0
- pycoast/tests/nh_points_cfg_pil.png +0 -0
- pycoast/tests/nh_points_pil.ini +19 -0
- pycoast/tests/nh_points_pil.png +0 -0
- pycoast/tests/nh_polygons.png +0 -0
- pycoast/tests/nh_polygons_agg.png +0 -0
- pycoast/tests/no_h_scratch_agg.png +0 -0
- pycoast/tests/no_h_scratch_pil.png +0 -0
- pycoast/tests/no_v_scratch_agg.png +0 -0
- pycoast/tests/no_v_scratch_pil.png +0 -0
- pycoast/tests/one_shapefile_from_cfg_agg.png +0 -0
- pycoast/tests/one_shapefile_from_cfg_pil.png +0 -0
- pycoast/tests/test_data/DejaVuSerif.ttf +0 -0
- pycoast/tests/test_data/gshhs/CITIES/cities.txt +20 -0
- pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L1.dbf +0 -0
- pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L1.prj +1 -0
- pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L1.shp +0 -0
- pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L1.shx +0 -0
- pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L2.dbf +0 -0
- pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L2.prj +1 -0
- pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L2.shp +0 -0
- pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L2.shx +0 -0
- pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L3.dbf +0 -0
- pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L3.prj +1 -0
- pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L3.shp +0 -0
- pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L3.shx +0 -0
- pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L4.dbf +0 -0
- pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L4.prj +1 -0
- pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L4.shp +0 -0
- pycoast/tests/test_data/gshhs/GSHHS_shp/l/GSHHS_l_L4.shx +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L1.dbf +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L1.prj +1 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L1.shp +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L1.shx +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L2.dbf +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L2.prj +1 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L2.shp +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L2.shx +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L3.dbf +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L3.prj +1 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L3.shp +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_border_c_L3.shx +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L01.dbf +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L01.prj +1 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L01.shp +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L01.shx +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L02.dbf +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L02.prj +1 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L02.shp +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L02.shx +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L03.dbf +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L03.prj +1 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L03.shp +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L03.shx +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L04.dbf +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L04.prj +1 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L04.shp +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L04.shx +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L05.dbf +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L05.prj +1 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L05.shp +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L05.shx +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L06.dbf +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L06.prj +1 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L06.shp +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L06.shx +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L07.dbf +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L07.prj +1 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L07.shp +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L07.shx +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L08.dbf +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L08.prj +1 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L08.shp +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L08.shx +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L09.dbf +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L09.prj +1 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L09.shp +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L09.shx +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L1.dbf +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L1.shp +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L1.shx +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L10.dbf +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L10.prj +1 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L10.shp +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L10.shx +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L11.dbf +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L11.prj +1 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L11.shp +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L11.shx +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L2.dbf +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L2.shp +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L2.shx +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L3.dbf +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L3.shp +0 -0
- pycoast/tests/test_data/gshhs/WDBII_shp/c/WDBII_river_c_L3.shx +0 -0
- pycoast/tests/test_data/shapes/Metareas.dbf +0 -0
- pycoast/tests/test_data/shapes/Metareas.mxd +0 -0
- pycoast/tests/test_data/shapes/Metareas.prj +1 -0
- pycoast/tests/test_data/shapes/Metareas.sbn +0 -0
- pycoast/tests/test_data/shapes/Metareas.sbx +0 -0
- pycoast/tests/test_data/shapes/Metareas.shp +0 -0
- pycoast/tests/test_data/shapes/Metareas.shx +0 -0
- pycoast/tests/test_data/shapes/README +3 -0
- pycoast/tests/test_data/shapes/divisao_politica/BRASIL.dbf +0 -0
- pycoast/tests/test_data/shapes/divisao_politica/BRASIL.shp +0 -0
- pycoast/tests/test_data/shapes/divisao_politica/BRASIL.shx +0 -0
- pycoast/tests/test_data/shapes/divisao_politica/BR_Capitais.dbf +0 -0
- pycoast/tests/test_data/shapes/divisao_politica/BR_Capitais.shp +0 -0
- pycoast/tests/test_data/shapes/divisao_politica/BR_Capitais.shx +0 -0
- pycoast/tests/test_data/shapes/divisao_politica/BR_Contorno.dbf +0 -0
- pycoast/tests/test_data/shapes/divisao_politica/BR_Contorno.shp +0 -0
- pycoast/tests/test_data/shapes/divisao_politica/BR_Contorno.shx +0 -0
- pycoast/tests/test_data/shapes/divisao_politica/BR_Regioes.dbf +0 -0
- pycoast/tests/test_data/shapes/divisao_politica/BR_Regioes.shp +0 -0
- pycoast/tests/test_data/shapes/divisao_politica/BR_Regioes.shx +0 -0
- pycoast/tests/test_data/shapes/divisao_politica/divisao_politica.txt +40 -0
- pycoast/tests/test_data/shapes/divisao_politica/leia.txt +9 -0
- pycoast/tests/test_data/shapes/metarea5.gsf +0 -0
- pycoast/tests/test_data/shapes/metarea5.tbl +21 -0
- pycoast/tests/test_data/shapes/metarea5.tbl.info +25 -0
- pycoast/tests/test_data/test_config.ini +12 -0
- pycoast/tests/test_pycoast.py +1916 -419
- pycoast/tests/two_shapefiles_agg.png +0 -0
- pycoast/tests/two_shapefiles_pil.png +0 -0
- pycoast/tests/western_shapes_agg.png +0 -0
- pycoast/tests/western_shapes_pil.png +0 -0
- pycoast/version.py +19 -17
- pycoast-1.7.1.dist-info/AUTHORS.md +20 -0
- pycoast-1.7.1.dist-info/LICENSE.txt +674 -0
- pycoast-1.7.1.dist-info/METADATA +81 -0
- pycoast-1.7.1.dist-info/RECORD +172 -0
- {pycoast-1.1.0.dist-info → pycoast-1.7.1.dist-info}/WHEEL +1 -1
- pycoast-1.1.0.dist-info/DESCRIPTION.rst +0 -3
- pycoast-1.1.0.dist-info/METADATA +0 -24
- pycoast-1.1.0.dist-info/RECORD +0 -13
- pycoast-1.1.0.dist-info/metadata.json +0 -1
- {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-
|
|
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
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Generator
|
|
31
|
+
|
|
27
32
|
import numpy as np
|
|
28
|
-
|
|
29
|
-
import
|
|
30
|
-
import
|
|
33
|
+
import shapefile
|
|
34
|
+
from PIL import Image
|
|
35
|
+
from pyproj import CRS, Proj
|
|
31
36
|
|
|
32
37
|
try:
|
|
33
|
-
import
|
|
38
|
+
from pyresample import AreaDefinition
|
|
34
39
|
except ImportError:
|
|
35
|
-
|
|
40
|
+
AreaDefinition = None
|
|
36
41
|
|
|
37
42
|
logger = logging.getLogger(__name__)
|
|
38
43
|
|
|
39
44
|
|
|
40
|
-
|
|
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
|
-
|
|
46
|
-
|
|
140
|
+
db_root_path : str
|
|
141
|
+
Path to root dir of GSHHS and WDBII shapefiles
|
|
142
|
+
|
|
47
143
|
"""
|
|
48
144
|
|
|
49
|
-
_draw_module =
|
|
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(
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
"""
|
|
63
|
-
|
|
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 ==
|
|
175
|
+
if ax == "r":
|
|
67
176
|
x_pos = x_pos - txt_width
|
|
68
|
-
elif ax ==
|
|
177
|
+
elif ax == "c":
|
|
69
178
|
x_pos = x_pos - txt_width / 2
|
|
70
179
|
|
|
71
|
-
if ay ==
|
|
180
|
+
if ay == "b":
|
|
72
181
|
y_pos = y_pos - txt_height
|
|
73
|
-
elif ay ==
|
|
74
|
-
y_pos = y_pos -
|
|
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(
|
|
80
|
-
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
"""
|
|
477
|
-
|
|
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",
|
|
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
|
-
"""
|
|
488
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
598
|
-
|
|
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 =
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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(
|
|
638
|
-
% shapefilename)
|
|
446
|
+
raise ValueError("Could not find shapefile %s" % shapefilename)
|
|
639
447
|
|
|
640
|
-
|
|
641
|
-
yield shape
|
|
448
|
+
yield from shapes
|
|
642
449
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
""
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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,
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
#
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
-
|
|
862
|
-
|
|
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
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
|
963
|
-
|
|
964
|
-
|
|
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
|
|
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]
|
|
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
|
|
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
|