Skip to content

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.

Aryanto, M.Si

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

FormatBest ForAvoid When
COG (Cloud Optimized GeoTIFF)Web serving, cloud storageVery small files
GeoParquetVector analytics, column queriesComplex geometry operations
FlatGeobufWeb transfer, streamingEditing workflows
ZarrMulti-dimensional arrays, time seriesSimple 2D rasters
PostGISComplex spatial queries, transactionsBulk 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:

  1. Chunk-based processing with dask-geopandas or rioxarray with dask
  2. Windowed reads — never read more than you need
  3. Downcast dtypes — int16 for Sentinel-2 raw values, float32 for indices (not float64)
  4. 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.