Skip to content
← Back to Python for GIS: scripting, GDAL and spatial statistics
Coding for GIS · Part 20 of 22Intermediate· 16 min readSources

Final Project: A Scorecard for Bengaluru's Corporations with pandas, GeoPandas and GDAL

Bring pandas, GeoPandas and GDAL together in one real project: count hospitals, measure parks, lakes and roads, take zonal NDVI with GDAL, join everything into one scorecard, check it, and save it as a CSV, a GeoPackage, a GeoJSON and a map.

This is the last part of the series. It uses what you learned in the others, on one real question, with real data. There is no new tool to learn. The skill is joining the tools into something that works from the first line to the last.

The question: how do Bengaluru's five city corporations compare on hospitals, green space, roads and vegetation? The answer is a scorecard: one table with one row per corporation and one column per measure.

Each measure uses the tool that suits it best.

MeasureQuestionTool
Hospitals per 100 km²How many places tagged as hospitals, relative to the area?GeoPandas spatial join, then pandas
Park and lake shareHow much of each corporation is mapped as park or lake?GeoPandas overlay
Road length per km²How dense is the road network?GeoPandas overlay
Mean NDVIHow green does it look from space?GDAL rasterize and NumPy
The scorecardEverything in one placepandas
The outputsA CSV, a GeoPackage, a GeoJSON and a mappandas, GeoPandas, GDAL

You need Parts 7 to 13, and it helps if you did Part 19, where the NDVI came from. Use the Jupyter kernel that has GDAL, as in Part 12.

What you need

Download bengaluru_final_project.zip (9 MB) and unzip it next to your notebook, so that you have a folder called bengaluru_final.

FileWhat it is
corporations.gpkgThe five city corporations, with the share of park and lake in the column pct
osm.gpkgOpenStreetMap layers hospitals, parks, lakes and roads
ndvi_2025.tifNDVI from Landsat 8 on 29 March 2025, made in Part 19

All layers are in EPSG:32643, which is in metres. The data is from OpenStreetMap contributors and from USGS Landsat, which is in the public domain.

1. Load everything and check it

Before you calculate anything, load the data and look at it. Most mistakes in an analysis start with data that was never checked.

import numpy as np
import pandas as pd
import geopandas as gpd
import matplotlib.pyplot as plt
from osgeo import gdal

gdal.UseExceptions()          # report GDAL problems as Python errors

# Step 1: read the layers. gpd.read_file takes a layer name for a GeoPackage with several layers.
corp = gpd.read_file("bengaluru_final/corporations.gpkg", layer="corporations")
hospitals = gpd.read_file("bengaluru_final/osm.gpkg", layer="hospitals")
parks = gpd.read_file("bengaluru_final/osm.gpkg", layer="parks")
lakes = gpd.read_file("bengaluru_final/osm.gpkg", layer="lakes")
roads = gpd.read_file("bengaluru_final/osm.gpkg", layer="roads")

# Step 2: a small table of what we have: how many features, what shape, which CRS
layers = {"corporations": corp, "hospitals": hospitals, "parks": parks, "lakes": lakes, "roads": roads}
overview = pd.DataFrame({
    "features": {name: len(g) for name, g in layers.items()},
    "geometry": {name: g.geom_type.iloc[0] for name, g in layers.items()},
    "epsg": {name: g.crs.to_epsg() for name, g in layers.items()},
})
print(overview)

# Step 3: every layer must share one CRS, or the overlays below would be wrong
assert overview["epsg"].nunique() == 1, "the layers do not share a CRS"
print("All layers use EPSG:", overview["epsg"].iloc[0])
              features      geometry   epsg
corporations         5  MultiPolygon  32643
hospitals         1160         Point  32643
parks             1987  MultiPolygon  32643
lakes             1721  MultiPolygon  32643
roads            13890    LineString  32643
All layers use EPSG: 32643

The assert line is a safety check. If a layer were in a different coordinate system, the script would stop here with a clear message, and not produce a wrong scorecard further down.

