add_trees.py 7.93 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
"""
Script to automatically add potential trees in a given region, along roads and paths.

Road information is downloaded from OpenStreetMap.

Trees are exported in a CSV table, a PNG diagram and an HTML interactive map.
"""
import pickle
from pathlib import Path
from collections import namedtuple

import folium
import matplotlib.pyplot as plt
import matplotlib.ticker as plticker
import numpy as np
import overpy
from pyproj import Transformer
from shapely import LineString, geometry, wkt
from shapely.ops import transform

21
from tree import Forest
Eric Duminil's avatar
Eric Duminil committed
22
from import_existing_trees import get_existing_forest
23
24
25
26
27
28
29
30
31

# TODO: Use Args
# TODO: Document
# TODO: Write issue
# TODO: Write tests?
# TODO: Export shapefile?

# From RegionChooser, or https://transfer.hft-stuttgart.de/gitlab/circulargreensimcity/circulargreensimcity/-/wikis/Fallstudien/Gromb%C3%BChl
WKT = "POLYGON((9.947021 49.803063, 9.947011 49.800917, 9.955025 49.800810, 9.955110 49.803019, 9.947021 49.803063))"
32
# Replace with None if no existing tree should be imported
33
EXISTING_TREES = 'existing_trees/Trees_ideal_2_20240227.shp'
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# Fellbach
# WKT = "POLYGON((9.271353 48.811327, 9.271911 48.809010, 9.272147 48.807187, 9.275838 48.807173, 9.275602 48.806749, 9.276138 48.806325, 9.277683 48.806424, 9.277319 48.812514, 9.275581 48.811991, 9.271353 48.811327))"
EPSG_ID = 25832
# Trees will be planted every TREE_DISTANCE along roads:
TREE_DISTANCE = 10  # [m]
# Unless there's already another tree closer than MIN_DISTANCE away:
MIN_DISTANCE = TREE_DISTANCE * 0.5  # [m]
# For display purposes only:
GRID = 100  # [m]

IGNORE_ROADS = set(['primary', 'unclassified', 'secondary',
                    'secondary_link', 'trunk', 'trunk_link', 'primary_link'])


SCRIPT_DIR = Path(__file__).resolve().parent


def load_region(wkt_polygon):
    region = wkt.loads(wkt_polygon)
    bounds = namedtuple("Bounds", "W S E N")(*region.bounds)
    return region, bounds


def get_basename(bounds):
    return f'{bounds.S}__{bounds.N}__{bounds.W}__{bounds.E}_{TREE_DISTANCE}m'.replace('.', '_')


def get_osm_roads(bounds):
    cache_dir = SCRIPT_DIR / 'cache'
    cache_dir.mkdir(exist_ok=True)

    cache_file = (cache_dir / get_basename(bounds)).with_suffix('.pickle')

    if cache_file.exists():
        print("Cache has been found. Parsing...")
        with open(cache_file, 'rb') as cache:
            ways = pickle.load(cache)
    else:
        print("Downloading data...")
        # TODO: Could add trees from OSM or Bäumekataster too.
        api = overpy.Overpass()
        result = api.query(f"""
        way({bounds.S},{bounds.W},{bounds.N},{bounds.E}) ["highway"];
        (._;>;);
        out body;
        """)
        ways = result.ways
        print("Caching data...")
        with open(cache_file, 'wb') as cache:
            pickle.dump(ways, cache)
    return ways


def set_plot(bounds, to_local_coordinates):
    x_min, y_min = to_local_coordinates.transform(bounds.W, bounds.S)
    x_max, y_max = to_local_coordinates.transform(bounds.E, bounds.N)
    ax = plt.axes()
    ax.set_xlim(x_min, x_max)
    ax.set_ylim(y_min, y_max)
    ax.set_aspect(1)

    x_grid = plticker.MultipleLocator(base=GRID)
    y_grid = plticker.MultipleLocator(base=GRID)
    ax.xaxis.set_major_locator(x_grid)
    ax.yaxis.set_major_locator(y_grid)
    return ax


Eric Duminil's avatar
Eric Duminil committed
102
def place_trees(forest, ways, region, to_local, tree_distance, min_distance_2) -> Forest:
103
104
105
106
107
108
109
110
111
112
    local_region = transform(to_local.transform, region)

    for way in ways:
        width = float(way.tags.get("width", 0))
        highway = way.tags.get("highway")
        if highway in IGNORE_ROADS:
            color = 'orange'
        else:
            color = 'gray'

Eric Duminil's avatar
Eric Duminil committed
113
        road_xy_s = [to_local.transform(node.lon, node.lat) for node in way.nodes]
114

Eric Duminil's avatar
Eric Duminil committed
115
        tree_path = LineString(road_xy_s)
116
117
118
119
120
121
122
123
124

        if width:
            road_as_polygon = tree_path.buffer(width / 2, cap_style='flat')
            tree_path = road_as_polygon.exterior
            displayed_width = width
        else:
            # NOTE: Could try to guess width depending on highway type.
            displayed_width = 1

Eric Duminil's avatar
Eric Duminil committed
125
126
        road_xs, road_ys = zip(*road_xy_s)
        plt.plot(road_xs, road_ys, linewidth=displayed_width, c=color, alpha=0.8, zorder=-1)
127
128
129
130
131
132
133
134
135
136
137

        distances = np.arange(0, tree_path.length, tree_distance)
        potential_trees = [tree_path.interpolate(
            distance) for distance in distances]
        if tree_path.boundary:
            potential_trees += [tree_path.boundary.geoms[-1]]

        for potential_tree in potential_trees:
            x = potential_tree.x
            y = potential_tree.y
            if local_region.contains(geometry.Point(x, y)):
