Simplified detection of urban types

Example adapted from the SDSC 2021 Workshop led by Martin Fleischmann. You can see the recording of the workshop on YouTube.

This example illustrates the potential of morphometrics captured by momepy in capturing the structure of cities. We will pick a town, fetch its data from the OpenStreetMap, and analyse it to detect individual types of urban structure within it.

This method is only illustrative and is based on the more extensive one published by Fleischmann et al. (2021) available from https://github.com/martinfleis/numerical-taxonomy-paper.

Fleischmann M, Feliciotti A, Romice O and Porta S (2021) Methodological Foundation of a Numerical Taxonomy of Urban Form. Environment and Planning B: Urban Analytics and City Science, doi: 10.1177/23998083211059835

It depends on the following packages:

- momepy
- osmnx
- clustergram
- bokeh
- scikit-learn
- geopy
- ipywidgets
import geopandas
import libpysal
import matplotlib.pyplot as plt
import momepy
import osmnx
import pandas
from bokeh.io import output_notebook
from bokeh.plotting import show
from clustergram import Clustergram

output_notebook()
Loading BokehJS ...

Pick a place, ideally a town with a good coverage in OpenStreetMap and its local CRS.

place = "Znojmo, Czechia"
local_crs = 5514

We can interactively explore the place we just selected.

geopandas.tools.geocode(place).explore()
Make this Notebook Trusted to load map: File -> Trust Notebook

Input data

We can use OSMnx to quickly download data from OpenStreetMap. If you intend to download larger areas, we recommend using pyrosm instead.

Buildings

buildings = osmnx.features_from_place(place, tags={"building": True})
buildings.head()
geometry building bunker_type historic military name ref:ropiky.net source website amenity ... outdoor_seating branch monitoring:water_level automated self_service shelter_type bridge:support construction bench type
element id
node 3372076291 POINT (16.05376 48.84683) bunker pillbox yes bunker 7/I/10/A-120 1105625216 ropiky.net https://ropiky.net/dbase_objekt.php?id=1105625216 NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
3372076393 POINT (16.05581 48.84158) bunker pillbox yes bunker 7/I/11/A-140 Z 1105625217 ropiky.net https://ropiky.net/dbase_objekt.php?id=1105625217 NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
3372076394 POINT (16.05867 48.83522) bunker pillbox yes bunker 7/I/12/A-220 1105625218 ropiky.net https://ropiky.net/dbase_objekt.php?id=1105625218 NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
3372076428 POINT (16.03949 48.85599) bunker pillbox yes bunker 7/I/8/E 1105625214 ropiky.net https://ropiky.net/dbase_objekt.php?id=1105625214 NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
3749294087 POINT (16.04696 48.85305) chapel NaN NaN NaN NaN NaN survey NaN place_of_worship ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN

5 rows × 140 columns

The OSM input may need a bit of cleaning to ensure only proper polygons are kept.

buildings.geom_type.value_counts()
Polygon    12437
Point          6
Name: count, dtype: int64
buildings = buildings[buildings.geom_type == "Polygon"].reset_index(drop=True)

And we should re-project the data from WGS84 to the local projection in meters (momepy default values assume meters not feet or degrees). We will also drop unnecessary columns.