A note about the hospitals. They are every point that OpenStreetMap tags as a hospital, and that includes small private hospitals and nursing homes, not only large public ones. A count of these is a count of mapped places, which depends on how well volunteers mapped an area. Keep that in mind when you read the numbers.

2. Hospitals per corporation

A spatial join gives each hospital the name of the corporation it is in. Then pandas counts them.

# Step 1: give each hospital the short name of the corporation that contains it.
# how="inner" drops hospitals that fall outside every corporation.
located = gpd.sjoin(hospitals, corp[["short", "geometry"]], how="inner", predicate="within")
print(len(hospitals), "hospitals,", len(located), "inside a corporation")

# Step 2: count them per corporation. groupby makes one group per name, and size counts the rows in each.
counts = located.groupby("short").size().rename("hospitals")

# Step 3: start the scorecard. One row per corporation, with the short name as its index (its row label).
scorecard = corp[["short", "name", "km2"]].set_index("short")

# Step 4: add the counts. join matches the rows by index. A corporation with no hospital would get NaN, so fill with 0.
scorecard = scorecard.join(counts)
scorecard["hospitals"] = scorecard["hospitals"].fillna(0).astype(int)

# Step 5: a fair comparison needs a rate, not a count, because the corporations differ in size
scorecard["hospitals_per_100km2"] = (scorecard["hospitals"] / scorecard["km2"] * 100).round(1)
print(scorecard[["km2", "hospitals", "hospitals_per_100km2"]])
1160 hospitals, 1116 inside a corporation
           km2  hospitals  hospitals_per_100km2
short                                          
Central   77.9        255                 327.3
East     163.7        138                  84.3
North    159.8        175                 109.5
South    152.3        211                 138.5
West     163.6        337                 206.0

Compare the counts with the rates. West has the most hospitals, 337, but Central has the highest rate: 255 hospitals in only 77.9 km² is 327 per 100 km². A bigger corporation has more hospitals just by being bigger, and the rate removes that. This is why the scorecard uses per 100 km² and not the raw count. The km2 column comes from the corporation layer, which was measured in a metre-based CRS.

3. Parks, lakes and roads with GeoPandas

For the green share, we want the area of each corporation that is covered by a park or a lake.

# Step 1: put the parks and the lakes into one layer, and merge all their shapes into one shape.
# Parks and lakes overlap each other, and the merge makes sure that no area is counted twice.
green = pd.concat([parks[["geometry"]], lakes[["geometry"]]])
green_shape = green.geometry.union_all()

# Step 2: how much of each corporation lies inside that shape? intersection cuts the corporation by it.
inside = corp.geometry.intersection(green_shape)

# Step 3: area in km2 (the CRS is in metres, so areas are in m2), then the share of each corporation
scorecard["green_km2"] = (inside.area.values / 1_000_000).round(2)
scorecard["green_pct"] = (inside.area.values / corp.geometry.area.values * 100).round(2)

# Step 4: a check. The corporations layer already has a column with this number, calculated in QGIS.
check = scorecard[["green_pct"]].join(corp.set_index("short")["pct"])
check["difference"] = (check["green_pct"] - check["pct"]).round(2)
print(check)
         green_pct   pct  difference
short                               
Central       5.63  5.63         0.0
East          6.67  6.67         0.0
North         4.75  4.75         0.0
South         4.73  4.73         0.0
West          3.20  3.20         0.0

The last step is a check against an independent source. The column pct was calculated in QGIS for the map series, and our Python result should match it. A difference of 0.00 everywhere means that the method is right. If it did not match, we would have a bug to find before going any further.

Now the roads: the length of road inside each corporation.

# Step 1: cut the road lines at the corporation borders and keep the name of the corporation on each piece
pieces = gpd.overlay(roads[["geometry"]], corp[["short", "geometry"]], how="intersection")

# Step 2: length of each piece in km (the CRS is in metres), then add the pieces up per corporation
pieces["km"] = pieces.geometry.length / 1000
road_km = pieces.groupby("short")["km"].sum().rename("road_km")

