Geospatial Analysis for Industrial Applications: A Production Guide with Python
A deep technical guide to geospatial analysis for industrial applications — GDAL, rasterio, PostGIS, coordinate systems, spatial indexing, and tile processing at scale.
Geospatial Analysis for Industrial Applications: A Production Guide with Python
Geospatial analysis sits at the intersection of mathematics, computer science, and domain expertise in a way that few other data science disciplines do. After nine years of applying geospatial techniques to problems ranging from precision forestry to urban planning to industrial asset monitoring, I’ve developed strong opinions about what works, what doesn’t, and what the tutorials always leave out.
This is not an introduction to GIS. This is a guide for data scientists who need to build production-grade geospatial pipelines that process terabytes of satellite imagery, manage spatial databases with billions of features, and deliver results that hold up to scrutiny from domain experts.
The Coordinate System Problem
Every geospatial project starts with coordinate reference systems (CRS), and most bugs trace back to getting them wrong. The problem is deceptively simple: the Earth is not flat, and every flat map is a compromise.
The EPSG Codes You Need to Know
- EPSG:4326 (WGS 84): The GPS standard. Latitude/longitude in degrees. Use for data exchange, APIs, and storage. Never use for distance or area calculations.
- EPSG:3857 (Web Mercator): Web maps standard. Distorts area massively at high latitudes. Use only for visualization.
- UTM Zones (EPSG:326XX / EPSG:327XX): Meter-based, minimal distortion within each 6° zone. Use for any calculation involving distance, area, or buffer operations.
The critical insight: always store in 4326, compute in a projected CRS appropriate for your region, and visualize in 3857. Mixing these up is the single most common source of incorrect results.
import rasterio
from rasterio.warp import calculate_default_transform, reproject, Resampling
from pyproj import Transformer
def reproject_raster(src_path: str, dst_path: str, dst_crs: str = "EPSG:32648"):
"""Reproject a raster to a target CRS."""
with rasterio.open(src_path) as src:
transform, width, height = calculate_default_transform(
src.crs, dst_crs, src.width, src.height, *src.bounds
)
kwargs = src.meta.copy()
kwargs.update({
"crs": dst_crs,
"transform": transform,
"width": width,
"height": height,
})
with rasterio.open(dst_path, "w", **kwargs) as dst:
for band_idx in range(1, src.count + 1):
reproject(
source=rasterio.band(src, band_idx),
destination=rasterio.band(dst, band_idx),
src_transform=src.transform,
src_crs=src.crs,
dst_transform=transform,
dst_crs=dst_crs,
resampling=Resampling.nearest, # categorical
# Use Resampling.bilinear for continuous data
)
The Datum Shift Trap
Two datasets can both claim to be in WGS 84 but use different datums or epochs. Tectonic plate movement means that coordinates in Indonesia shift ~2-3 cm/year relative to the ITRF frame. For centimeter-precision applications (surveying, infrastructure monitoring), ignoring datum shifts introduces real error.
from pyproj import Transformer
# Proper datum-aware transformation
transformer = Transformer.from_crs(
"EPSG:4326", # WGS 84
"EPSG:4979", # WGS 84 3D
always_xy=True,
)
For most industrial applications, the built-in EPSG transformations are sufficient. But if you’re doing surveying or precision agriculture, investigate the specific datum realizations used by your data sources.
GDAL and Rasterio: The Foundation
GDAL is the backbone of geospatial raster processing. Every tool you use — QGIS, Google Earth Engine, rasterio, rioxarray — is built on GDAL. Understanding it is non-optional.
Rasterio for Production Workflows
Rasterio provides a Pythonic interface to GDAL that handles most use cases elegantly:
import rasterio
from rasterio.windows import Window
import numpy as np
def process_large_raster_in_tiles(
src_path: str,
dst_path: str,
tile_size: int = 2048,
process_fn=None,
):
"""Process a raster too large to fit in memory, tile by tile."""
with rasterio.open(src_path) as src:
profile = src.profile.copy()
# Update dtype if processing changes it
profile.update(dtype="float32", count=1)
with rasterio.open(dst_path, "w", **profile) as dst:
for ji, window in src.block_windows(1):
data = src.read(window=window)
result = process_fn(data)
# Handle NoData
nodata = src.nodata
if nodata is not None:
mask = data[0] == nodata
result[0][mask] = nodata
dst.write(result, window=window)
Memory management is the key concern. A single Sentinel-2 tile at 10m resolution is ~1 GB. Processing 100 tiles for a region requires tile-by-tile processing with careful window management.
The Overviews Problem
When serving rasters via web services (GeoServer, Titiler, custom APIs), you need overviews (pyramids). Without them, loading a 50,000 x 50,000 pixel raster at zoom level 3 means reading the entire file and downsampling — catastrophically slow.
# Add overviews to a COG
gdaladdo -r average output.tif 2 4 8 16 32 64
# Or create a Cloud Optimized GeoTIFF from the start
gdal_translate input.tif output_cog.tif \
-of COG \
-co COMPRESS=LZW \
-co OVERVIEW_RESAMPLING=AVERAGE
Cloud Optimized GeoTIFFs (COGs) are the standard for a reason: they enable HTTP range requests, so clients can read only the tiles and overviews they need. Always create COGs for any raster that will be served over a network.
PostGIS: Spatial SQL at Scale
PostGIS turns PostgreSQL into a spatial database capable of operations that would be painful in pure Python. For vector data, it’s my default choice.
Spatial Indexing
PostGIS uses R-tree indexes via GiST. The index is critical — a spatial query without an index scans every row.
-- Create spatial index
CREATE INDEX idx_parcels_geom ON parcels USING GIST (geom);
-- Verify index usage with EXPLAIN
EXPLAIN ANALYZE
SELECT p.parcel_id, p.area_ha
FROM parcels p
JOIN protected_areas pa ON ST_Intersects(p.geom, pa.geom)
WHERE pa.name = 'National Forest Reserve';
Rule of thumb: If your table has more than 10,000 rows and you’re doing any spatial operation, you need a spatial index. Without it, queries that should take milliseconds take minutes.
Spatial Joins: The Performance Killer
Spatial joins are the most expensive operation in PostGIS. The naive approach — joining every geometry in table A against every geometry in table B — is O(n*m). The index makes this practical, but you still need to be smart:
-- BAD: Full spatial join without filtering
SELECT a.id, b.category
FROM polygons_a a, polygons_b b
WHERE ST_Intersects(a.geom, b.geom);
-- BETTER: Pre-filter with bounding box + add LIMIT for exploration
SELECT a.id, b.category
FROM polygons_a a, polygons_b b
WHERE a.geom && b.geom -- Bounding box intersection (uses index)
AND ST_Intersects(a.geom, b.geom); -- Precise check
-- BEST: Use ST_Subdivide for very large geometries
SELECT a.id, b.category
FROM polygons_a a
JOIN LATERAL (
SELECT category
FROM polygons_b b
WHERE ST_Intersects(a.geom, b.geom)
LIMIT 100
) b ON true;
The && operator performs a bounding box intersection check that is extremely fast with the GiST index. Adding it as a pre-filter before the precise ST_Intersects check can speed up queries by 10-100x.
Spatial Aggregation
PostGIS provides spatial aggregation functions that are invaluable for industrial applications:
-- Aggregate point observations into heatmap clusters
SELECT
ST_ClusterDBSCAN(geom, eps := 500, minpoints := 5) OVER () AS cluster_id,
COUNT(*) as point_count,
ST_Centroid(ST_Collect(geom)) as cluster_center,
AVG(value) as mean_value
FROM sensor_observations
WHERE observation_date = '2026-07-01'
GROUP BY cluster_id;
DBSCAN clustering in PostGIS is one of those features that saves you from pulling millions of points into Python, clustering there, and writing results back. Let the database do what databases are good at.
Tile Processing Architecture
For large-scale raster processing, the tile-based architecture is the only approach that scales. The core idea: divide the spatial extent into regular tiles, process each independently, and merge results.
Tile Grid Design
import geopandas as gpd
from shapely.geometry import box
def create_tile_grid(
bounds: tuple[float, float, float, float],
tile_size_m: float = 10000,
crs: str = "EPSG:32648",
) -> gpd.GeoDataFrame:
"""Create a regular tile grid for processing."""
minx, miny, maxx, maxy = bounds
tiles = []
x = minx
while x < maxx:
y = miny
while y < maxy:
tiles.append({
"geometry": box(x, y, x + tile_size_m, y + tile_size_m),
"tile_id": f"x{int(x)}_y{int(y)}",
})
y += tile_size_m
x += tile_size_m
return gpd.GeoDataFrame(tiles, crs=crs)
Tile size selection is a critical design decision. Too small (100m), and you have excessive overhead from file I/O and edge artifacts. Too large (100km), and you lose the memory benefits of tile processing. For satellite imagery, I typically use tiles that match the source resolution: 10km x 10km for 10m Sentinel-2, 5km x 5km for 30m Landsat.
Edge Handling
Tiles share boundaries, and objects near edges create problems. A building that straddles two tiles gets split. A buffer operation near a tile edge produces incorrect geometry.
The standard solution is overlap: process each tile with a buffer zone, then clip results to the original tile boundary.
def process_tile_with_overlap(
tile_geom,
data_source,
overlap_m: float = 100,
process_fn=None,
):
"""Process a tile with overlap to handle edge effects."""
# Expand tile by overlap distance
buffered = tile_geom.buffer(overlap_m)
buffered_bounds = buffered.bounds
# Read data for the buffered region
data = data_source.read(bounds=buffered_bounds)
# Process
result = process_fn(data, buffered)
# Clip result to original tile boundary
clipped = gpd.clip(result, tile_geom)
return clipped
Parallelization Strategy
Tile processing is embarrassingly parallel — each tile is independent. The challenge is managing resources:
from concurrent.futures import ProcessPoolExecutor, as_completed
import psutil
def process_tiles_parallel(tiles, process_fn, max_workers=None):
"""Process tiles in parallel with resource awareness."""
if max_workers is None:
# Leave 2 cores free for system + main process
max_workers = max(1, psutil.cpu_count(logical=False) - 2)
results = []
with ProcessPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(process_fn, tile): tile
for tile in tiles
}
for future in as_completed(futures):
tile = futures[future]
try:
result = future.result(timeout=3600)
results.append(result)
except Exception as e:
logger.error(f"Tile {tile['tile_id']} failed: {e}")
# Continue processing other tiles — don't let one failure stop everything
return results
Use ProcessPoolExecutor, not ThreadPoolExecutor, for CPU-bound geospatial work. Python’s GIL means threads don’t help with NumPy/GDAL operations. For I/O-bound operations (downloading tiles, database queries), threads are fine.
Remote Sensing Data Processing
Sentinel-2 Processing Pipeline
Sentinel-2 is the workhorse of industrial remote sensing. Free, 10m resolution, 5-day revisit. But the data needs significant preprocessing:
import rioxarray
import xarray as xr
def preprocess_sentinel2(
safe_path: str,
bands: list[str] = ["B02", "B03", "B04", "B08"],
target_resolution: int = 10,
) -> xr.Dataset:
"""Preprocess Sentinel-2 Level-2A data."""
datasets = []
for band in bands:
band_path = find_band_path(safe_path, band)
ds = rioxarray.open_rasterio(band_path)
# Resample to target resolution if needed
if ds.rio.resolution()[0] != target_resolution:
ds = ds.rio.reproject(
ds.rio.crs,
resolution=target_resolution,
resampling=Resampling.bilinear,
)
datasets.append(ds)
# Stack bands
stacked = xr.concat(datasets, dim="band")
stacked = stacked.assign_coords(band=bands)
# Apply QA60 cloud mask
qa_path = find_band_path(safe_path, "QA60")
qa = rioxarray.open_rasterio(qa_path)
cloud_mask = (qa & 0x3000).astype(bool) # Bits 10-11
return stacked.where(~cloud_mask)
Cloud masking is the most impactful preprocessing step. An unmasked cloud pixel looks like a bright surface and will corrupt any analysis — NDVI calculations, land cover classification, change detection. QA60 is the quick approach; for production, consider more sophisticated methods like s2cloudless or Fmask.
Spectral Indices
NDVI is the most common spectral index, but industrial applications often need more specialized indices:
def calculate_indices(dataset: xr.Dataset) -> xr.Dataset:
"""Calculate spectral indices from Sentinel-2 bands."""
b02 = dataset.sel(band="B02").astype(float) # Blue
b03 = dataset.sel(band="B03").astype(float) # Green
b04 = dataset.sel(band="B04").astype(float) # Red
b08 = dataset.sel(band="B08").astype(float) # NIR
b11 = dataset.sel(band="B11").astype(float) # SWIR1 (if available)
indices = {}
# NDVI — vegetation health
indices["ndvi"] = (b08 - b04) / (b08 + b04 + 1e-10)
# NDWI — water detection
indices["ndwi"] = (b03 - b08) / (b03 + b08 + 1e-10)
# NDBI — built-up area detection
indices["ndbi"] = (b11 - b08) / (b11 + b08 + 1e-10)
# BSI — bare soil index
indices["bsi"] = ((b11 + b04) - (b08 + b02)) / ((b11 + b04) + (b08 + b02) + 1e-10)
return indices
The 1e-10 epsilon in the denominator prevents division by zero. This is a small but important detail — without it, areas of no data produce NaN/Inf that propagate through your analysis.
Spatial Indexing for Performance
R-tree and Quad-tree Indexes
For applications that need to query millions of geometries, spatial indexing is the difference between interactive and unusable performance.
from rtree import index
import geopandas as gpd
def build_spatial_index(gdf: gpd.GeoDataFrame) -> index.Index:
"""Build an R-tree spatial index for fast spatial queries."""
idx = index.Index()
for i, row in gdf.iterrows():
idx.insert(i, row.geometry.bounds)
return idx
def find_nearest_features(
point, gdf: gpd.GeoDataFrame, spatial_index, k: int = 5
) -> gpd.GeoDataFrame:
"""Find k nearest features to a point using spatial index."""
# R-tree nearest neighbor
nearest_ids = list(spatial_index.nearest(point.bounds, k))
return gdf.iloc[nearest_ids]
H3 Hexagonal Indexing
Uber’s H3 hexagonal grid system is increasingly used for spatial analysis. It provides a hierarchical, uniform grid that avoids the shape distortion of latitude-longitude grids:
import h3
import pandas as pd
def points_to_h3(df: pd.DataFrame, resolution: int = 9) -> pd.DataFrame:
"""Assign H3 hexagon IDs to point observations."""
df["h3_id"] = df.apply(
lambda row: h3.latlng_to_cell(row["lat"], row["lon"], resolution),
axis=1,
)
return df
def aggregate_to_h3(df: pd.DataFrame, value_col: str, resolution: int = 9) -> pd.DataFrame:
"""Aggregate point data into H3 hexagons."""
df = points_to_h3(df, resolution)
return df.groupby("h3_id").agg(
count=(value_col, "count"),
mean_value=(value_col, "mean"),
std_value=(value_col, "std"),
).reset_index()
H3 resolution 9 gives hexagons of ~100m edge length — good for urban analysis. Resolution 7 (~500m) works for regional analysis. Resolution 12 (~30m) matches Sentinel-2 pixel size.
Production Considerations
Data Format Selection
| Format | Best For | Avoid When |
|---|---|---|
| COG (Cloud Optimized GeoTIFF) | Web serving, cloud storage | Very small files |
| GeoParquet | Vector analytics, column queries | Complex geometry operations |
| FlatGeobuf | Web transfer, streaming | Editing workflows |
| Zarr | Multi-dimensional arrays, time series | Simple 2D rasters |
| PostGIS | Complex spatial queries, transactions | Bulk raster storage |
GeoParquet is the format I reach for most often now. It combines the columnar efficiency of Parquet with native geometry support, and it’s becoming the standard for cloud-native geospatial analytics.
Memory Management
Geospatial operations are memory-hungry. A single 10m Sentinel-2 band for a 100km x 100km area is 10,000 x 10,000 pixels = 400 MB as float32. Stack 10 bands and you’re at 4 GB. Add temporal analysis and you’re in trouble.
Strategies that work:
- Chunk-based processing with dask-geopandas or rioxarray with dask
- Windowed reads — never read more than you need
- Downcast dtypes — int16 for Sentinel-2 raw values, float32 for indices (not float64)
- Explicit cleanup — delete large arrays when done, don’t rely on garbage collection
Conclusion
Geospatial analysis for industrial applications demands a combination of mathematical precision, engineering discipline, and domain understanding. The tools — GDAL, rasterio, PostGIS, H3 — are mature and powerful, but they require careful application.
The most important lesson from nine years of geospatial work: coordinate systems matter more than algorithms. A sophisticated analysis with the wrong CRS produces beautifully visualized garbage. Get the fundamentals right first — CRS, resolution, extent, NoData handling — and the advanced techniques become straightforward.
Production geospatial pipelines are inherently resource-intensive. Design for memory constraints from the start, use tile-based processing, leverage spatial indexes, and choose the right data format for your access patterns. The investment in proper architecture pays dividends when your pipeline needs to scale from a pilot region to a continent.