A complete workflow, from raw data to an evaluated clustering. Small spatial units are grouped into functional clusters by combining three relations between them: contig (spatial contiguity, from contiguity_graph() over the polygons), 15_min_walk (pedestrian accessibility, from bridge_nodes() plus add_metapaths_by_weight() over the street network), and 15_min_multi (the same with the GTFS bus network added, excluding the pairs already reachable on foot). contig is the homogeneous reference, two units related if they touch; the other two carry a far less local hypothesis about what makes two units alike.
The study area is central São Paulo, and the units are a 250 m grid rather than census tracts. A grid is a worse unit, respecting neither population nor morphology, but it plays the same role in the graph mechanics and needs no separate download. The last section records how to swap in real boundaries, a different transit feed and Overture place categories.
PyTorch is not installed in Blender’s Python, so gdf_to_pyg() and anything that learns an embedding cannot run here. The notebook builds the full heterogeneous graph with its 23 attributes anyway, then clusters it with PCA + K-Means and scores the result by silhouette and by modularity per relation.
18 · Functional clustering, end to end
Group small spatial units into functional clusters by combining three relations between them, then measure whether the grouping holds up.
Relation
What it encodes
How it is built
contig
spatial contiguity
contiguity_graph() over the polygons
15_min_walk
pedestrian accessibility
bridge_nodes() + add_metapaths_by_weight() over the street network
15_min_multi
multimodal accessibility
the same, adding the bus network from the GTFS
contig is the homogeneous reference: two units are related if they touch. The other two carry accessibility, a much less local hypothesis.
Central São Paulo, units on a 250 m grid. A grid respects neither population nor morphology but behaves the same in the graph and needs no download; section 12 swaps in real boundaries and another feed.
PyTorch is not installed in Blender’s Python, so gdf_to_pyg() and anything that learns an embedding are out of reach. PCA + K-Means, scored by silhouette and by modularity per relation, stands in for it.
0 · Setup
import pathlibimport sysimport timedef find_repo():"""Locate the SciGraphs repository, without relying on the cwd. In a normal Jupyter the kernel starts per notebook with the cwd set to its folder. Not here: the kernel lives inside Blender and existed before you opened anything, so the cwd is wherever Blender was launched from. What the extension does know is which folder it is serving to JupyterLab. """ candidates = []try:import bpy prefs = bpy.context.preferences.addons["bl_ext.user_default.jupyter_blender"].preferences candidates.append(pathlib.Path(bpy.path.abspath(prefs.notebook_dir)))exceptException:pass candidates.append(pathlib.Path.cwd())for base in candidates:for directory in (base, *base.parents):if (directory /"SciGraphs"/"api"/"graphs.py").is_file():return directoryraiseRuntimeError("Cannot find the SciGraphs repository. Point at it by hand:\n"" sys.path.insert(0, '/path/to/SciGraphs-1/notebooks/tools')")sys.path.insert(0, str(find_repo() /"notebooks"/"tools"))import bpyimport city2graph as c2gimport geopandas as gpdimport numpy as npimport pandas as pd# `nb` must import first: it puts this repository on sys.path, so the# `SciGraphs` import below reads this tree, not the copy Blender installed.import nbfrom nb import checkfrom SciGraphs import api as sgox = nb.osmnx()# Central São Paulo; the radius keeps this to minutes.CENTER = (-23.5505, -46.6333)RADIUS_M =1800ZONE_SIDE_M =250METRIC_CRS ="EPSG:31983"# UTM 23SSPEED_MS =4.8*1000/3600# 4.8 km/h, the standard walking speedTHRESHOLD_S =900# 15 minutesGTFS_FEED = nb.repo("examples", "GTFS", "sao_paulo.zip")OUTPUT_DIR = nb.out("18_case_study")OUTPUT_DIR.mkdir(parents=True, exist_ok=True)print("GTFS feed:", GTFS_FEED.name, GTFS_FEED.exists())
GTFS feed: sao_paulo.zip True
1 · The spatial units
A 250 m grid over the study area, keeping the cells with something built on them. These are the units everything else attaches to.
from shapely.geometry import box, Pointcenter_m = gpd.GeoSeries([Point(CENTER[1], CENTER[0])], crs="EPSG:4326").to_crs(METRIC_CRS).iloc[0]study_area = center_m.buffer(RADIUS_M)xs = np.arange(study_area.bounds[0], study_area.bounds[2], ZONE_SIDE_M)ys = np.arange(study_area.bounds[1], study_area.bounds[3], ZONE_SIDE_M)cells = [{"zone_id": f"Z{i:03d}{j:03d}","geometry": box(x, y, x + ZONE_SIDE_M, y + ZONE_SIDE_M)}for i, x inenumerate(xs) for j, y inenumerate(ys)]grid = gpd.GeoDataFrame(cells, crs=METRIC_CRS)grid = grid[grid.intersects(study_area)].reset_index(drop=True)print(f"{len(grid)} candidate cells of {ZONE_SIDE_M} m")
191 candidate cells of 250 m
# Two shapes on purpose: section 9 extrudes the untrimmed download because# `height` and `building:levels` live on it. From `buildings` alone the skyline# could only be a constant.buildings_osm = ox.features_from_point(CENTER, tags={"building": True}, dist=RADIUS_M)buildings_osm = buildings_osm[ buildings_osm.geometry.geom_type.isin(["Polygon", "MultiPolygon"])]buildings = buildings_osm.to_crs(METRIC_CRS)[["geometry"]].reset_index(drop=True)with_building = gpd.sjoin(grid, buildings, how="inner", predicate="intersects")["zone_id"].unique()zones = grid[grid["zone_id"].isin(with_building)].reset_index(drop=True)zones = zones.set_index("zone_id")print(f"{len(buildings):,} buildings")print(f"{len(zones)} built-up zones")check("enough zones to cluster", len(zones) >=50, f"{len(zones)}")
26,548 buildings
191 built-up zones
[PASS] enough zones to cluster — 191
True
2 · Urban function attributes
23 dimensions per zone: 16 POI counts by category and 7 areas by land-use class.
The logarithm flattens the tails (POI counts are heavily skewed); the standardization puts every dimension on one scale.
X = np.log1p(attributes.to_numpy(dtype=float))sigma = X.std(axis=0)sigma[sigma ==0] =1.0X = (X - X.mean(axis=0)) / sigmaprint(f"X: {X.shape}")print(f"mean {X.mean():.2e}, standard deviation {X.std():.3f}")check("the attributes are standardized",abs(X.mean()) <1e-9andabs(X.std() -1) <0.2)
X: (191, 23)
mean -9.70e-18, standard deviation 0.978
[PASS] the attributes are standardized
True
3 · Relation 1: contig
Queen contiguity between zones, nodes at the centroid. The edges get a pseudo travel time so they weigh the same as the other two relations: Euclidean distance between centroids divided by the walking speed.
zones_pt = zones.copy()zones_pt["geometry"] = zones.geometry.centroidnodes_contig, edges_contig = c2g.contiguity_graph(zones, contiguity="queen")# The contiguity graph returns polygons; the rest of the pipeline wants nodes.nodes_contig = nodes_contig.copy()nodes_contig["geometry"] = nodes_contig.geometry.centroidcentroids = zones_pt.geometryedges_contig = edges_contig.copy()edges_contig["travel_time"] = [ centroids.loc[a].distance(centroids.loc[b]) / SPEED_MSfor a, b in edges_contig.index]print(f"contig: {len(nodes_contig)} nodes, {len(edges_contig)} edges")print(f"pseudo travel time: median {edges_contig['travel_time'].median():.0f} s")
contig: 191 nodes, 689 edges
pseudo travel time: median 188 s
4 · Relation 2: 15_min_walk
Street network, zone↔︎intersection bridge, and projection by cost with a budget of 900 s. Identical to notebook 17, with zones instead of POIs.
_, zone_bridges = c2g.bridge_nodes( {"oa": zones_pt[["geometry"]], "connector": connectors}, proximity_method="knn", k=1)bridge_key = ("oa", "is_nearby", "connector")bridge = zone_bridges[bridge_key].copy()bridge["travel_time"] = bridge.geometry.length / SPEED_MSnodes_walk = {"oa": zones_pt[["geometry"]], "connector": connectors}edges_walk = { ("connector", "is_connected_to", "connector"): segments[["travel_time", "geometry"]], bridge_key: bridge,}# add_metapaths_by_weight() mutates the dict it receives and returns that same# object, so note the keys first and pass a copy.WALK_KEYS =set(edges_walk)t0 = time.time()_, edges_walk_out = c2g.add_metapaths_by_weight( nodes=nodes_walk, edges=dict(edges_walk), weight="travel_time", threshold=THRESHOLD_S, endpoint_type="oa", new_relation_name="15_min_walk", directed=False)print(f"{time.time() - t0:.0f} s")walk_key =next(k for k in edges_walk_out if k notin WALK_KEYS)walk_edges = edges_walk_out[walk_key]print(f"{walk_key}: {len(walk_edges):,} edges")
0 s
('oa', '15_min_walk', 'oa'): 2,740 edges
5 · Relation 3: 15_min_multi
The same projection with the bus network added as a layer, minus the pairs already connected by 15_min_walk. That exclusion is what makes the relation mean what transport adds rather than what could already be walked.
conn = c2g.load_gtfs(GTFS_FEED)stops_gdf, bus_segments = c2g.travel_summary_graph( conn, start_time="06:00:00", end_time="10:00:00")conn.close()# The feed covers all of São Paulo.stops_m = stops_gdf.to_crs(METRIC_CRS)inside = stops_m[stops_m.within(study_area.buffer(ZONE_SIDE_M))]bus_segments_m = bus_segments.to_crs(METRIC_CRS)bus_segments_m = bus_segments_m[ bus_segments_m.index.get_level_values(0).isin(inside.index)& bus_segments_m.index.get_level_values(1).isin(inside.index)]area_stops = inside.loc[sorted(set(bus_segments_m.index.get_level_values(0))|set(bus_segments_m.index.get_level_values(1)))][["geometry"]]area_stops.index.name ="stop_id"print(f"{len(area_stops)} stops with service in the area")print(f"{len(bus_segments_m)} bus segments")check("there is a transit network in the area", len(bus_segments_m) >0)
273 stops with service in the area
293 bus segments
[PASS] there is a transit network in the area
True
The multimodal graph: zones, street connectors and bus stops, with zone↔︎connector, zone↔︎stop and stop↔︎connector bridges.
MULTI_KEYS =set(edges_multi)t0 = time.time()_, edges_multi_out = c2g.add_metapaths_by_weight( nodes=nodes_multi, edges=dict(edges_multi), weight="travel_time", threshold=THRESHOLD_S, endpoint_type="oa", new_relation_name="15_min_multi", directed=False)print(f"{time.time() - t0:.0f} s")multi_key =next(k for k in edges_multi_out if k notin MULTI_KEYS)multi_edges_all = edges_multi_out[multi_key]# Pairs already reachable on foot are not a transit result.walk_pairs = {tuple(sorted(p)) for p in walk_edges.index}mask = [tuple(sorted(p)) notin walk_pairs for p in multi_edges_all.index]multi_edges = multi_edges_all[mask]print(f"raw multimodal : {len(multi_edges_all):,}")print(f"already walkable : {len(multi_edges_all) -len(multi_edges):,}")print(f"contributed by bus : {len(multi_edges):,}")check("15_min_multi contributes new pairs", len(multi_edges) >0)
0 s
raw multimodal : 6,036
already walkable : 2,740
contributed by bus : 3,296
[PASS] 15_min_multi contributes new pairs
True
6 · The full heterogeneous graph
Three relations over the same set of nodes, the structure that becomes a HeteroData object once converted to tensors.
nodes
oa 191 Point
edges
oa -> contig -> oa 689
oa -> 15_min_walk -> oa 2740
oa -> 15_min_multi -> oa 3296
edges per relation:
contig 689
15_min_walk 2,740
15_min_multi 3,296
7 · Conversion to tensors: gdf_to_pyg()
Serializes the graph into a PyTorch Geometric HeteroData, keeping in the metadata the map from the original indices to the sequential integers PyG requires. That map is what allows the return trip with pyg_to_gdf().
if c2g.is_torch_available(): pyg_data = c2g.gdf_to_pyg( nodes=nodes_final, edges=edges_final, node_feature_cols={"oa": list(attributes.columns)})print(pyg_data)print("\nmetadata:", pyg_data.metadata()) zones_back, _ = c2g.pyg_to_gdf(pyg_data) check("pyg -> gdf recovers the original identifiers",list(zones_back["oa"].index) ==list(nodes_final["oa"].index))else:print("PyTorch is not available in this Blender Python.")print()print("Without torch you cannot call gdf_to_pyg() nor train a graph")print("autoencoder on this graph. What follows is PCA + K-Means, which")print("needs no torch and which is the natural reference: it says how much")print("the structure of the graph adds on top of the attributes alone.")print()print("To get torch inside Blender:")print(" /opt/blender/blender-5.2.0/5.2/python/bin/python3.13 -m pip install \\")print(" --target ~/.config/blender/5.2/extensions/.local/lib/python3.13/site-packages \\")print(" torch torch_geometric --index-url https://download.pytorch.org/whl/cpu")print(" (about 900 MB; the add-on will keep working just the same without them)")
PyTorch is not available in this Blender Python.
Without torch you cannot call gdf_to_pyg() nor train a graph
autoencoder on this graph. What follows is PCA + K-Means, which
needs no torch and which is the natural reference: it says how much
the structure of the graph adds on top of the attributes alone.
To get torch inside Blender:
/opt/blender/blender-5.2.0/5.2/python/bin/python3.13 -m pip install \
--target ~/.config/blender/5.2/extensions/.local/lib/python3.13/site-packages \
torch torch_geometric --index-url https://download.pytorch.org/whl/cpu
(about 900 MB; the add-on will keep working just the same without them)
8 · The baseline: PCA + K-Means
PCA over the scaled attribute matrix, then K-Means over the components. It uses none of the three relations, which is what makes it the reference to measure them against.
from sklearn.cluster import KMeansfrom sklearn.decomposition import PCAfrom sklearn.metrics import silhouette_scoreD_EMBEDDING =16# dimension of the embeddingZ = PCA(n_components=min(D_EMBEDDING, X.shape[1]), random_state=42).fit_transform(X)# Unit norm, or K-Means' Euclidean distance follows the magnitudes instead of# the directions.Z = Z / np.clip(np.linalg.norm(Z, axis=1, keepdims=True), 1e-12, None)results = []for k inrange(2, min(21, len(zones) //3)): labels_k = KMeans(n_clusters=k, n_init=10, random_state=42).fit_predict(Z) results.append((k, silhouette_score(Z, labels_k), labels_k))best_k, best_sil, best_labels =max(results, key=lambda r: r[1])print(f"best k = {best_k}, silhouette = {best_sil:.3f}")
best k = 18, silhouette = 0.177
Modularity per relation
The second criterion treats the K-Means partition as a community detection and measures, per relation, how well the clusters align with that connectivity. That is what shows which relation a clustering has picked up.
import networkx as nxassignment =dict(zip(zones.index, best_labels))print(f"{'relation':<16}{'edges':>9}{'modularity':>12}")print("-"*40)modularities = {}for key, gdf in edges_final.items(): G_rel = nx.Graph() G_rel.add_nodes_from(zones.index) G_rel.add_edges_from([(a, b) for a, b in gdf.index if a in assignment and b in assignment]) communities = {}for node, label in assignment.items(): communities.setdefault(label, set()).add(node)try: q = nx.community.modularity(G_rel, list(communities.values()))except (ZeroDivisionError, nx.NetworkXError): q =float("nan") modularities[key[1]] = qprint(f"{key[1]:<16}{G_rel.number_of_edges():>9,}{q:>12.3f}")print("\nThe three values come out low, which is the expected result: a")print("clustering built from the attributes alone does not recover the")print("accessibility structure, because it never saw it.")
relation edges modularity
----------------------------------------
contig 689 0.087
15_min_walk 2,740 0.034
15_min_multi 3,296 -0.008
The three values come out low, which is the expected result: a
clustering built from the attributes alone does not recover the
accessibility structure, because it never saw it.
9 · Everything into Blender
Zones colored by cluster; the three relations stacked along Z for comparison.
sg.graphs.clear_scene(keep_anchor=False)anchor = sg.graphs.anchor(CENTER[0], CENTER[1], scale=0.001, name="Anchor_CaseStudy")zones_cluster = zones_pt.copy()zones_cluster["cluster"] = best_labelszones_cluster["silhouette_k"] = best_kfor column in attributes.columns: zones_cluster[column] = attributes[column]objs_zones = sg.graphs.from_features(zones.assign(cluster=best_labels), name="Zones", ref=anchor, coll="C2G_CaseStudy")objects = {}for i, (key, gdf) inenumerate(edges_final.items()): obj = sg.graphs.from_gdf( zones_cluster, gdf, name=f"Relation_{key[1]}", ref=anchor, coll="C2G_CaseStudy", markers={"relation": key[1], "modularity": modularities.get(key[1], 0.0)})if obj isnotNone: obj.location.z += i *0.35 objects[key[1]] = obj sg.graphs.visualize(obj, node_size=0.012, edge_thickness=0.0015)print(f" {obj.name:<28}{obj.get('num_edges'):>7,} edges z={obj.location.z:.2f}")check("the three relations are in the scene", len(objects) ==3,f"{len(objects)}/3")
Using reference projection: center=(-23.550500, -46.633300), scale=0.001
Created 1 object(s) from GeoDataFrame with 191 features
Promoted 1 edge attribute(s) to point domain for GN propagation
Info: Geometry Nodes modifier added
Relation_contig 689 edges z=0.00
Promoted 1 edge attribute(s) to point domain for GN propagation
Info: Geometry Nodes modifier added
Relation_15_min_walk 2,740 edges z=0.35
Promoted 1 edge attribute(s) to point domain for GN propagation
Info: Geometry Nodes modifier added
Relation_15_min_multi 3,296 edges z=0.70
[PASS] the three relations are in the scene — 3/3
True
The three relations, thinned in the data
15_min_walk and 15_min_multi side by side, contig as the sparse reference. The accessibility relations are nearly complete within the area, so all three get the same backbone, the 3 cheapest edges per node: what changes between panels is structure, not density.
The thinning is in the data, via SciGraphs.api.thin, not on the GPU. sg.preview.backbone() sparsifies through scene.scigraphs_preview_backbone_*, read only where the add-on’s own renderer fills its buffers. The mesh is unchanged, num_edges is still the full count, and the Geometry Nodes path EEVEE draws through tubes every edge, so draw_eevee() with a GPU backbone set comes back a solid mat with nothing in the log. It ranks top-k-per-node in Python into an ordinary graph object, checked below edge for edge against what the GPU filter would have drawn. That check found two bugs.
The weight did not resolve. sg.preview.backbone(attribute="travel_time") names a GeoDataFrame column; the filter looks on the mesh, where _write_edge_attributes stored it as edge_travel_time. Missing, it fell back to weight; missing again, it returned None, and backbone_mask reads None as uniform weights. Uniform weights tie top-k everywhere, so the survivors were whichever edges the stable sort reached first: mesh order, drawn as a measurement. That is the vertical banding in the old 15_min_walk panel. simplify.edge_weight_candidates now tries the edge_-prefixed form too, so the bare column name resolves; the check below reports which name was read rather than trusting that it did.
And TOPK keeps the largest weight, on a travel time the slowest, so even with the name resolved it would keep each zone’s three least accessible neighbors. These panels use sense='low'; the check runs both.
top-3 per node on 'travel_time' (smallest first): 368 of 689 edges (53.4%), 45.2% of the total weight
Backbone_contig: 191 nodes, 368 edges
top-3 per node on 'travel_time' (smallest first): 364 of 2,740 edges (13.3%), 7.0% of the total weight
Backbone_15_min_walk: 191 nodes, 364 edges
top-3 per node on 'travel_time' (smallest first): 470 of 3,296 edges (14.3%), 11.1% of the total weight
Backbone_15_min_multi: 191 nodes, 470 edges
[PASS] the three backbones were materialized — 3/3
True
sg.thin.verify() reads the mesh of the unthinned relation, resolves the weight the way edge_weights_raw does and applies the rule, then compares that against the mesh of the copy built from the ranked GeoDataFrame, as unordered vertex pairs.
gpu_weight_attribute is the name the filter actually found. unprefixed_name_resolves should read True: it is the regression guard on the resolution fix, and a False there means the ranking has silently gone back to mesh order.
The tied at the cut count on contig is not a fault. Queen contiguity over a regular 250 m grid has exactly two travel times, 187.5 s orthogonal and 265.2 s diagonal, so a zone with four orthogonal neighbors has a four-way tie for three places and sort order decides. Nearly every zone is in that position, so contig’s backbone is a representative three neighbors, not a distinguished three. The accessibility relations, whose weights are real path costs, have no ties.
for name, thin_obj in backbones.items():print(f"{name}:") result = sg.thin.verify(thin_obj, objects[name], zones_cluster, edges_final[("oa", name, "oa")], weight="travel_time", k=3, sense='low') check(f"{name} backbone matches the GPU filter", result["identical"],f"{result['materialized_kept']:,} edges, "f"{result['only_in_gpu']} missing, "f"{result['only_in_materialized']} extra")
contig:
GPU filter would keep 368 of 689 mesh edges
materialized backbone has 368
identical edge sets: True
spot check: 40 nodes, 0 missing an edge strictly in their top 3 (37 tied at the cut, where the choice is the sort's and not the data's)
[PASS] contig backbone matches the GPU filter — 368 edges, 0 missing, 0 extra
15_min_walk:
GPU filter would keep 364 of 2,740 mesh edges
materialized backbone has 364
identical edge sets: True
spot check: 40 nodes, 0 missing an edge strictly in their top 3 (0 tied at the cut, where the choice is the sort's and not the data's)
[PASS] 15_min_walk backbone matches the GPU filter — 364 edges, 0 missing, 0 extra
15_min_multi:
GPU filter would keep 470 of 3,296 mesh edges
materialized backbone has 470
identical edge sets: True
spot check: 40 nodes, 0 missing an edge strictly in their top 3 (0 tied at the cut, where the choice is the sort's and not the data's)
[PASS] 15_min_multi backbone matches the GPU filter — 470 edges, 0 missing, 0 extra
And how much the sense costs, on the relation where a travel time means the most. These are two different graphs, not two drawings of one.
for sense, label in (('low', "cheapest 3 per zone"), ('high', "slowest 3")): _, report = sg.thin.top_k( zones_cluster, edges_final[("oa", "15_min_walk", "oa")], weight="travel_time", k=3, sense=sense, verbose=False)print(f" 15_min_walk, {label:<20}{report['kept']:>6,} edges, "f"{report['weight_frac'] *100:>5.1f}% of the total travel time")
15_min_walk, cheapest 3 per zone 364 edges, 7.0% of the total travel time
15_min_walk, slowest 3 385 edges, 19.0% of the total travel time
The panels. Notebooks 00 to 07 share the render style notebook 15 sets out, look='ink' on turbo straight down through an orthographic camera, so all three are the same plan of the same 3.6 km. The color is node_cluster, the K-Means label, on the nodes; the edges stay neutral, because the clustering belongs to the zones.
turbo is continuous and a K-Means label has no order, so k clusters are k samples of the ramp. At this run’s k the closest adjacent pair is 55/765 apart in turbo against 20/765 in the default viridis. turbo is the best of the five colormaps here and still not categorical, so only wide jumps read as “different”. No percentile clip either: a clip on a label folds the top cluster into the one below.
node_fraction=0.12 because the default 0.35 on a 250 m grid is an 87 m sphere, three to eight times the height of the buildings under it.
for name, thin_obj in backbones.items(): full = objects[name]print(f"{name}: {full.get('num_edges'):,} edges thinned to "f"{thin_obj.get('num_edges'):,}, "f"modularity {modularities.get(name, float('nan')):.3f}") nb.figure(thin_obj, f"renders/04_case_study/1_{name}", resolution=(900, 700), width=800, look='ink', color_attribute="node_cluster", node_fraction=0.12)# Kept in the file, since the panels above show them, but out of the renders# below, which frame on the full relations and the ground under them.for thin_obj in backbones.values(): thin_obj.hide_render =True thin_obj.hide_viewport =True
contig: 689 edges thinned to 368, modularity 0.087
Promoted 1 edge attribute(s) to point domain for GN propagation
Info: Geometry Nodes modifier added
Info: Attribute -> node_cluster · node_cluster -> turbo [log] [0 … 17] on Point
[PASS] 1_contig.png legible — 11.3% ink (healthy range 0.5–60%)
15_min_walk: 2,740 edges thinned to 364, modularity 0.034
Promoted 1 edge attribute(s) to point domain for GN propagation
Info: Geometry Nodes modifier added
Info: Attribute -> node_cluster · node_cluster -> turbo [log] [0 … 17] on Point
[PASS] 1_15_min_walk.png legible — 12.0% ink (healthy range 0.5–60%)
15_min_multi: 3,296 edges thinned to 470, modularity -0.008
Promoted 1 edge attribute(s) to point domain for GN propagation
Info: Geometry Nodes modifier added
Info: Attribute -> node_cluster · node_cluster -> turbo [log] [0 … 17] on Point
[PASS] 1_15_min_multi.png legible — 31.4% ink (healthy range 0.5–60%)
contig over the buildings that defined it
contig is the sparse one, queen adjacency between 250 m cells, so it needs no backbone and goes through EEVEE with the buildings under it. Those buildings are not decoration: section 1 kept a grid cell only if it intersected a footprint, so this layer decided which nodes the relation has.
terrain_source="flat" reaches no network. Central São Paulo has real relief, but the elevation fetch is opt-in per notebook; notebook 17 fetches a DEM. The context radius is 1.5x the study radius, for the framing reason notebook 13 gives; the footprints came at RADIUS_M and a wider ground cannot invent any.
No aerial imagery either, although add_context() will drape it and notebooks 04 and 05 do: a photograph would compete with a categorical color scale, which has no ordering to fall back on against a noisy background. The color is node_cluster, on the POINT domain.
Creating terrain mesh: 2x2 vertices
Terrain mesh created: 4 vertices, 1 faces
flat ground, 5400 x 5400 m (no elevation data)
26548 footprints -> 289228 faces, 525356 vertices
heights: 26153 from OSM tags (98.5%), the rest at 7.2 m (median of tagged)
context moved -0.00100 Blender units (-1.0 m) so the ground clears z=0 by 1 m
[PASS] Context_Ground aligned with Relation_contig — center off by 0.1041 BU (2.07% of the graph diagonal), covers 100.0% of it, 1.52x its width
[PASS] Context_Buildings aligned with Relation_contig — center off by 0.1057 BU (2.10% of the graph diagonal), covers 100.0% of it, 1.06x its width
real elevation: False
Context_Ground ground 5.400 x 5.400 x 0.000 BU source=flat real_elevation=False imagery=none
Context_Buildings buildings 3.779 x 3.774 x 0.170 BU 26548 footprints, 99% tagged, default 7.2 m (median of tagged)
isolate=False renders everything in the scene, and the scene holds the zone polygons and the other two relations stacked 350 and 700 m above this one. They step out for the render.
hidden =list(objs_zones) + [o for n, o in objects.items() if n !="contig"]for obj in hidden: obj.hide_render =Trueif"contig"in objects:print("node_cluster domain:", sg.render.attribute_domain(objects["contig"], "node_cluster"))# 0.12 for the reason given above. Ink ceiling lifted for the usual# overhead reason (notebook 13); the floor still bites. path = nb.render( objects["contig"], "renders/04_case_study/2_contig_over_buildings", resolution=(900, 700), isolate=False, look='ink', color_attribute="node_cluster", node_fraction=0.12, verbose=False) nb.show(path, width=800) nb.check_render(path, maximum=1.0)for obj in hidden: obj.hide_render =False
node_cluster domain: POINT
Promoted 1 edge attribute(s) to point domain for GN propagation
Info: Geometry Nodes modifier added
Info: Attribute -> node_cluster · node_cluster -> turbo [log] [0 … 17] on Point
[PASS] 2_contig_over_buildings.png legible — 20.1% ink (healthy range 0.5–100%)
if"contig"in objects: sg.graphs.color_by(objects["contig"], "node_cluster", colormap="turbo")sg.graphs.frame()print(f"Zones colored by cluster (k = {best_k}). The three stacked layers ""are contig (bottom), 15_min_walk and 15_min_multi (top).")
Info: Attribute -> node_cluster · node_cluster -> turbo [log] [0 … 17] on Point
Zones colored by cluster (k = 18). The three stacked layers are contig (bottom), 15_min_walk and 15_min_multi (top).
10 · Isochrones compared
From the same point, how far you get on foot and how far you get by bus.
G_walk = c2g.gdf_to_nx(nodes=connectors, edges=segments)iso_walk = c2g.create_isochrone( graph=G_walk, center_point=connectors.geometry.iloc[int(np.argmin([g.distance(center_m) for g in connectors.geometry]))], threshold=[300, 600, 900], edge_attr="travel_time")print("pedestrian isochrones (m2):")for threshold, area inzip([300, 600, 900], iso_walk.geometry.area):print(f" {threshold //60:>2} min {area:>12,.0f}")sg.graphs.from_features(iso_walk, name="Isochrones_Walk", ref=anchor, coll="C2G_CaseStudy")
pedestrian isochrones (m2):
5 min 241,450
10 min 1,103,941
15 min 2,803,227
Using reference projection: center=(-23.550500, -46.633300), scale=0.001
Created 1 object(s) from GeoDataFrame with 3 features
[bpy.data.objects['Isochrones_Walk']]
The multimodal one uses the three-layer graph. cut_edge_types exists for this: bus segments connect distant points, and a concave hull over them would claim everything between two stops, when you can only walk from each stop.
G_multi = c2g.gdf_to_nx(nodes=nodes_multi, edges=edges_multi)try: iso_multi = c2g.create_isochrone( graph=G_multi, center_point=zones_pt.geometry.iloc[int(np.argmin([g.distance(center_m) for g in zones_pt.geometry]))], threshold=[300, 600, 900], edge_attr="travel_time", cut_edge_types=[("bus_station", "is_next_to", "bus_station")])print("multimodal isochrones (m2):")for threshold, area inzip([300, 600, 900], iso_multi.geometry.area):print(f" {threshold //60:>2} min {area:>12,.0f}") sg.graphs.from_features(iso_multi, name="Isochrones_Multimodal", ref=anchor, coll="C2G_CaseStudy") ratio = iso_multi.geometry.area.iloc[-1] / iso_walk.geometry.area.iloc[-1]print(f"\nthe bus multiplies the area reachable in 15 min by {ratio:.1f}")exceptExceptionas exc:print("multimodal isochrone not available:", type(exc).__name__, exc)
multimodal isochrones (m2):
5 min 231,955
10 min 2,261,818
15 min 7,273,221
Using reference projection: center=(-23.550500, -46.633300), scale=0.001
Created 1 object(s) from GeoDataFrame with 3 features
the bus multiplies the area reachable in 15 min by 2.6
11 · Saving
sg.graphs.save_gdf(zones.assign(cluster=best_labels).join(attributes), OUTPUT_DIR /"zones_clusters.gpkg")for key, gdf in edges_final.items(): sg.graphs.save_gdf(gdf, OUTPUT_DIR /f"relation_{key[1]}.gpkg")attributes.to_csv(OUTPUT_DIR /"urban_function_attributes.csv")pd.DataFrame([{"k": k, "silhouette": s} for k, s, _ in results]).to_csv( OUTPUT_DIR /"silhouette_by_k.csv", index=False)print("written to", nb.rel(OUTPUT_DIR))for f insorted(OUTPUT_DIR.iterdir()):print(" ", f.name, f"{f.stat().st_size /1e3:.0f} kB")
12 · Swapping in real units, a real feed and richer POIs
The grid, the São Paulo feed and the OSM tags are each replaceable without touching the rest of the workflow. England and Wales, where all three are open:
Census units and population-weighted centroids (ONS, United Kingdom):
data = c2g.load_overture_data(bbox, types=["place", "land_use", "segment", "connector"])
Slower, since it reads remote GeoParquet, but its place categories are normalized, so the 16 POI classes come out of one column instead of the OSM tag rules in section 2.
For embeddings, examples/city2graph-case-study-main/ has graph-autoencoder code that runs in a normal Python environment with PyTorch, not inside Blender. gdf_to_pyg() / pyg_to_gdf() is the bridge; the embeddings come back as node columns to color by.
Rendering
Every figure here is EEVEE, straight down through an orthographic camera, colored by a number the notebook computed. Notebook 13 explains that path: Geometry Nodes glyphs, the three-sun rig, the legibility check. Each render is drawn right after the step that made its object, into notebooks/out/renders/.
The Vulkan warning no longer applies to anything here, but it is worth carrying if you switch a figure back to the SciGraphs engine: on Blender’s Vulkan backend, the default on Linux, gpu.state.point_size_set does nothing for the add-on shaders, so POINT and DISK nodes come out 1 pixel wide. Start Blender with --gpu-backend opengl for any SciGraphs-engine render you intend to look at. EEVEE is unaffected, and so are SPHERE impostors, which are real geometry.
Summary
Built: the 23-dimensional urban function matrix, the three relations with the walk/multi exclusion, PCA + K-Means scored by silhouette and by modularity per relation, and pedestrian and multimodal isochrones. Left out: training a graph autoencoder (it needs PyTorch) and real census boundaries (section 12).