buildings = buildings[["geometry"]].to_crs(local_crs)
buildings.head()
geometry
0 POLYGON ((-643052.212 -1193474.914, -643069.77...
1 POLYGON ((-642796.708 -1193674.586, -642795.74...
2 POLYGON ((-642960.567 -1193475.288, -642969.02...
3 POLYGON ((-642973.521 -1193481.346, -642960.58...
4 POLYGON ((-642972.411 -1193762.425, -642979.76...

Streets

Similar operations are done with streets.

osm_graph = osmnx.graph_from_place(place, network_type="drive")
osm_graph = osmnx.projection.project_graph(osm_graph, to_crs=local_crs)
streets = osmnx.graph_to_gdfs(
    osmnx.convert.to_undirected(osm_graph),
    nodes=False,
    edges=True,
    node_geometry=False,
    fill_edge_geometry=True,
).reset_index(drop=True)
streets.head()
osmid highway maxspeed name ref oneway reversed length from to geometry lanes bridge width tunnel junction access
0 33733060 secondary 50 Přímětická 361 False True 24.573585 639231391 74103628 LINESTRING (-643229.639 -1192872.949, -643239.... NaN NaN NaN NaN NaN NaN
1 33733060 secondary 50 Přímětická 361 False False 60.345697 3775990798 74103628 LINESTRING (-643236.395 -1192790.304, -643236.... NaN NaN NaN NaN NaN NaN
2 50313252 residential NaN Raisova NaN True False 74.762885 639231413 74103628 LINESTRING (-643291.344 -1192797.012, -643288.... NaN NaN NaN NaN NaN NaN
3 33733060 secondary 50 Přímětická 361 False True 54.260241 74142638 639231391 LINESTRING (-643205.434 -1192921.533, -643219.... NaN NaN NaN NaN NaN NaN
4 50313241 residential NaN Mičurinova NaN True False 104.235768 639231391 639231314 LINESTRING (-643229.639 -1192872.949, -643233.... NaN NaN NaN NaN NaN NaN

We can also do some preprocessing using momepy to ensure we have proper network topology.

streets = momepy.remove_false_nodes(streets)
streets = streets[["geometry"]]
/tmp/ipykernel_4093/2560506308.py:1: FutureWarning: `remove_false_nodes` is deprecated and will be removed in momepy 1.0. The function has been improved and moved to the `neatnet` package and can be used as `neatnet.remove_interstitial_nodes`. See https://uscuni.org/neatnet for details.
  streets = momepy.remove_false_nodes(streets)
streets.head()
geometry
0 LINESTRING (-643229.639 -1192872.949, -643239....
1 LINESTRING (-643236.395 -1192790.304, -643236....
2 LINESTRING (-643291.344 -1192797.012, -643288....
3 LINESTRING (-643205.434 -1192921.533, -643219....
4 LINESTRING (-643229.639 -1192872.949, -643233....

Generated data

Tessellation

Given building footprints:

blg

We can generate a spatial unit using morphological tessellation:

tess

limit = momepy.buffered_limit(buildings, "adaptive")

tessellation = momepy.morphological_tessellation(buildings, clip=limit)

OpenStreetMap data are often problematic due to low quality of some polygons. If some collapse, we get a mismatch between the length of buildings and the length of polygons.

collapsed, _ = momepy.verify_tessellation(tessellation, buildings)
/tmp/ipykernel_4093/3509021287.py:1: UserWarning: Tessellation does not fully match buildings. 21 element(s) disappeared during generation. Index of the affected elements: Index([ 3976,  3986,  4195,  4203,  4206,  4233,  4237,  4238,  4242,  8520,
        8747,  9001,  9012,  9034, 10432, 10648, 10649, 11216, 11430, 11431,
       11435],
      dtype='int64').
  collapsed, _ = momepy.verify_tessellation(tessellation, buildings)
/tmp/ipykernel_4093/3509021287.py:1: UserWarning: Tessellation contains MultiPolygon elements. Initial objects should  be edited. Index of affected elements: [52, 200, 260, 569, 572, 573, 583, 683, 834, 1387, 1670, 1675, 1676, 1680, 1682, 1688, 1697, 1732, 1741, 2367, 2910, 2941, 2956, 3195, 3801, 4190, 4682, 4739, 4940, 5185, 5316, 5491, 5697, 5796, 6038, 6141, 6368, 6375, 6380, 6411, 6556, 6582, 6639, 6964, 7177, 7225, 7242, 7368, 7369, 7370, 7371, 7372, 7374, 7375, 7376, 7377, 7379, 7381, 7382, 7385, 7386, 7387, 7388, 7469, 7471, 7472, 7477, 7719, 7821, 7948, 8409, 8417, 8428, 8430, 8516, 8745, 8883, 8920, 9428, 9441, 9455, 9580, 9771, 9822, 9834, 9959, 10026, 10053, 10179, 10285, 10470, 10620, 10959, 11014, 11226, 11423, 11686, 11736, 11827, 11980, 12232].
  collapsed, _ = momepy.verify_tessellation(tessellation, buildings)

Better to drop affected buildings and re-create tessellation.

buildings = buildings.drop(collapsed)
limit = momepy.buffered_limit(buildings, "adaptive")
tessellation = momepy.morphological_tessellation(buildings, clip=limit)

Check the result.

tessellation.shape[0] == buildings.shape[0]
True

Link unique IDs of streets to buildings and tessellation cells based on the nearest neighbor join.

buildings["street_index"] = momepy.get_nearest_street(
    buildings, streets, max_distance=100
)
buildings
geometry street_index
0 POLYGON ((-643052.212 -1193474.914, -643069.77... 510.0
1 POLYGON ((-642796.708 -1193674.586, -642795.74... 375.0
2 POLYGON ((-642960.567 -1193475.288, -642969.02... 521.0
3 POLYGON ((-642973.521 -1193481.346, -642960.58... 521.0
4 POLYGON ((-642972.411 -1193762.425, -642979.76... 388.0
... ... ...
12432 POLYGON ((-642683.876 -1191764.671, -642680.97... 130.0
12433 POLYGON ((-642622.226 -1191808.244, -642619.29... 130.0
12434 POLYGON ((-642684.751 -1191833.324, -642691.36... 130.0
12435 POLYGON ((-642713.984 -1191804.053, -642707.42... 130.0
12436 POLYGON ((-642856.605 -1191712.297, -642853.78... 130.0

12416 rows × 2 columns

Aattach the network index to the tessellation as well.

tessellation["street_index"] = buildings["street_index"]

Measure

Measure individual morphometric characters. For details see the User Guide and the API reference.

Dimensions

buildings["building_area"] = buildings.area
tessellation["tess_area"] = tessellation.area
streets["length"] = streets.length

Shape

buildings["eri"] = momepy.equivalent_rectangular_index(buildings)
buildings["elongation"] = momepy.elongation(buildings)
tessellation["convexity"] = momepy.convexity(tessellation)
streets["linearity"] = momepy.linearity(streets)
fig, ax = plt.subplots(1, 2, figsize=(24, 12))

buildings.plot("eri", ax=ax[0], scheme="natural_breaks", legend=True)
buildings.plot("elongation", ax=ax[1], scheme="natural_breaks", legend=True)

ax[0].set_axis_off()
ax[1].set_axis_off()
../_images/0bfcce68dc7db24316a14e70013c16c2ee6a3ecaafd3a9237be6aa014153e753.png
fig, ax = plt.subplots(1, 2, figsize=(24, 12))

tessellation.plot("convexity", ax=ax[0], scheme="natural_breaks", legend=True)
streets.plot("linearity", ax=ax[1], scheme="natural_breaks", legend=True)

ax[0].set_axis_off()
ax[1].set_axis_off()
../_images/ea90b6f5608e10be93f2b6c4d9509465efa4d1f7ee595db149274fdca6aa2315.png

Spatial distribution

buildings["shared_walls"] = momepy.shared_walls(buildings) / buildings.length
buildings.plot(
    "shared_walls", figsize=(12, 12), scheme="natural_breaks", legend=True
).set_axis_off()
../_images/f296c116483fad49c7463f4713d40411ca482a8d573deb22cbf43cab8f64076f.png

Generate spatial graph using libpysal.

queen_1 = libpysal.graph.Graph.build_contiguity(tessellation, rook=False)
tessellation["neighbors"] = momepy.neighbors(
    tessellation, queen_1, weighted=True
)
tessellation["covered_area"] = queen_1.describe(tessellation.area)["sum"]
buildings["neighbor_distance"] = momepy.neighbor_distance(buildings, queen_1)
fig, ax = plt.subplots(1, 2, figsize=(24, 12))

buildings.plot(
    "neighbor_distance", ax=ax[0], scheme="natural_breaks", legend=True
)
tessellation.plot(
    "covered_area", ax=ax[1], scheme="natural_breaks", legend=True
)

ax[0].set_axis_off()
ax[1].set_axis_off()
../_images/0f198b1cb637a09ffd2bbbb73103697e24ddb3af2aea79d37ce63e123624832d.png
queen_3 = queen_1.higher_order(3)
buildings_q1 = libpysal.graph.Graph.build_contiguity(buildings, rook=False)

buildings["interbuilding_distance"] = momepy.mean_interbuilding_distance(
    buildings, queen_1, queen_3
)
buildings["adjacency"] = momepy.building_adjacency(buildings_q1, queen_3)
/home/runner/micromamba/envs/documentation/lib/python3.14/site-packages/momepy/distribution.py:377: RuntimeWarning: invalid value encountered in scalar divide
  mean_distances[i] = sub_matrix.sum() / sub_matrix.nnz
fig, ax = plt.subplots(1, 2, figsize=(24, 12))

buildings.plot(
    "interbuilding_distance", ax=ax[0], scheme="natural_breaks", legend=True
)
buildings.plot("adjacency", ax=ax[1], scheme="natural_breaks", legend=True)

ax[0].set_axis_off()
ax[1].set_axis_off()
../_images/75dc0dba622558a00481f8025c182138571abe110e13a69c6fa08492e8a11ee4.png
profile = momepy.street_profile(streets, buildings)
streets[profile.columns] = profile
fig, ax = plt.subplots(1, 3, figsize=(24, 12))

streets.plot("width", ax=ax[0], scheme="natural_breaks", legend=True)
streets.plot("width_deviation", ax=ax[1], scheme="natural_breaks", legend=True)
streets.plot("openness", ax=ax[2], scheme="natural_breaks", legend=True)

ax[0].set_axis_off()
ax[1].set_axis_off()
ax[2].set_axis_off()
../_images/7d7454c3fd6ba7c513a737889be1d319f3ceed736720f8fd12df4b1112db101c.png

Intensity

tessellation["car"] = buildings.area / tessellation.area
tessellation.plot(
    "car", figsize=(12, 12), vmin=0, vmax=1, legend=True
).set_axis_off()
../_images/4f9a7415f53a0e0ddf7749df3d102e3d9debfc5163c083386ae61de6e1fa35ab.png

Connectivity

graph = momepy.gdf_to_nx(streets)
graph = momepy.node_degree(graph)
graph = momepy.closeness_centrality(graph, radius=400, distance="mm_len")
graph = momepy.meshedness(graph, radius=400, distance="mm_len")
nodes, edges = momepy.nx_to_gdf(graph)
fig, ax = plt.subplots(1, 3, figsize=(24, 12))

nodes.plot(
    "degree", ax=ax[0], scheme="natural_breaks", legend=True, markersize=1
)
nodes.plot(
    "closeness",
    ax=ax[1],
    scheme="natural_breaks",
    legend=True,
    markersize=1,
    legend_kwds={"fmt": "{:.6f}"},
)
nodes.plot(
    "meshedness", ax=ax[2], scheme="natural_breaks", legend=True, markersize=1
)

ax[0].set_axis_off()
ax[1].set_axis_off()
ax[2].set_axis_off()
/home/runner/micromamba/envs/documentation/lib/python3.14/site-packages/mapclassify/classifiers.py:689: UserWarning: Not enough unique values in array to form 5 classes. Setting k to 4.
  self._classify()
../_images/a5a9e641e0de9dc8968d61d5831e82570190342b30aa65146414aca6f317fdf6.png
buildings["edge_index"] = momepy.get_nearest_street(buildings, edges)
buildings["node_index"] = momepy.get_nearest_node(
    buildings, nodes, edges, buildings["edge_index"]
)

Link all data together (to tessellation cells or buildings).

tessellation.head()
geometry street_index tess_area convexity neighbors covered_area car
0 POLYGON ((-643027.254 -1193482.952, -643031.14... 510.0 1570.760714 0.944061 0.049921 4700.496454 0.490475
1 POLYGON ((-642846.55 -1193731.531, -642848.065... 375.0 4683.653933 0.886292 0.042576 13751.851395 0.390842
2 POLYGON ((-642983.939 -1193470.454, -642981.28... 521.0 677.122280 0.855853 0.067580 3190.052520 0.786457
3 POLYGON ((-642989.083 -1193506.476, -642990.21... 521.0 1289.763991 0.918215 0.057808 3885.094908 0.593375
4 POLYGON ((-642981.974 -1193756.089, -642982.16... 388.0 1110.536283 0.824523 0.072330 6966.791366 0.748308
buildings.head()
geometry street_index building_area eri elongation shared_walls neighbor_distance interbuilding_distance adjacency edge_index node_index
0 POLYGON ((-643052.212 -1193474.914, -643069.77... 510.0 770.419570 0.742491 0.971708 0.000000 7.533535 4.129293 0.312500 707.0 428.0
1 POLYGON ((-642796.708 -1193674.586, -642795.74... 375.0 1830.570622 0.544350 0.850052 0.194888 15.770806 9.982065 0.352941 229.0 123.0
2 POLYGON ((-642960.567 -1193475.288, -642969.02... 521.0 532.527489 0.646696 0.629207 0.499747 1.894416 5.967325 0.310345 577.0 331.0
3 POLYGON ((-642973.521 -1193481.346, -642960.58... 521.0 765.313279 0.630997 0.734407 0.301092 4.253296 4.153901 0.250000 577.0 435.0
4 POLYGON ((-642972.411 -1193762.425, -642979.76... 388.0 831.023404 0.618086 0.273948 0.444967 4.196437 6.480408 0.268293 582.0 338.0
tessellation[buildings.columns.drop(["geometry", "street_index"])] = (
    buildings.drop(columns=["geometry", "street_index"])
)
merged = tessellation.merge(
    edges.drop(columns="geometry"),
    left_on="edge_index",
    right_index=True,
    how="left",
)
merged = merged.merge(
    nodes.drop(columns="geometry"),
    left_on="node_index",
    right_index=True,
    how="left",
)
merged.columns
Index(['geometry', 'street_index', 'tess_area', 'convexity', 'neighbors',
       'covered_area', 'car', 'building_area', 'eri', 'elongation',
       'shared_walls', 'neighbor_distance', 'interbuilding_distance',
       'adjacency', 'edge_index', 'node_index', 'length', 'linearity', 'width',
       'openness', 'width_deviation', 'mm_len', 'node_start', 'node_end', 'x',
       'y', 'degree', 'closeness', 'meshedness', 'nodeID'],
      dtype='str')

Understanding the context

Measure first, second and third quartile of distribution of values within an area around each building.

percentiles = []
for column in merged.columns.drop(
    [
        "street_index",
        "node_index",
        "edge_index",
        "nodeID",
        "mm_len",
        "node_start",
        "node_end",
        "geometry",
    ]
):
    perc = momepy.percentile(merged[column], queen_3)
    perc.columns = [f"{column}_" + str(x) for x in perc.columns]
    percentiles.append(perc)
percentiles_joined = pandas.concat(percentiles, axis=1)
percentiles_joined.head()
tess_area_25 tess_area_50 tess_area_75 convexity_25 convexity_50 convexity_75 neighbors_25 neighbors_50 neighbors_75 covered_area_25 ... y_75 degree_25 degree_50 degree_75 closeness_25 closeness_50 closeness_75 meshedness_25 meshedness_50 meshedness_75
focal
0 230.708126 351.672562 576.090369 0.855431 0.902430 0.954034 0.064978 0.072889 0.084756 2006.131179 ... -1.193414e+06 3.0 3.0 3.0 0.000131 0.000181 0.000183 0.164179 0.184615 0.215686
1 239.815210 533.779538 834.138716 0.874198 0.913986 0.971472 0.044209 0.053862 0.079564 2697.795438 ... -1.193642e+06 3.0 3.0 4.0 0.000138 0.000155 0.000158 0.123288 0.138462 0.156863
2 162.689121 314.811852 641.571428 0.868671 0.912821 0.965460 0.053447 0.066875 0.083608 2559.747341 ... -1.193449e+06 3.0 3.0 3.0 0.000131 0.000180 0.000181 0.184615 0.215686 0.215686
3 211.145757 289.914947 573.343767 0.840993 0.897746 0.944636 0.053661 0.066618 0.078878 2056.505521 ... -1.193446e+06 3.0 3.0 3.0 0.000131 0.000180 0.000181 0.169014 0.215686 0.215686
4 241.005229 336.842770 831.258718 0.821091 0.905923 0.955967 0.041497 0.059971 0.084659 2572.369597 ... -1.193685e+06 3.0 4.0 4.0 0.000139 0.000158 0.000173 0.123288 0.138462 0.140845

5 rows × 66 columns

See the difference between original convexity and spatially lagged one.

fig, ax = plt.subplots(1, 2, figsize=(24, 12))

tessellation.plot("convexity", ax=ax[0], scheme="natural_breaks", legend=True)
merged.plot(
    percentiles_joined["convexity_50"].values,
    ax=ax[1],
    scheme="natural_breaks",
    legend=True,
)

ax[0].set_axis_off()
ax[1].set_axis_off()
../_images/314166db54ba9ef50a8f271aea6e19386764ca21a629d2a5c44f310e135a121d.png

Clustering

Now we can use obtained values within a cluster analysis that should detect types of urban structure.

Standardize values before clustering.

standardized = (
    percentiles_joined - percentiles_joined.mean()
) / percentiles_joined.std()
standardized.head()
tess_area_25 tess_area_50 tess_area_75 convexity_25 convexity_50 convexity_75 neighbors_25 neighbors_50 neighbors_75 covered_area_25 ... y_75 degree_25 degree_50 degree_75 closeness_25 closeness_50 closeness_75 meshedness_25 meshedness_50 meshedness_75
focal
0 -0.418227 -0.571980 -0.706483 -1.371278 -2.089822 -1.891631 2.348795 1.497604 0.841496 -0.716654 ... -0.001666 0.567546 0.245915 -0.088798 1.189604 1.632729 1.273427 0.948247 0.854741 0.739129
1 -0.406031 -0.410445 -0.576281 -0.917786 -1.640356 -0.667184 0.671661 0.334094 0.625438 -0.569708 ... -0.112765 0.567546 0.245915 1.383153 1.321746 1.190761 0.883754 0.479651 0.386089 0.258467
2 -0.509316 -0.604676 -0.673443 -1.051348 -1.685686 -1.089345 1.417684 1.129868 0.793748 -0.599037 ... -0.018744 0.567546 0.245915 -0.088798 1.189604 1.613104 1.243693 1.182438 1.170238 0.739129
3 -0.444424 -0.626761 -0.707868 -1.720187 -2.272025 -2.551573 1.434912 1.114160 0.596898 -0.705952 ... -0.016930 0.567546 0.245915 -0.088798 1.189604 1.609427 1.247973 1.003654 1.170238 0.739129
4 -0.404437 -0.585134 -0.577734 -2.201111 -1.953971 -1.755963 0.452636 0.707651 0.837456 -0.596355 ... -0.133533 0.567546 1.588515 1.383153 1.334746 1.238371 1.120909 0.479651 0.386089 0.127582

5 rows × 66 columns

How many clusters?

To determine how many clusters we should aim for, we can use a little package called clustergram. See its documentation for details.

cgram = Clustergram(range(1, 12), n_init=10, random_state=42)
cgram.fit(standardized.fillna(0))

show(cgram.bokeh())
K=1 skipped. Mean computed from data directly.
K=2 fitted in 0.158 seconds.
K=3 fitted in 0.457 seconds.
K=4 fitted in 0.473 seconds.
K=5 fitted in 0.434 seconds.
K=6 fitted in 1.004 seconds.
K=7 fitted in 0.783 seconds.
K=8 fitted in 0.604 seconds.
K=9 fitted in 0.766 seconds.
K=10 fitted in 0.850 seconds.
K=11 fitted in 0.859 seconds.

Clustegram gives us also the final labels. (Normally, you would run the final clustering on much larger number of initialisations.)

cgram.labels.head()
1 2 3 4 5 6 7 8 9 10 11
0 0 1 1 3 3 5 4 1 7 6 4
1 0 1 1 3 3 5 4 1 7 6 4
2 0 1 1 3 3 5 4 1 7 6 4
3 0 1 1 3 3 5 4 1 7 6 4
4 0 1 1 3 3 5 4 1 7 6 4
merged["cluster"] = cgram.labels[10].values
buildings["cluster"] = merged["cluster"]
buildings.plot(
    "cluster", categorical=True, figsize=(16, 16), legend=True
).set_axis_off()
../_images/9cb7c1dd0125c32cc5c53e0d363859bef5b8416b4decfbcea9d3dd35e128d66d.png
ax = buildings.plot("cluster", categorical=True, figsize=(16, 16), legend=True)
ax.set_xlim(-645000, -641000)
ax.set_ylim(-1195500, -1191000)
ax.set_axis_off()
../_images/614231a4ffd8f34ace303fd4ce985caf66b70c30fefd0b1d68bd930183bbf0e5.png