# Step 3: add to the scorecard, with road density: km of road per km2 of corporation
scorecard = scorecard.join(road_km)
scorecard["road_km"] = scorecard["road_km"].round(0)
scorecard["road_km_per_km2"] = (scorecard["road_km"] / scorecard["km2"]).round(1)
print(scorecard[["km2", "road_km", "road_km_per_km2"]])
           km2  road_km  road_km_per_km2
short                                   
Central   77.9    418.0              5.4
East     163.7    453.0              2.8
North    159.8    563.0              3.5
South    152.3    629.0              4.1
West     163.6    755.0              4.6

overlay with intersection cuts lines at polygon borders and tags each piece with the polygon it falls in. A road that crosses from one corporation to another is split in two, and each part is counted where it belongs. length on a line gives its length in the CRS units, which are metres here.

4. Mean NDVI with GDAL

In Part 19, rasterio took the zonal statistics. Here GDAL does the same job in another way, as a check that two tools agree. The idea: turn the corporations into a raster with the same cells as the NDVI, where each cell holds the number of the corporation it is in. Then, for each number, take the NDVI cells that carry it.

# Step 1: open the NDVI raster with GDAL and read its values. GeoTransform tells us where the grid sits.
ndvi_ds = gdal.Open("bengaluru_final/ndvi_2025.tif")
ndvi = ndvi_ds.GetRasterBand(1).ReadAsArray()
gt = ndvi_ds.GetGeoTransform()                 # (left, cell width, 0, top, 0, minus cell height)
left, top = gt[0], gt[3]
right = left + gt[1] * ndvi_ds.RasterXSize
bottom = top + gt[5] * ndvi_ds.RasterYSize
print("NDVI grid:", ndvi.shape, "| cell size:", gt[1], "m")

# Step 2: give each corporation a number (1 to 5) and save the shapes to a small file for GDAL
zones = corp[["short", "geometry"]].copy()
zones["zone_id"] = range(1, len(zones) + 1)
zones.to_file("zones.gpkg", layer="zones", driver="GPKG")

# Step 3: Rasterize burns the zone number into every cell inside each shape.
# outputBounds and the cell size copy the NDVI grid exactly, so cell [row, column] means the same place in both rasters.
gdal.Rasterize(
    "zones.tif", "zones.gpkg",
    attribute="zone_id",
    outputBounds=[left, bottom, right, top],        # min x, min y, max x, max y
    xRes=gt[1], yRes=-gt[5],                        # cell size, as positive numbers
    outputType=gdal.GDT_Int16,
    noData=0,                                       # cells outside every corporation
    outputSRS="EPSG:32643",
)
zones_ds = gdal.Open("zones.tif")           # keep the dataset in a variable. If you chain gdal.Open(...).GetRasterBand(...)
zone_grid = zones_ds.GetRasterBand(1).ReadAsArray()   # in one line, Python can close the file too early and the read fails
print("zone grid:", zone_grid.shape, "| same shape as NDVI:", zone_grid.shape == ndvi.shape)
NDVI grid: (1274, 1304) | cell size: 30.0 m
zone grid: (1274, 1304) | same shape as NDVI: True

The shape check is the important one. If the two grids did not match cell for cell, the statistics would silently mix up places. GDAL gives us full control, as you can see: we chose the bounds and the cell size ourselves.

# Step 4: for each zone number, take the NDVI cells in that zone, ignoring cells without a value (NaN)
mean_ndvi = {}
for _, row in zones.iterrows():
    in_zone = (zone_grid == row["zone_id"]) & ~np.isnan(ndvi)
    mean_ndvi[row["short"]] = round(float(ndvi[in_zone].mean()), 3)

scorecard["mean_ndvi"] = pd.Series(mean_ndvi)
print(scorecard[["mean_ndvi"]])
         mean_ndvi
short             
Central      0.357
East         0.313
North        0.334
South        0.321
West         0.316

