Wiener Straßenbäume¶
Wie grün sind Wiens Straßen?
Zwei Datensets:
Abgerufen 31.05.2025
Als erstes die CSVs als Geopandas-Dataframe laden und auf EPSG:31256 projizieren.
import pandas as pd
from shapely import wkt
import geopandas as gpd
import matplotlib.pyplot as plt
from shapely.geometry import box
strassenlinien = pd.read_csv("datasets/strassenlinien.csv")
baeume = pd.read_csv("datasets/baeume.csv")
strassenlinien["geometry"] = strassenlinien["SHAPE"].apply(wkt.loads)
baeume["geometry"] = baeume["SHAPE"].apply(wkt.loads)
strassenlinien = gpd.GeoDataFrame(strassenlinien, geometry="geometry", crs="EPSG:4326")
baeume = gpd.GeoDataFrame(baeume, geometry="geometry", crs="EPSG:4326")
strassenlinien = strassenlinien.to_crs(epsg=31256)
baeume = baeume.to_crs(epsg=31256)
yppen_x, yppen_y = 239, 341672
bbox = box(yppen_x - 200, yppen_y - 200, yppen_x + 200, yppen_y + 200)
_, ax = plt.subplots()
strassenlinien[strassenlinien.intersects(bbox)].plot(ax=ax, color="blue", alpha=0.5, label="Straßenlinien")
baeume[baeume.intersects(bbox)].plot(ax=ax, color="green", alpha=0.5, markersize=5, label="Bäume")
minx, miny, maxx, maxy = bbox.bounds
ax.set_xlim(minx, maxx)
ax.set_ylim(miny, maxy)
plt.show()
Der Baumkataster enthält auch Bäume in Parks oder Innenhöfen.
Uns interessieren nur Straßenbäume.
Die definieren wir als jene Bäume die zumindest mit einem Teil ihrer Krone auf die Straße ragen.
Dafür brauchen wir:
- Straßenflächen
- Kronenflächen der einzelnen Bäume
Zunächst die Straßenflächen.
Leider hat ein Großteil der Straßen im Straßengraphen keine Breite angegeben:
print(f'{len(strassenlinien[strassenlinien["DEDICATEDWIDTH"] == -1])} von {len(strassenlinien)} Straßenlinien ohne Breite!')
Die Straßenabschnitte mit vorhandener Breiteninformation sind im Median 10 m breit:
strassenlinien[strassenlinien["DEDICATEDWIDTH"] != -1]["DEDICATEDWIDTH"].median()
Mit dieser Information Straßenflächen erzeugen:
strassenlinien["buffer_distance"] = strassenlinien["DEDICATEDWIDTH"].apply(lambda width: 5 if width == -1 else width / 2)
strassenflaechen = gpd.GeoDataFrame(geometry=strassenlinien.geometry.buffer(strassenlinien["buffer_distance"], cap_style="flat"), crs=strassenlinien.crs)
Der Kronendurchmesser im Baumkataster ist als Intervall angegeben:
baeume = baeume.rename(columns={"KRONENDURCHMESSER": "KRONENDURCHMESSER_ID"})
baeume.value_counts(subset=['KRONENDURCHMESSER_ID', 'KRONENDURCHMESSER_TXT']).reset_index(name='count')
Korrigieren der Kronendurchmesser-Texte die nicht zur jeweiligen ID passen.
import numpy as np
baeume["KRONENDURCHMESSER_TXT"] = np.where((baeume["KRONENDURCHMESSER_ID"] == 4) & (baeume["KRONENDURCHMESSER_TXT"] == ">21 m"), "10-12 m", baeume["KRONENDURCHMESSER_TXT"])
baeume["KRONENDURCHMESSER_TXT"] = np.where((baeume["KRONENDURCHMESSER_ID"] == 0) & (baeume["KRONENDURCHMESSER_TXT"] == "0-3 m"), "nicht bekannt", baeume["KRONENDURCHMESSER_TXT"])
baeume["KRONENDURCHMESSER_TXT"] = np.where((baeume["KRONENDURCHMESSER_ID"] == 6) & (baeume["KRONENDURCHMESSER_TXT"] == ">21 m"), "16-18 m", baeume["KRONENDURCHMESSER_TXT"])
baeume["KRONENDURCHMESSER_TXT"] = np.where((baeume["KRONENDURCHMESSER_ID"] == 5) & (baeume["KRONENDURCHMESSER_TXT"] == ">21 m"), "13-15 m", baeume["KRONENDURCHMESSER_TXT"])
baeume["KRONENDURCHMESSER_TXT"] = np.where((baeume["KRONENDURCHMESSER_ID"] == 7) & (baeume["KRONENDURCHMESSER_TXT"] == ">21 m"), "19-21 m", baeume["KRONENDURCHMESSER_TXT"])
baeume["KRONENDURCHMESSER_TXT"] = np.where((baeume["KRONENDURCHMESSER_ID"] == 2) & (baeume["KRONENDURCHMESSER_TXT"] == "7-9 m"), "4-6 m", baeume["KRONENDURCHMESSER_TXT"])
Überprüfung:
baeume.value_counts(subset=['KRONENDURCHMESSER_ID', 'KRONENDURCHMESSER_TXT']).reset_index(name='count')
Jetzt konkrete Werte für Kronendurchmesser ermitteln.
Wir schätzen die Kronendurchmesser auf den Mittelpunkt des jeweiligen Intervalls.
kronendurchmesser_mapping = {
0: np.nan,
1: 1.5,
2: 5,
3: 8,
4: 11,
5: 14,
6: 17,
7: 20,
8: 22
}
baeume["KRONENDURCHMESSER"] = baeume["KRONENDURCHMESSER_ID"].map(kronendurchmesser_mapping)
Mit einem konkreten Wert für den Kronendurchmesser können wir jetzt die Kronenflächen erzeugen und zusammen mit den Straßenflächen visualisieren.
Für Bäume mit unbekanntem Kronendurchmesser nehmen wir den durchschnittlichen Kronendurchmesser.
mean_kronendurchmesser = baeume["KRONENDURCHMESSER"].mean()
baeume["kronenflaeche"] = baeume.geometry.buffer(np.where(baeume["KRONENDURCHMESSER"].isna(), mean_kronendurchmesser / 2, baeume["KRONENDURCHMESSER"]) / 2)
bbox = box(yppen_x - 200, yppen_y - 200, yppen_x + 200, yppen_y + 200)
_, ax = plt.subplots()
strassenflaechen[strassenflaechen.intersects(bbox)].plot(ax=ax, color="blue", alpha=0.5, label="Straßenflächen")
baeume[baeume.intersects(bbox)]["kronenflaeche"].plot(ax=ax, color="green", alpha=0.5, label="Kronenflächen der Bäume")
minx, miny, maxx, maxy = bbox.bounds
ax.set_xlim(minx, maxx)
ax.set_ylim(miny, maxy)
plt.show()
Jetzt können wir jene Bäume herausfiltern deren Kronen sich nicht mit den Straßenflächen überschneiden.
from shapely.prepared import prep
from tqdm import tqdm
from shapely.ops import unary_union
strassenflaechen_merged = unary_union(strassenflaechen.geometry)
tqdm.pandas()
prepared_strassenflaechen = prep(strassenflaechen_merged)
baeume = baeume[baeume["kronenflaeche"].progress_apply(prepared_strassenflaechen.intersects)]
bbox = box(yppen_x - 200, yppen_y - 200, yppen_x + 200, yppen_y + 200)
_, ax = plt.subplots()
strassenflaechen[strassenflaechen.intersects(bbox)].plot(ax=ax, color="blue", alpha=0.5, label="Straßenflächen")
baeume[baeume.intersects(bbox)]["kronenflaeche"].plot(ax=ax, color="green", alpha=0.5, label="Kronenflächen der Bäume")
minx, miny, maxx, maxy = bbox.bounds
ax.set_xlim(minx, maxx)
ax.set_ylim(miny, maxy)
plt.show()
Einige der Baumkatastereinträge beschreiben keine echten Bäume sondern noch zu pflanzende Jungbäume.
len(baeume[baeume["GATTUNG_ART"] == "Jungbaum wird gepflanzt"])
Diese auch entfernen.
baeume = baeume[baeume["GATTUNG_ART"] != "Jungbaum wird gepflanzt"]
Wie viele Bäume bleiben übrig von den ursprünglichen?
print(f"{len(baeume)} vs {len(pd.read_csv('datasets/baeume.csv'))}")
Die Metriken im Baumkataster sind teils etwas unintuitiv, deshalb:
- Pflanzjahr -> Alter
- Stammumfang -> Stammdurchmesser
from datetime import datetime
current_year = datetime.now().year
baeume["ALTER"] = np.where(baeume["PFLANZJAHR"] > 0, current_year - baeume["PFLANZJAHR"], np.nan)
baeume["ALTER_TXT"] = np.where(baeume["ALTER"] >= 0, baeume["ALTER"].astype("Int64").astype(str) + " Jahre", "unbekannt")
baeume["STAMMDURCHMESSER"] = np.where(baeume["STAMMUMFANG"] > 0, baeume["STAMMUMFANG"] / np.pi, np.nan)
baeume["STAMMDURCHMESSER_TXT"] = np.where(baeume["STAMMDURCHMESSER"].isna(), "unbekannt", baeume["STAMMDURCHMESSER"].round().astype("Int64").astype(str) + " cm")
Auch bei Kronendurchmesser und Baumhöhe fehlende Werte auf np.nan
bzw. unbekannt
setzen.
baeume.value_counts(subset=['BAUMHOEHE', 'BAUMHOEHE_TXT']).reset_index(name='count')
baeume["KRONENDURCHMESSER_TXT"] = np.where(baeume["KRONENDURCHMESSER_ID"] == 0, "unbekannt", baeume["KRONENDURCHMESSER_TXT"])
baeume["KRONENDURCHMESSER_ID"] = np.where(baeume["KRONENDURCHMESSER_ID"] == 0, np.nan, baeume["KRONENDURCHMESSER_ID"])
baeume = baeume.rename(columns={"BAUMHOEHE": "BAUMHOEHE_ID"})
baeume["BAUMHOEHE_TXT"] = np.where(baeume["BAUMHOEHE_ID"] == 0, "unbekannt", baeume["BAUMHOEHE_TXT"])
baeume["BAUMHOEHE_ID"] = np.where(baeume["BAUMHOEHE_ID"] == 0, np.nan, baeume["BAUMHOEHE_ID"])
Für jede Metrik eines Baumes möchten wir wissen in welchem Perzentil die jeweilige Metrikausprägung liegt.
Unbekannte Werte ignorieren wir.
Für Baumhöhe und Kronendurchmesser die in Intervallen angegeben sind verwenden wir einfach die (aufsteigende) Intervall-ID.
baeume["ALTER_PERCENTILE"] = np.clip((baeume["ALTER"].rank(pct=True, na_option="keep") * 100).round(0).astype("Int64"), 1, 99)
baeume["BAUMHOEHE_PERCENTILE"] = np.clip((baeume["BAUMHOEHE_ID"].rank(pct=True, na_option="keep") * 100).round(0).astype("Int64"), 1, 99)
baeume["STAMMDURCHMESSER_PERCENTILE"] = np.clip((baeume["STAMMDURCHMESSER"].rank(pct=True, na_option="keep") * 100).round(0).astype("Int64"), 1, 99)
baeume["KRONENDURCHMESSER_PERCENTILE"] = np.clip((baeume["KRONENDURCHMESSER_ID"].rank(pct=True, na_option="keep") * 100).round(0).astype("Int64"), 1, 99)
Für Gattung und Art eines Baumes bewerten wir die Seltenheit.
Wie viele Gattungen bzw. Arten gibt es eigentlich?
len(baeume["GATTUNG_ART"].unique())
Ziemlich viele!
Die Seltenheit wäre für viele Gattungen mit Art also sehr hoch.
Bei vielen Bäumen ist noch die Sorte angegeben, vielleicht gibt es auch deswegen so viele verschiedene Ausprägungen.
Hinter dem lateinischen Namen steht in Klammer die deutsche Bezeichnung ohne Sorte, z.B. Cercis canadensis 'Ruby Falls' (Kanadischer Judasbaum)
Haben alle Bäume so eine deutsche Bezeichnung?
has_german_name = baeume["GATTUNG_ART"].str.contains(r"\(.*\)", na=False)
missing_german_name = baeume[~has_german_name]
print(missing_german_name["GATTUNG_ART"])
Passt, also wie viele einzigartige deutsche Artbezeichnungen gibt es?
baeume["GATTUNG_ART_DEUTSCH"] = baeume["GATTUNG_ART"].str.extract(r"\((.*?)\)")
len(baeume["GATTUNG_ART_DEUTSCH"].unique())
Nicht berauschend aber besser!
Jetzt die Verteilung.
baeume["GATTUNG_ART"] = baeume["GATTUNG_ART_DEUTSCH"]
baeume["GATTUNG_ART"].value_counts()
baeume["GATTUNG_ART_FREQUENCY"] = baeume["GATTUNG_ART"].map(baeume["GATTUNG_ART"].value_counts())
Schließlich exportieren wir die Bäume mit ihren darzustellenden Eigenschaften als GeoJSON.
baeume_wsg84 = baeume[["geometry", "BAUMNUMMER", "GATTUNG_ART", "GATTUNG_ART_FREQUENCY", "ALTER_TXT", "ALTER_PERCENTILE", "STAMMDURCHMESSER_TXT", "STAMMDURCHMESSER_PERCENTILE", "BAUMHOEHE_TXT", "BAUMHOEHE_PERCENTILE", "KRONENDURCHMESSER", "KRONENDURCHMESSER_TXT", "KRONENDURCHMESSER_PERCENTILE"]].to_crs(epsg=4326)
baeume_wsg84.to_file("web/baeume.geojson", driver="GeoJSON", id_generate=True)
Um die Wiener Straßenflächen je nach Baumdichte einzufärben visualisieren wir die Bäume als Heatmap und maskieren diese mit den invertierten Straßenflächen.
Die invertierten Straßenflächen padden wir mir je 3 km in jede Richtung um die interaktive Karte besser navigierbar zu machen.
from shapely.geometry import box
minx, miny, maxx, maxy = baeume.total_bounds
bounding_box = box(minx - 3000, miny - 3000, maxx + 3000, maxy + 3000)
inverted_strassenflaechen = bounding_box.difference(strassenflaechen_merged)
inverted_strassenflaechen = gpd.GeoDataFrame(geometry=[inverted_strassenflaechen], crs="EPSG:31256")
inverted_strassenflaechen.to_crs(epsg=4326).to_file("web/inverted_strassenflaechen.geojson", driver="GeoJSON")
Welche Max-Bounds für die Visualisierung setzen?
inverted_strassenflaechen.to_crs(epsg=4326).total_bounds