gcp_name = "GCP-01"
gcp_x = 512340.25
gcp_y = 3891220.10
gcp_elevation = 812.4
print(gcp_name, "is located at elevation", gcp_elevation, "meters")GCP-01 is located at elevation 812.4 meters
Warm-up before we touch a single raster or shapefile
Before we open a single satellite image or elevation model, we need to make sure everyone speaks the same Python language. This session is not about GIS itself. It is about the small set of Python tools that every single session in this course will lean on. Nothing here is random. Every example was picked because you will see the exact same pattern again later, when you calculate NDVI, slope, camera parameters, or filter road networks.
Read the explanation, run the example, then try the exercise before looking at the solution. Solutions are hidden inside a collapsible box. Click to reveal them only after you have tried on your own.
In spatial work, almost everything is a bunch of numbers with a label attached to it: pixel values, band names, coordinate systems, ground control point coordinates, weights used in a model. Lists and dictionaries are the two basic Python structures used to organize that kind of information.
A variable is simply a name that points to a value. The value can be a number, text, or something more complex. Let’s start with something familiar from surveying: a single ground control point (GCP) used in photogrammetry to align an aerial photo with real world coordinates.
gcp_name = "GCP-01"
gcp_x = 512340.25
gcp_y = 3891220.10
gcp_elevation = 812.4
print(gcp_name, "is located at elevation", gcp_elevation, "meters")GCP-01 is located at elevation 812.4 meters
# Example 2: a camera parameter used in photogrammetry
focal_length_mm = 35
sensor_width_mm = 23.5
image_width_px = 6000
# Ground sample distance depends on all three parameters
flying_height_m = 1200
gsd_cm = (sensor_width_mm * flying_height_m * 100) / (focal_length_mm * image_width_px)
print("Ground sample distance:", round(gsd_cm, 2), "cm/pixel")Ground sample distance: 13.43 cm/pixel
A list stores an ordered collection of values. This is the natural way to represent a series of readings, such as elevation values collected at several ground control points, or NDVI values from a set of field plots.
# Elevation readings (in meters) from 6 ground control points
elevations = [812, 790, 845, 803, 860, 798]
print("Number of GCPs:", len(elevations))
print("Highest point:", max(elevations))
print("Lowest point:", min(elevations))
print("Total:", sum(elevations))Number of GCPs: 6
Highest point: 860
Lowest point: 790
Total: 4908
Lists come with several built-in methods that you will use constantly. Below are the ones that matter most for this course.
band_names = ["red", "nir"]
# add a single item
band_names.append("swir")
print(band_names)
# add several items at once
band_names.extend(["blue", "green"])
print(band_names)
# remove a specific value
band_names.remove("swir")
print(band_names)
# sort alphabetically
band_names.sort()
print(band_names)
# check whether a value exists
print("red" in band_names)
# find the position of a value
print(band_names.index("nir"))['red', 'nir', 'swir']
['red', 'nir', 'swir', 'blue', 'green']
['red', 'nir', 'blue', 'green']
['blue', 'green', 'nir', 'red']
True
2
# Slicing: getting a subset of a list
flight_line_ids = ["L01", "L02", "L03", "L04", "L05", "L06"]
print(flight_line_ids[0]) # first item
print(flight_line_ids[-1]) # last item
print(flight_line_ids[1:4]) # items 1 to 3
print(flight_line_ids[::2]) # every second itemL01
L06
['L02', 'L03', 'L04']
['L01', 'L03', 'L05']
elevations = [812, 790, 845, 803, 860, 798]
for index, value in enumerate(elevations):
print(f"GCP {index + 1}: {value} m")GCP 1: 812 m
GCP 2: 790 m
GCP 3: 845 m
GCP 4: 803 m
GCP 5: 860 m
GCP 6: 798 m
# A second example, from remote sensing: cloud cover percentage per scene
cloud_cover = [12, 45, 3, 67, 8, 30]
scene_ids = ["S01", "S02", "S03", "S04", "S05", "S06"]
usable_scenes = []
for scene_id, cover in zip(scene_ids, cloud_cover):
if cover < 20:
usable_scenes.append(scene_id)
print("Usable scenes (cloud cover below 20 percent):", usable_scenes)Usable scenes (cloud cover below 20 percent): ['S01', 'S03', 'S05']
A dictionary stores key and value pairs. Instead of creating several separate variables, a dictionary keeps related information together under one name. In Session 3, rasterio will return raster metadata in exactly this format.
raster_metadata = {
"filename": "sentinel.tif",
"width": 512,
"height": 512,
"crs": "EPSG:32639",
"bands": 4
}
print("This raster uses CRS:", raster_metadata["crs"])
print("Dimensions:", raster_metadata["width"], "x", raster_metadata["height"])This raster uses CRS: EPSG:32639
Dimensions: 512 x 512
# Dictionary methods that matter most
print(raster_metadata.keys())
print(raster_metadata.values())
print(raster_metadata.items())
# safely get a value that might not exist
print(raster_metadata.get("nodata", "not specified"))
# update or add a key
raster_metadata["nodata"] = -9999
print(raster_metadata)dict_keys(['filename', 'width', 'height', 'crs', 'bands'])
dict_values(['sentinel.tif', 512, 512, 'EPSG:32639', 4])
dict_items([('filename', 'sentinel.tif'), ('width', 512), ('height', 512), ('crs', 'EPSG:32639'), ('bands', 4)])
not specified
{'filename': 'sentinel.tif', 'width': 512, 'height': 512, 'crs': 'EPSG:32639', 'bands': 4, 'nodata': -9999}
# A nested dictionary example: metadata for several photogrammetry cameras
cameras = {
"camera_A": {"focal_length_mm": 35, "sensor_width_mm": 23.5},
"camera_B": {"focal_length_mm": 50, "sensor_width_mm": 36.0}
}
for camera_name, specs in cameras.items():
print(f"{camera_name}: focal length {specs['focal_length_mm']} mm")camera_A: focal length 35 mm
camera_B: focal length 50 mm
Use a list when order matters and items are of the same kind, such as a series of elevation readings. Use a dictionary when you need to label each value, such as raster metadata or camera specifications.
Create a dictionary called image_info with keys sensor, resolution_m, and cloud_cover_percent. Fill in realistic values. Then print a sentence reporting the sensor name and its resolution using an f-string.
image_info = {
"sensor": "Sentinel-2",
"resolution_m": 10,
"cloud_cover_percent": 5
}
print(f"{image_info['sensor']} has a resolution of {image_info['resolution_m']} m")Sentinel-2 has a resolution of 10 m
Given elevations = [812, 790, 845, 803, 860, 798], write a loop that prints only the ground control points with elevation above 800 m, along with their index in the list.
elevations = [812, 790, 845, 803, 860, 798]
for index, value in enumerate(elevations):
if value > 800:
print(f"GCP {index + 1}: {value} m")GCP 1: 812 m
GCP 3: 845 m
GCP 4: 803 m
GCP 5: 860 m
Starting from band_names = ["blue", "green", "red"], add "nir" and "swir" using two different list methods, then sort the final list and print it.
band_names = ["blue", "green", "red"]
band_names.append("nir")
band_names.extend(["swir"])
band_names.sort()
print(band_names)['blue', 'green', 'nir', 'red', 'swir']
Which method would you use to safely retrieve a value from a dictionary without raising an error if the key does not exist?
A. dictionary.keys()
B. dictionary.get()
C. dictionary.values()
D. dictionary.items()
.get() method returns a default value instead of raising an error when the key is missing, which is exactly what raster_metadata.get("nodata", "not specified") did above.
Almost every session in this course ends with writing one function, such as calculate_ndvi, calculate_slope, or calculate_final_score. Becoming comfortable with functions now will remove a lot of confusion later.
def celsius_to_fahrenheit(celsius):
"""Convert a temperature from Celsius to Fahrenheit."""
fahrenheit = (celsius * 9 / 5) + 32
return fahrenheit
print(celsius_to_fahrenheit(21.5))70.7
def classify_elevation(elevation_m, threshold=800):
"""Return 'high' or 'low' depending on a threshold."""
if elevation_m > threshold:
return "high"
return "low"
print(classify_elevation(860))
print(classify_elevation(790))
print(classify_elevation(860, threshold=900))high
low
low
threshold=800 means use 800 unless the caller specifies otherwise. This exact pattern appears again in Session 8, where the suitability function has default weights that a student can later override.
def image_stats(pixel_values):
"""Return the minimum, maximum, and average of a list of pixel values."""
minimum = min(pixel_values)
maximum = max(pixel_values)
average = sum(pixel_values) / len(pixel_values)
return minimum, maximum, average
pixels = [120, 98, 145, 132, 110, 156]
low, high, avg = image_stats(pixels)
print("Min:", low, "Max:", high, "Average:", round(avg, 2))Min: 98 Max: 156 Average: 126.83
def dn_to_reflectance(dn_value, gain=0.0001, offset=0.0):
"""Convert a digital number to reflectance using a linear model."""
return dn_value * gain + offset
def classify_reflectance(reflectance, threshold=0.2):
"""Classify a reflectance value as vegetation or non-vegetation."""
if reflectance > threshold:
return "vegetation"
return "non-vegetation"
digital_number = 2500
reflectance_value = dn_to_reflectance(digital_number)
label = classify_reflectance(reflectance_value)
print(f"DN {digital_number} corresponds to reflectance {reflectance_value:.3f}, classified as {label}")DN 2500 corresponds to reflectance 0.250, classified as vegetation
def classify_all(elevation_list, threshold=800):
"""Classify every elevation value in a list."""
results = []
for value in elevation_list:
results.append(classify_elevation(value, threshold))
return results
elevations = [812, 790, 845, 803, 860, 798]
print(classify_all(elevations))['high', 'low', 'high', 'high', 'high', 'low']
Write a function meters_to_feet(meters) that converts meters to feet, using the fact that 1 meter equals 3.281 feet. Test it with the flying height used earlier in this session.
def meters_to_feet(meters):
"""Convert meters to feet."""
return meters * 3.281
print(meters_to_feet(1200))3937.2000000000003
Write a function average_elevation(elevation_list) that returns the average of a list of elevation values, then test it on the elevations list used above.
def average_elevation(elevation_list):
"""Return the average value of a list of numbers."""
return sum(elevation_list) / len(elevation_list)
elevations = [812, 790, 845, 803, 860, 798]
print(average_elevation(elevations))818.0
Write a function gsd_from_flight(focal_length_mm, sensor_width_mm, image_width_px, flying_height_m) that returns the ground sample distance in centimeters, based on the formula used at the start of this session. Test it with two different flying heights.
def gsd_from_flight(focal_length_mm, sensor_width_mm, image_width_px, flying_height_m):
"""Return ground sample distance in cm per pixel."""
return (sensor_width_mm * flying_height_m * 100) / (focal_length_mm * image_width_px)
print(gsd_from_flight(35, 23.5, 6000, 1200))
print(gsd_from_flight(35, 23.5, 6000, 800))13.428571428571429
8.952380952380953
What does a function return if there is no explicit return statement inside it?
A. An error, always
B. None
C. 0
D. The last variable defined inside the function
None by default when there is no explicit return statement.
This is the single most important concept in this session. A raster, whether it is a satellite image or an elevation model, is nothing more than a large NumPy array of numbers. Every calculation performed in later sessions, including NDVI, slope, aspect, and the final suitability score, is simply NumPy math applied to arrays.
elevations = [812, 790, 845, 803, 860, 798]
# converting every value to feet forces a manual loop with a plain list
elevations_ft = []
for value in elevations:
elevations_ft.append(value * 3.281)
print(elevations_ft)[2664.172, 2591.9900000000002, 2772.445, 2634.643, 2821.6600000000003, 2618.2380000000003]
import numpy as np
elevations = np.array([812, 790, 845, 803, 860, 798])
# multiplication now applies to every value at once, no loop needed
elevations_ft = elevations * 3.281
print(elevations_ft)[2664.172 2591.99 2772.445 2634.643 2821.66 2618.238]
When an operation applies to a whole array at once, without an explicit loop, this is called a vectorized operation. This is not only shorter code, it is dramatically faster, which matters when the array is an image with millions of pixels.
zeros_array = np.zeros(5)
ones_array = np.ones(5)
sequence_array = np.arange(0, 50, 10)
even_spacing = np.linspace(0, 1, 5)
print("Zeros:", zeros_array)
print("Ones:", ones_array)
print("Arange:", sequence_array)
print("Linspace:", even_spacing)Zeros: [0. 0. 0. 0. 0.]
Ones: [1. 1. 1. 1. 1.]
Arange: [ 0 10 20 30 40]
Linspace: [0. 0.25 0.5 0.75 1. ]
These functions are commonly used to create placeholder arrays before filling them with real raster values, or to generate evenly spaced coordinates along a flight line.
elevations = np.array([812, 790, 845, 803, 860, 798])
# boolean mask: True or False for each value
above_800 = elevations > 800
print(above_800)
# use the mask to pull out the matching values
print(elevations[above_800])
# equivalent one line version
print(elevations[elevations > 800])[ True False True True True False]
[812 845 803 860]
[812 845 803 860]
In Session 5, code very similar to slope_array[slope_array < 10] will be used to find every pixel with a gentle enough slope for solar panel installation. It is the same pattern used above.
ndvi_values = np.array([0.1, 0.65, 0.72, 0.05, 0.55, 0.68])
print("Mean:", np.mean(ndvi_values))
print("Median:", np.median(ndvi_values))
print("Standard deviation:", np.std(ndvi_values))
print("Minimum:", np.min(ndvi_values))
print("Maximum:", np.max(ndvi_values))
print("Sum:", np.sum(ndvi_values))
print("Count above 0.5:", np.sum(ndvi_values > 0.5))Mean: 0.4583333333333334
Median: 0.6000000000000001
Standard deviation: 0.2762496857715659
Minimum: 0.05
Maximum: 0.72
Sum: 2.7500000000000004
Count above 0.5: 4
A raster is a grid of numbers, which in NumPy is represented as a two dimensional array. Imagine the grid below as a small elevation model.
dem = np.array([
[812, 815, 818, 820, 825],
[805, 808, 812, 818, 822],
[795, 798, 802, 808, 815],
[780, 785, 790, 796, 805],
[760, 770, 775, 782, 790]
])
print("Shape of this DEM:", dem.shape)
print("Value at row 2, column 3:", dem[2, 3])Shape of this DEM: (5, 5)
Value at row 2, column 3: 808
In NumPy, and in every raster used later, the first index is the row (the y direction, top to bottom) and the second index is the column (the x direction, left to right). This is one of the most common beginner mistakes, so keep it in mind starting from Session 3.
print(dem[0:2, 0:2]) # top-left 2x2 corner
print(dem[0, :]) # entire first row
print(dem[:, -1]) # entire last column
print(dem[-1, :]) # entire last row[[812 815]
[805 808]]
[812 815 818 820 825]
[825 822 815 805 790]
[760 770 775 782 790]
high_pixels = dem > 800
print(high_pixels)
print("Count of pixels above 800 m:", np.sum(high_pixels))
print("Mean elevation of the whole grid:", np.mean(dem))
print("Mean elevation of only the high pixels:", np.mean(dem[high_pixels]))[[ True True True True True]
[ True True True True True]
[False False True True True]
[False False False False True]
[False False False False False]]
Count of pixels above 800 m: 14
Mean elevation of the whole grid: 800.24
Mean elevation of only the high pixels: 813.2142857142857
flat_values = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9])
grid = flat_values.reshape(3, 3)
print(grid)
back_to_flat = grid.flatten()
print(back_to_flat)[[1 2 3]
[4 5 6]
[7 8 9]]
[1 2 3 4 5 6 7 8 9]
# A remote sensing example: combining two bands into one array
red_band = np.array([[80, 85], [90, 95]])
nir_band = np.array([[150, 160], [140, 130]])
stacked = np.stack([red_band, nir_band])
print("Stacked shape:", stacked.shape)
print(stacked)Stacked shape: (2, 2, 2)
[[[ 80 85]
[ 90 95]]
[[150 160]
[140 130]]]
Using the dem array defined above, find the minimum and maximum elevation in the grid, and count how many pixels have elevation below 790 m.
print("Min:", np.min(dem))
print("Max:", np.max(dem))
print("Pixels below 790:", np.sum(dem < 790))Min: 760
Max: 825
Pixels below 790: 6
Create a NumPy array from the NDVI values [0.12, 0.58, 0.61, 0.09, 0.70, 0.33]. Print only the values that represent healthy vegetation, using a threshold of 0.5, and print how many values passed the threshold.
ndvi = np.array([0.12, 0.58, 0.61, 0.09, 0.70, 0.33])
healthy = ndvi[ndvi > 0.5]
print(healthy)
print("Count:", len(healthy))[0.58 0.61 0.7 ]
Count: 3
Create a one dimensional array with np.arange containing the numbers from 0 to 15, reshape it into a 4 by 4 grid, then print only the last column of that grid.
values = np.arange(16)
grid = values.reshape(4, 4)
print(grid)
print(grid[:, -1])[[ 0 1 2 3]
[ 4 5 6 7]
[ 8 9 10 11]
[12 13 14 15]]
[ 3 7 11 15]
Why are NumPy arrays preferred over plain Python lists when working with raster data?
A. NumPy arrays use less disk space than lists
B. NumPy arrays support fast, vectorized math without explicit loops
C. Lists cannot store numbers, only text
D. NumPy arrays automatically carry a coordinate system
Starting from Session 1, the project will have a real folder structure, including data, data/outputs, and modules. A reliable way of building file paths is needed that works the same way on Windows, Mac, and Linux.
from pathlib import Path
data_folder = Path("data")
outputs_folder = data_folder / "outputs"
ndvi_path = outputs_folder / "ndvi.tif"
print(ndvi_path)
print("Does this file exist:", ndvi_path.exists())data/outputs/ndvi.tif
Does this file exist: True
The / symbol joins path pieces together when used with pathlib. It automatically uses the correct separator for the operating system, so the same code works on any computer.
image_path = Path("data/sentinel_scene_01.tif")
print("Name:", image_path.name)
print("Stem (name without extension):", image_path.stem)
print("Suffix (extension):", image_path.suffix)
print("Parent folder:", image_path.parent)
print("Is this an absolute path:", image_path.is_absolute())Name: sentinel_scene_01.tif
Stem (name without extension): sentinel_scene_01
Suffix (extension): .tif
Parent folder: data
Is this an absolute path: False
data_folder = Path("data")
if data_folder.exists():
for file in data_folder.glob("*.tif"):
print(file.name)
else:
print("Folder 'data' does not exist yet in this environment")dem.tif
sentinel.tif
outputs_folder = Path("data/outputs")
outputs_folder.mkdir(parents=True, exist_ok=True)
print("Folder ready:", outputs_folder)Folder ready: data/outputs
Without this argument, Python raises an error if the folder already exists. With it, Python continues without complaining, which is useful when a notebook is run more than once.
sample_paths = [
Path("data/sentinel.tif"),
Path("data/roads.shp"),
Path("data/notes.txt")
]
for path in sample_paths:
print(path.name, "->", path.suffix)sentinel.tif -> .tif
roads.shp -> .shp
notes.txt -> .txt
Using pathlib, build a path pointing to data/roads.shp without typing the slash character manually, then print its stem and suffix.
from pathlib import Path
roads_path = Path("data") / "roads.shp"
print(roads_path)
print(roads_path.stem)
print(roads_path.suffix)data/roads.shp
roads
.shp
Every session produces a .py file inside a modules folder, such as data_loader.py or raster_utils.py. By the end of the course, main.py and app.py import and combine all of them. This section practices the same pattern with something simple.
Save the code below in a file named unit_converter.py, in the same folder as this notebook.
# unit_converter.py
def meters_to_feet(meters):
"""Convert meters to feet."""
return meters * 3.281
def celsius_to_fahrenheit(celsius):
"""Convert Celsius to Fahrenheit."""
return (celsius * 9 / 5) + 32
def classify_elevation(elevation_m, threshold=800):
"""Return 'high' or 'low' depending on a threshold."""
if elevation_m > threshold:
return "high"
return "low"import unit_converter
print(unit_converter.meters_to_feet(1200))
print(unit_converter.celsius_to_fahrenheit(21.5))from unit_converter import meters_to_feet, classify_elevation
print(meters_to_feet(500))
print(classify_elevation(860))Python looks for unit_converter.py in the same folder as the notebook, or somewhere on its search path. If a ModuleNotFoundError appears, the most common cause is the file being in the wrong folder. Starting Session 1, this will be organized properly with a dedicated modules folder.
Add a new function dn_to_reflectance(dn_value, gain=0.0001, offset=0.0) into unit_converter.py, matching the one defined earlier in this session. Import it and test it with two different digital number values.
def dn_to_reflectance(dn_value, gain=0.0001, offset=0.0):
"""Convert a digital number to reflectance using a linear model."""
return dn_value * gain + offset
print(dn_to_reflectance(2500))
print(dn_to_reflectance(1800))0.25
0.18000000000000002
If the statement from unit_converter import meters_to_feet is used, how is the function called afterward?
A. unit_converter.meters_to_feet(500)
B. meters_to_feet(500)
C. import.meters_to_feet(500)
D. It cannot be called this way, this import style is only for classes
from module import function_name, it is called directly by name, with no module prefix needed. Option A would be correct only if import unit_converter had been used instead.
This session covered every Python tool the rest of the course will lean on: lists and dictionaries for storing metadata and small collections of values, functions as the shape every processing step will take, NumPy arrays as the real structure behind every raster, pathlib for navigating the project folders, and custom modules as the pattern the entire ten session project is built on.
A real satellite image will be opened with rasterio, showing for the first time that it really is just a NumPy array with extra geographic information attached.