Part 19 gave 0.357, 0.313, 0.334, 0.321 and 0.316 for Central, East, North, South and West, and GDAL gives exactly the same numbers. Two different tools, two different ways of deciding which cells belong to a polygon, one answer. That is reassuring. On other data a tiny difference is possible, because the tools may treat cells on a polygon's edge slightly differently.

5. The scorecard

Everything is in one table now. Look at it, and rank it.

# Step 1: choose and order the columns, and rename them so that they read well
card = scorecard[["name", "km2", "hospitals", "hospitals_per_100km2", "green_pct", "road_km_per_km2", "mean_ndvi"]].reset_index()
card = card.rename(columns={"short": "corporation"})
print(card.to_string(index=False))

# Step 2: rank each measure (1 = highest value)
for column in ["hospitals_per_100km2", "green_pct", "road_km_per_km2", "mean_ndvi"]:
    card["rank_" + column] = card[column].rank(ascending=False).astype(int)
print(card[["corporation"] + [c for c in card.columns if c.startswith("rank_")]].to_string(index=False))
corporation                               name   km2  hospitals  hospitals_per_100km2  green_pct  road_km_per_km2  mean_ndvi
    Central Bengaluru Central City Corporation  77.9        255                 327.3       5.63              5.4      0.357
       East    Bengaluru East City Corporation 163.7        138                  84.3       6.67              2.8      0.313
      North   Bengaluru North City Corporation 159.8        175                 109.5       4.75              3.5      0.334
      South   Bengaluru South City Corporation 152.3        211                 138.5       4.73              4.1      0.321
       West    Bengaluru West City Corporation 163.6        337                 206.0       3.20              4.6      0.316
corporation  rank_hospitals_per_100km2  rank_green_pct  rank_road_km_per_km2  rank_mean_ndvi
    Central                          1               2                     1               1
       East                          5               1                     5               5
      North                          4               3                     4               2
      South                          3               4                     3               3
       West                          2               5                     2               4

Read the ranks across a row. Central is first on hospital rate, road density and NDVI. East is last on those same three, yet it is first on park and lake share. The hospital rate and the road density give exactly the same order, which makes sense: both are high where the city is dense and built up. The green share goes the other way. So no corporation is first on everything, and each measure tells a different part of the story.

Be careful with a single "overall score" that adds the ranks together. It would treat a hospital and a tree as equal, and it would hide the very differences that the table shows.

6. Check the scorecard

Before you save, test what you have. This is the habit that separates a result from a guess.

# Step 1: a function with the rules that a correct scorecard must follow
def check_scorecard(table):
    problems = []
    if len(table) != 5:
        problems.append(f"expected 5 corporations, found {len(table)}")
    if table.isna().any().any():
        problems.append("the table has missing values")
    if not table["green_pct"].between(0, 100).all():
        problems.append("a green share is outside 0 to 100")
    if not table["mean_ndvi"].between(-1, 1).all():
        problems.append("a mean NDVI is outside -1 to 1")
    if table["hospitals"].sum() > len(hospitals):
        problems.append("more hospitals than were in the data")
    return problems

# Step 2: run it. An empty list means that every rule passed.
problems = check_scorecard(card)
print("problems:", problems if problems else "none")
problems: none

Each rule is one line, and each would catch a real mistake: a lost row, a failed join, a wrong unit, a double count. When you build your own project, think of such rules as you go, and add them here.

7. Save the results in four formats

One result, four uses.

# Step 1: a CSV for people who use Excel. pandas writes it.
card.to_csv("scorecard.csv", index=False)

# Step 2: a GeoPackage for GIS software. Put the shapes back onto the table, then GeoPandas writes it.
card_geo = corp[["short", "geometry"]].rename(columns={"short": "corporation"}).merge(card, on="corporation")
card_geo.to_file("scorecard.gpkg", layer="scorecard", driver="GPKG")

# Step 3: a GeoJSON for web maps. GDAL's VectorTranslate converts the GeoPackage and, as the web expects, reprojects to longitude and latitude.
gdal.VectorTranslate("scorecard.geojson", "scorecard.gpkg", format="GeoJSON", dstSRS="EPSG:4326")

