What we'll learn
- Calculate NDBI from Landsat 8 to identify built-up areas
- Apply thresholds to create an urban mask
- Visualize urban footprints with VIIRS nighttime lights
- Compare nighttime lights across time to detect urban growth
- Calculate urban area statistics using
ee.Image.pixelArea() - Build a combined NDVI-NDBI-NDWI classification for urban land use mapping
Why should we care?
Houston is one of the fastest-growing metropolitan areas in the United States, adding over one million residents between 2010 and 2023. That's a staggering amount of growth.
All those new people need somewhere to live, so agricultural land and wetlands get transformed into subdivisions, highways, and commercial centers. Think of it like watching a drop of ink spread across a wet paper towel, except the ink is concrete and asphalt.
Tracking this expansion with satellite data helps planners manage infrastructure, assess flood risk (and Houston knows a thing or two about floods), and protect remaining green spaces. The best part? The techniques we practice here apply to any rapidly urbanizing city in the world.
Before We Dive In
- Prerequisites: Spectral Indices module, Urban Analysis module
- Estimated time: 45 minutes
- You will need: GEE Code Editor access
Key terms
- NDBI
- Normalized Difference Built-up Index: (SWIR1 - NIR) / (SWIR1 + NIR). Highlights impervious surfaces like roads, rooftops, and parking lots.
- Urban mask
- A binary image where 1 indicates built-up pixels and 0 indicates non-built-up pixels. Think of it like a stencil that reveals only the city.
- VIIRS DNB
- Visible Infrared Imaging Radiometer Suite Day/Night Band, measuring nighttime light radiance in nanoWatts per square centimeter per steradian. Essentially, it photographs city lights from orbit.
- Pixel area
- The ground area represented by each pixel, calculated with
ee.Image.pixelArea()in square meters.
Lab Instructions
Part 1: Can we see concrete from space?
Let's find out! We'll load Landsat 8 imagery, apply the scale factors, and calculate NDBI to highlight built-up surfaces across the Houston metro area.
NDBI works a lot like NDVI, but instead of highlighting vegetation, it highlights impervious surfaces. Concrete, asphalt, and rooftops reflect more shortwave infrared light than near-infrared, and NDBI exploits that difference.
// ===== PART 1: Calculate NDBI =====
// Define the Houston metro study area
var houstonCenter = ee.Geometry.Point([-95.37, 29.76]);
var roi = ee.Geometry.Rectangle([-95.8, 29.5, -95.0, 30.0]);
// Load Landsat 8 SR, summer 2023
var l8 = ee.ImageCollection('LANDSAT/LC08/C02/T1_L2')
.filterDate('2023-06-01', '2023-08-31')
.filterBounds(roi)
.filter(ee.Filter.lt('CLOUD_COVER', 20))
.median();
// Apply Landsat Collection 2 scale factors
var scaled = l8.multiply(0.0000275).add(-0.2);
// Calculate NDBI = (SWIR1 - NIR) / (SWIR1 + NIR)
// Landsat 8: SWIR1 = SR_B6, NIR = SR_B5
var ndbi = scaled.normalizedDifference(['SR_B6', 'SR_B5']).rename('NDBI');
// Visualize NDBI
Map.addLayer(ndbi.clip(roi), {
min: -0.5, max: 0.5,
palette: ['blue', 'white', 'red']
}, 'NDBI - Houston');
Map.centerObject(houstonCenter, 10);
print('NDBI range: -1 (vegetation) to +1 (built-up)');
Let's break that down:
.normalizedDifference(['SR_B6', 'SR_B5'])computes (B6 - B5) / (B6 + B5), which is the NDBI formula. Same math we used for NDVI, just different bands!.multiply(0.0000275).add(-0.2)is the required scale/offset for Landsat Collection 2 surface reflectance- Red pixels have high NDBI (built-up); blue pixels have low NDBI (vegetation). The city should pop right out.
What you should see
A map of the Houston metro area where downtown, the Energy Corridor, and major highway interchanges glow red. Residential neighborhoods appear in lighter tones. Buffalo Bayou, parks, and surrounding agricultural areas show up blue. It's like a heat map of urbanization!
Part 2: Drawing the line between city and countryside
Now let's turn our continuous NDBI image into a simple yes-or-no question: is this pixel built-up or not? We do this by applying a threshold. It's like setting a cutoff grade on an exam.
// ===== PART 2: Threshold NDBI to create urban mask =====
// Pixels with NDBI > 0 are classified as built-up
var urbanMask = ndbi.gt(0).rename('urban');
// Self-mask to show only built-up pixels
Map.addLayer(urbanMask.selfMask().clip(roi), {
palette: ['red']
}, 'Urban Mask (NDBI > 0)');
// Also try a stricter threshold
var urbanStrict = ndbi.gt(0.1).rename('urban_strict');
Map.addLayer(urbanStrict.selfMask().clip(roi), {
palette: ['darkred']
}, 'Urban Mask (NDBI > 0.1)');
print('Compare the two thresholds by toggling layers on/off');
Let's break that down:
.gt(0)creates a binary image where 1 = NDBI above threshold, 0 = below. Simple and elegant..selfMask()makes 0 pixels transparent so only built-up areas are visible on the map- A threshold of 0 captures most built-up areas but may include some bare soil; 0.1 is stricter but may miss low-density residential areas. Toggle both layers on and off to see the difference!
Part 3: What does a city look like at night?
Here's where it gets really interesting. Let's look at Houston from a completely different perspective: nighttime lights from the VIIRS satellite.
Think of it this way: NDBI tells us where the concrete is, but nighttime lights tell us where the people are. Lights reveal economic activity and population density in a way that spectral indices simply can't.
// ===== PART 3: VIIRS Nighttime Lights =====
// Load VIIRS monthly composite for June 2023
var ntl2023 = ee.ImageCollection('NOAA/VIIRS/DNB/MONTHLY_V1/VCMSLCFG')
.filterDate('2023-06-01', '2023-06-30')
.first()
.select('avg_rad');
// Visualize with a night-sky color palette
Map.addLayer(ntl2023.clip(roi), {
min: 0, max: 80,
palette: ['black', 'navy', 'blue', 'purple', 'magenta', 'orange', 'yellow', 'white']
}, 'Nighttime Lights - 2023');
// Print statistics
var ntlStats = ntl2023.reduceRegion({
reducer: ee.Reducer.minMax(),
geometry: roi,
scale: 500,
maxPixels: 1e9
});
print('Nighttime light range (nW/cm2/sr):', ntlStats);
What you should see
A dark background with Houston glowing brightly, like a constellation on Earth. The urban core (downtown, Galleria, Medical Center) appears white or yellow, surrounded by concentric rings of decreasing brightness. Look closely and you can even trace the highway corridors (I-10, I-45, US-290) as bright lines radiating outward. Pretty cool, right?
Part 4: Where did the city grow in the last decade?
Now let's use nighttime lights as a time machine. We'll load data from 2014, subtract it from 2023, and see exactly where Houston has been expanding. Areas that got brighter over the decade are areas that urbanized.
// ===== PART 4: Nighttime Light Change Detection =====
// Load 2014 nighttime lights
var ntl2014 = ee.ImageCollection('NOAA/VIIRS/DNB/MONTHLY_V1/VCMSLCFG')
.filterDate('2014-06-01', '2014-06-30')
.first()
.select('avg_rad');
// Calculate change (positive = brighter = growth)
var ntlChange = ntl2023.subtract(ntl2014).rename('NTL_Change');
// Visualize change
Map.addLayer(ntlChange.clip(roi), {
min: -10, max: 40,
palette: ['blue', 'lightblue', 'white', 'orange', 'red']
}, 'NTL Change (2014-2023)');
// Add side-by-side comparison layers
Map.addLayer(ntl2014.clip(roi), {
min: 0, max: 80,
palette: ['black', 'yellow', 'white']
}, 'NTL 2014');
Map.addLayer(ntl2023.clip(roi), {
min: 0, max: 80,
palette: ['black', 'yellow', 'white']
}, 'NTL 2023');
print('Red = areas that became brighter (urban growth)');
print('Blue = areas that became dimmer (rare in growing cities)');
What you should see
A change map showing red and orange tones in areas of new development, particularly along the Katy Freeway (I-10 west), in Cypress, in the Sugar Land area, and north of The Woodlands. Notice how the urban core shows modest change because it was already bright in 2014. The growth is happening at the edges, like a ripple spreading outward.
Part 5: How much did the city actually grow?
Maps are great, but sometimes we need a number. Let's quantify the urban footprint by calculating the total area of built-up pixels for both years. This is where ee.Image.pixelArea() becomes our best friend.
// ===== PART 5: Urban Area Statistics =====
// Define "urban" as nighttime light radiance > 5 nW/cm2/sr
var urban2014 = ntl2014.gt(5).rename('urban_2014');
var urban2023 = ntl2023.gt(5).rename('urban_2023');
// Calculate area of urban pixels using pixelArea()
var urbanArea2014 = urban2014.multiply(ee.Image.pixelArea())
.reduceRegion({
reducer: ee.Reducer.sum(),
geometry: roi,
scale: 500,
maxPixels: 1e9
});
var urbanArea2023 = urban2023.multiply(ee.Image.pixelArea())
.reduceRegion({
reducer: ee.Reducer.sum(),
geometry: roi,
scale: 500,
maxPixels: 1e9
});
// Print results (values are in square meters)
print('=== Urban Area Statistics ===');
print('Urban area 2014 (sq m):', urbanArea2014);
print('Urban area 2023 (sq m):', urbanArea2023);
// Calculate total ROI area for context
var totalArea = ee.Image.pixelArea().reduceRegion({
reducer: ee.Reducer.sum(),
geometry: roi,
scale: 500,
maxPixels: 1e9
});
print('Total ROI area (sq m):', totalArea);
// Visualize both urban extents
Map.addLayer(urban2014.selfMask().clip(roi), {palette: ['yellow']}, 'Urban Extent 2014');
Map.addLayer(urban2023.selfMask().clip(roi), {palette: ['red']}, 'Urban Extent 2023');
Let's break that down:
ee.Image.pixelArea()creates an image where each pixel's value equals its ground area in square meters. Think of it like stamping a tiny measuring tape onto every pixel.- Multiplying the binary urban mask by pixel area gives the area of only the urban pixels (non-urban pixels become zero)
ee.Reducer.sum()adds up all those pixel areas to get total urban area- Divide by 1,000,000 to convert square meters to square kilometers for more interpretable numbers
Part 6: Putting it all together with a land use map
Here's the cool part. We can create a simple but powerful land use classification by combining three spectral indices we already know: NDVI, NDBI, and NDWI. Each one is like a specialist detector, and together, they separate Houston's landscape into water, vegetation, and built-up categories.
// ===== PART 6: Combined Index Classification =====
// Calculate NDVI and NDWI from the same Landsat image
var ndvi = scaled.normalizedDifference(['SR_B5', 'SR_B4']).rename('NDVI');
var ndwi = scaled.normalizedDifference(['SR_B3', 'SR_B5']).rename('NDWI');
// Classification rules:
// Water: NDWI > 0
// Vegetation: NDVI > 0.3 (and not water)
// Built-up: NDBI > 0 (and not water or vegetation)
// Other: everything else
var water = ndwi.gt(0);
var vegetation = ndvi.gt(0.3).and(water.not());
var builtUp = ndbi.gt(0).and(water.not()).and(vegetation.not());
// Create classified image
var classification = ee.Image(0)
.where(water, 1) // 1 = Water (blue)
.where(vegetation, 2) // 2 = Vegetation (green)
.where(builtUp, 3) // 3 = Built-up (red)
.selfMask()
.clip(roi);
Map.addLayer(classification, {
min: 1, max: 3,
palette: ['#2196F3', '#4CAF50', '#F44336']
}, 'Land Use Classification');
// Calculate area for each class
var classAreas = ee.Image.pixelArea().addBands(classification)
.reduceRegion({
reducer: ee.Reducer.sum().group({
groupField: 1,
groupName: 'class'
}),
geometry: roi,
scale: 30,
maxPixels: 1e9
});
print('Area by class (sq m):', classAreas);
print('Class legend: 1=Water, 2=Vegetation, 3=Built-up');
What you should see
A beautiful three-color map where blue represents water bodies (Galveston Bay, reservoirs, bayous), green represents vegetation (parks, agricultural land, forests), and red represents built-up areas (the urban core, suburbs, commercial zones). Notice the clear pattern: built-up areas concentrate in the center with vegetation increasing toward the edges. That's the urban-to-rural gradient, visible from space!
Try it: Explore another city
Replace Houston's coordinates with a city you're curious about. Try a rapidly growing city like Phoenix, Austin, or Dubai, or compare with a slower-growing one like Detroit or Cleveland. How do the NDBI patterns and nighttime light changes differ? The contrast can be striking.
Quick self-check
- What Landsat 8 bands does NDBI use, and what wavelength regions do they cover?
- Why might a threshold of 0 for NDBI misclassify some pixels in arid regions? (Hint: think about what else reflects SWIR light.)
- What does
ee.Image.pixelArea()return, and what units does it use? - Why are nighttime lights useful for measuring urban growth, rather than relying on NDBI alone?
- In the combined classification, why is the order of rules (water first, then vegetation, then built-up) important?
Troubleshooting
Solution: Check that you applied scale factors (.multiply(0.0000275).add(-0.2)). Without scaling, all values cluster near zero after normalization and you lose all the contrast.
Solution: Increase the max visualization parameter. Some cities have radiance values above 60. Try max: 100 and see if the lights appear.
Solution: Make sure maxPixels: 1e9 is set in your reduceRegion call. Large study areas need this parameter to complete the computation. Don't worry, this is one of the most common gotchas in GEE.
Solution: Increase the NDBI threshold from 0 to 0.1 or 0.15. In humid regions like Houston, bare soil is less of an issue, but industrial areas may still appear brighter than expected.
Key Takeaways
- NDBI uses SWIR and NIR bands to highlight built-up surfaces, producing positive values for impervious areas
- VIIRS nighttime lights provide an independent measure of urbanization based on artificial light emissions, like a census taken from orbit
- Multi-temporal comparison of nighttime lights reveals where cities are expanding
ee.Image.pixelArea()enables quantitative area calculations from classified maps- Combining NDVI, NDBI, and NDWI creates a simple but effective urban land use classification
Common mistakes to watch for
- Using SR_B7 instead of SR_B6: NDBI requires SWIR1 (
SR_B6).SR_B7is SWIR2 and produces different results. Double-check your band names! - Comparing different seasons: Vegetation phenology affects NDBI values. Always compare the same month across years so you're measuring urban change, not seasonal change.
- Forgetting
maxPixels: Area calculations over large regions fail withoutmaxPixels: 1e9. We've all made this mistake at least once. - Not clipping to ROI: Without
.clip(roi), visualizations and statistics may include areas outside your study region.
Pro tips
- Use the JRC Global Human Settlement Layer (
JRC/GHSL/P2023A/GHS_BUILT_S) for validated, multi-epoch urban extent maps spanning 1975 to 2030 - For nighttime light comparisons, use the same month each year to control for seasonal lighting differences
- Convert square meters to square kilometers by dividing by 1,000,000 for more interpretable statistics
- Export your classification to Google Drive and import it into ArcGIS Pro for cartographic finishing
📋 Lab Submission
Subject: Lab 26 - Urban Analysis - [Your Name]
Include in your submission:
- Shareable GEE script link
- Screenshot of your NDBI urban mask for Houston
- Screenshot comparing nighttime lights 2014 vs 2023
- Area statistics table:
| Metric | Value |
|---|---|
| Urban area 2014 (sq km) | |
| Urban area 2023 (sq km) | |
| Change (sq km) | |
| Percent increase |
- Question 1: Where did Houston experience the most urban growth between 2014 and 2023? Describe the geographic pattern you observe in the nighttime light change map.
- Question 2: Why might the NDBI-based urban area estimate differ from the nighttime-lights-based estimate? Which method do you think is more reliable for measuring urban extent, and why?