Eric Duminil's avatar
Eric Duminil committed
138
                forest.add_tree_if_possible(min_distance_2, x, y,
Eric Duminil's avatar
Eric Duminil committed
139
                                            color='#DFFF00',
Eric Duminil's avatar
Eric Duminil committed
140
141
                                            type='Fake Tree',
                                            description='Tilia tomentosa'
Eric Duminil's avatar
Eric Duminil committed
142
                                            radius=3
Eric Duminil's avatar
Eric Duminil committed
143
                                            )
Eric Duminil's avatar
Eric Duminil committed
144

Eric Duminil's avatar
Eric Duminil committed
145
    return forest
146
147


Eric Duminil's avatar
Eric Duminil committed
148
def plot_trees(bounds, forest, tree_distance) -> None:
Eric Duminil's avatar
Eric Duminil committed
149
    print("Exporting diagram...")
Eric Duminil's avatar
Eric Duminil committed
150
151
    tree_xs, tree_ys, colors = forest.xs_ys_cs
    plt.scatter(tree_xs, tree_ys, s=2, c=colors)
152
153
154
155
156
157
158
159

    plt.grid(True)
    plt.title(f"{bounds}\nTree distance : {tree_distance} m")
    plt.gcf().set_size_inches(15, 10)
    plt.savefig(
        SCRIPT_DIR / f"{get_basename(bounds)}.png", bbox_inches='tight', dpi=300)


Eric Duminil's avatar
Eric Duminil committed
160
161
def export_map(bounds, forest, epsg_id) -> None:
    print("Exporting Map...")
162
163
164
165
    to_wgs84 = Transformer.from_crs(f"EPSG:{epsg_id}", "EPSG:4326", always_xy=True)
    interactive_map = folium.Map()
    interactive_map.fit_bounds([(bounds.S, bounds.W), (bounds.N, bounds.E)])

Eric Duminil's avatar
Eric Duminil committed
166
167
    for tree in forest:
        lon, lat = to_wgs84.transform(tree.x, tree.y)
168
169
        folium.Circle(
            location=[lat, lon],
Eric Duminil's avatar
Eric Duminil committed
170
            radius=tree.radius or 2, # [m], when defined
171
172
173
174
            color="black",
            weight=1,
            fill_opacity=0.9,
            opacity=1,
Eric Duminil's avatar
Eric Duminil committed
175
            fill_color=tree.color,
176
            fill=False,  # gets overridden by fill_color
Eric Duminil's avatar
Eric Duminil committed
177
178
            popup=repr(tree),
            tooltip=str(tree),
179
180
181
182
183
184
185
186
187
188
189
        ).add_to(interactive_map)

    folium.TileLayer(
        tiles='https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
        attr='Esri',
        name='Esri Satellite',
        overlay=False,
        control=True
    ).add_to(interactive_map)

    interactive_map.save(f"{get_basename(bounds)}_trees.html")
Eric Duminil's avatar
Eric Duminil committed
190
    print("  DONE!")
191
192


Eric Duminil's avatar
Eric Duminil committed
193
194
def export_csv(bounds, forest, wkt_polygon, tree_distance, min_distance, epsg_id) -> None:
    print("Exporting CSV...")
195
196
197
198
199
    with open(SCRIPT_DIR / f"{get_basename(bounds)}_trees.csv", "w") as csv:
        csv.write(f"# Fake trees for; {wkt_polygon}\n")
        csv.write(f"# Tree distance along roads; {tree_distance}; [m]\n")
        csv.write(f"# Minimum allowed distance between trees; {min_distance}; [m]\n")
        csv.write(f"# EPSG; {epsg_id}\n")
Eric Duminil's avatar
Eric Duminil committed
200
201
        csv.write("# X; Y; Type; Description; Radius\n")
        csv.write("# [m]; [m]; [?]; [?]; [m]\n")
Eric Duminil's avatar
Eric Duminil committed
202
        for tree in forest:
Eric Duminil's avatar
Eric Duminil committed
203
            csv.write(f"{tree.x};{tree.y};{tree.type};{tree.description};{tree.radius}\n")
204

Eric Duminil's avatar
Eric Duminil committed
205
    print("  DONE!")
206
207


Eric Duminil's avatar
Eric Duminil committed
208
def main(wkt_polygon, epsg_id, tree_distance, min_distance, import_tree_shp) -> None:
209
210
211
212
    region, bounds = load_region(wkt_polygon)
    ways = get_osm_roads(bounds)

    if import_tree_shp:
Eric Duminil's avatar
Eric Duminil committed
213
        existing_forest = get_existing_forest(import_tree_shp)
214
    else:
Eric Duminil's avatar
Eric Duminil committed
215
216
217
        existing_forest = Forest()

    print(existing_forest)
218
219
220
221

    to_local = Transformer.from_crs("EPSG:4326", f"EPSG:{epsg_id}", always_xy=True)

    set_plot(bounds, to_local)
Eric Duminil's avatar
Eric Duminil committed
222
223
    forest = place_trees(existing_forest, ways, region, to_local, tree_distance, min_distance**2)
    print(existing_forest)
224

Eric Duminil's avatar
Eric Duminil committed
225
226
227
    plot_trees(bounds, forest, tree_distance)
    export_map(bounds, forest, epsg_id)
    export_csv(bounds, forest, wkt_polygon,
228
229
230
231
232
               tree_distance, min_distance, epsg_id)


if __name__ == "__main__":
    main(WKT, EPSG_ID, TREE_DISTANCE, MIN_DISTANCE, EXISTING_TREES)