# Step 4: read each file back, as a check that it was written and has all its rows
print("csv rows:    ", len(pd.read_csv("scorecard.csv")))
print("gpkg rows:   ", len(gpd.read_file("scorecard.gpkg", layer="scorecard")))
print("geojson rows:", len(gpd.read_file("scorecard.geojson")), "| CRS:", gpd.read_file("scorecard.geojson").crs.to_epsg())
csv rows:     5
gpkg rows:    5
geojson rows: 5 | CRS: 4326

The GeoPackage keeps the original coordinate system. The GeoJSON is in EPSG:4326, so it is ready for web mapping libraries. You chose the right tool for each file: pandas for the table, GeoPandas for the GeoPackage, and GDAL for the format conversion with a new CRS.

And a map.

# Step 5: four maps in a 2 x 2 grid, one for each measure, with the corporation names on them
measures = [
    ("hospitals_per_100km2", "Hospitals per 100 km2", "Blues"),
    ("green_pct", "Park and lake share (%)", "Greens"),
    ("road_km_per_km2", "Road km per km2", "Oranges"),
    ("mean_ndvi", "Mean NDVI", "YlGn"),
]
fig, axes = plt.subplots(2, 2, figsize=(11, 9))
for ax, (column, title, colours) in zip(axes.ravel(), measures):      # ravel turns the 2 x 2 grid into a flat list
    card_geo.plot(ax=ax, column=column, cmap=colours, edgecolor="white", legend=True, legend_kwds={"shrink": 0.7})
    for _, row in card_geo.iterrows():
        p = row.geometry.representative_point()
        ax.annotate(row["corporation"], (p.x, p.y), ha="center", fontsize=8)
    ax.set_title(title, fontsize=11)
    ax.set_axis_off()
fig.savefig("scorecard_maps.png", dpi=130, bbox_inches="tight")
print("saved")
saved
Four small maps of the five Bengaluru corporations, one each for hospitals per 100 square kilometres, park and lake share, road kilometres per square kilometre and mean NDVI, each with its own colour scale and the corporation names
Figure 20.1. The scorecard as four maps.

8. What the scorecard can and cannot say

Write this part yourself every time. A table is not a conclusion.

  • What it shows. How the five corporations differ on four measures, using one set of rules for all of them.
  • What it does not show. Quality. A hospital count says nothing about beds or doctors. A green share says nothing about whether people can reach and use the green space.
  • Where the data is weak. OpenStreetMap mapping effort differs from place to place, and the hospital points include very small facilities. NDVI is from one day in the dry season.
  • Five rows. With five corporations, any ranking is easy to over-read. The table is for orientation, and it is where you start asking why.

A good report puts these four lines next to the numbers.

Run this part as a notebook

The code on this page is also a Jupyter notebook, with the theory in text cells, the code, guided practice, an independent assignment and hidden solutions. Download the Part 20 notebook, put it next to the unzipped bengaluru_final folder, and run the cells from top to bottom. Use the kernel with GDAL.

Practice

  1. Add a fifth measure: the number of lakes (lakes) per corporation, with a spatial join. Which corporation has most per km²?
  2. Change the hospital measure to the distance from the centre of each corporation to its nearest hospital. Which tool do you need, and which CRS?
  3. Add a check to check_scorecard for the road density, such as that it stays between 1 and 40 km per km². Is there a value that surprises you?
  4. Do the project again for a different set of regions of your own, with your own layers.

Common mistakes

  • Starting to calculate before checking the data and the CRS.
  • Comparing counts instead of rates when the regions differ in size.
  • Counting an area twice because layers overlap. Merge them first.
  • Trusting a number that no second source or tool has confirmed.
  • Making a single score from unlike measures.
  • Writing the conclusion before looking at the data's weaknesses.
  • Saving one format and finding later that another was needed.

Key takeaways

You have reached the end of the series. The same pattern, load and check, calculate in small steps, compare, check again and save, works for almost any analysis. Part 17 shows how to connect it with QGIS, and the map layouts series shows how to turn a result like this into a finished map for each region.