Skip to content

H3 Raster to Hex: Converting Geospatial Rasters to Hexagonal Grids

Objective: Master the conversion of raster data (satellite imagery, elevation models, climate data) to H3 hexagonal grids for efficient spatial analysis, aggregation, and visualization. Transform pixels into hexagons for better spatial reasoning and analysis.

Architecture

flowchart LR
    R[Raster Data] -->|GDAL/OGR| P[Python Processor]
    P -->|H3 Indexing| H[H3 Hex Grid]
    H -->|Aggregation| DB[Database Storage]
    DB -->|Query| V[Visualization]

    subgraph "Raster Sources"
        R1[Satellite Imagery]
        R2[Elevation Models]
        R3[Climate Data]
        R4[Population Density]
    end

    subgraph "H3 Processing"
        P
        H
        A[Aggregation Functions]
    end

    subgraph "Output Formats"
        DB
        G[GeoJSON]
        C[CSV]
        P2[Parquet]
    end

Why: H3 provides consistent hexagonal indexing for spatial data, enabling efficient aggregation, spatial joins, and hierarchical analysis. Converting rasters to H3 hexagons improves spatial reasoning and enables better data integration.

Docker Compose with Profiles

# docker-compose.yml
version: "3.9"
x-hc: &hc { interval: 5s, timeout: 3s, retries: 40 }

services:
  postgres:
    image: postgis/postgis:15-3.3
    environment:
      POSTGRES_DB: h3_raster
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports: ["5432:5432"]
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck: { test: ["CMD-SHELL", "pg_isready -U postgres"], <<: *hc }

  jupyter:
    image: jupyter/scipy-notebook:latest
    ports: ["8888:8888"]
    volumes:
      - ./notebooks:/home/jovyan/work
      - ./data:/home/jovyan/data
    environment:
      - JUPYTER_ENABLE_LAB=yes
    healthcheck: { test: ["CMD", "curl", "-f", "http://localhost:8888/health"], <<: *hc }

  # Raster processing service (profile)
  processor:
    build: ./processor
    profiles: ["processing"]
    environment:
      POSTGRES_URL: postgresql://postgres:postgres@postgres:5432/h3_raster
    volumes:
      - ./data:/data
      - ./output:/output
    depends_on:
      postgres: { condition: service_healthy }

  # Visualization service (profile)
  viz:
    build: ./viz
    profiles: ["viz"]
    ports: ["3000:3000"]
    environment:
      POSTGRES_URL: postgresql://postgres:postgres@postgres:5432/h3_raster
    depends_on:
      postgres: { condition: service_healthy }

volumes:
  postgres_data:

Operator Recipes

# Core H3 raster processing
docker compose up -d

# Add processing service
docker compose --profile processing up -d

# Add visualization
docker compose --profile viz up -d

Data Model & H3 Schema

H3 Hex Schema

-- Create H3 hex table
CREATE TABLE h3_hexes (
    h3_index TEXT PRIMARY KEY,
    resolution INTEGER NOT NULL,
    lat DOUBLE PRECISION NOT NULL,
    lon DOUBLE PRECISION NOT NULL,
    geom GEOMETRY(POLYGON, 4326) NOT NULL,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

-- Create raster data table
CREATE TABLE raster_data (
    id SERIAL PRIMARY KEY,
    h3_index TEXT NOT NULL,
    resolution INTEGER NOT NULL,
    value DOUBLE PRECISION,
    data_type VARCHAR(50),
    source VARCHAR(100),
    timestamp TIMESTAMPTZ,
    metadata JSONB,
    FOREIGN KEY (h3_index) REFERENCES h3_hexes(h3_index)
);

-- Create indexes
CREATE INDEX idx_h3_hexes_resolution ON h3_hexes(resolution);
CREATE INDEX idx_h3_hexes_geom ON h3_hexes USING GIST(geom);
CREATE INDEX idx_raster_data_h3 ON raster_data(h3_index);
CREATE INDEX idx_raster_data_type ON raster_data(data_type);
CREATE INDEX idx_raster_data_timestamp ON raster_data(timestamp);

Python Raster to H3 Processor

processor/Dockerfile

FROM python:3.11-slim

RUN apt-get update && apt-get install -y \
    gdal-bin \
    libgdal-dev \
    && rm -rf /var/lib/apt/lists/*

RUN pip install rasterio geopandas h3 pandas numpy sqlalchemy psycopg2-binary

WORKDIR /app
COPY processor.py .

CMD ["python", "processor.py"]

processor/processor.py

import os
import h3
import rasterio
import geopandas as gpd
import pandas as pd
import numpy as np
from shapely.geometry import Polygon
from sqlalchemy import create_engine, text
import json
from typing import List, Dict, Tuple
import logging

class RasterToH3Processor:
    def __init__(self, postgres_url: str):
        self.engine = create_engine(postgres_url)
        self.setup_logging()

    def setup_logging(self):
        """Setup logging."""
        logging.basicConfig(level=logging.INFO)
        self.logger = logging.getLogger(__name__)

    def create_h3_hexes(self, bounds: Tuple[float, float, float, float], 
                       resolution: int) -> List[Dict]:
        """Create H3 hexagons for given bounds and resolution."""
        min_lat, min_lon, max_lat, max_lon = bounds

        # Get all H3 hexagons in bounds
        hexes = []

        # Calculate step size for sampling
        lat_step = (max_lat - min_lat) / 100
        lon_step = (max_lon - min_lon) / 100

        for lat in np.arange(min_lat, max_lat, lat_step):
            for lon in np.arange(min_lon, max_lon, lon_step):
                h3_index = h3.latlng_to_cell(lat, lon, resolution)

                if h3_index not in [h['h3_index'] for h in hexes]:
                    # Get hexagon boundary
                    hex_boundary = h3.cell_to_boundary(h3_index)

                    # Create polygon
                    polygon = Polygon([(lon, lat) for lat, lon in hex_boundary])

                    # Get center coordinates
                    center_lat, center_lon = h3.cell_to_latlng(h3_index)

                    hexes.append({
                        'h3_index': h3_index,
                        'resolution': resolution,
                        'lat': center_lat,
                        'lon': center_lon,
                        'geom': polygon.wkt
                    })

        return hexes

    def process_raster_to_h3(self, raster_path: str, data_type: str, 
                            resolution: int = 9) -> None:
        """Process raster data and convert to H3 hexagons."""
        self.logger.info(f"Processing raster: {raster_path}")

        # Open raster
        with rasterio.open(raster_path) as src:
            # Get raster bounds
            bounds = src.bounds
            transform = src.transform

            # Read raster data
            data = src.read(1)  # Read first band

            # Create H3 hexagons for bounds
            hexes = self.create_h3_hexes(bounds, resolution)

            # Store hexes in database
            self.store_hexes(hexes)

            # Process raster values for each hex
            raster_values = self.aggregate_raster_to_hexes(data, transform, hexes)

            # Store raster data
            self.store_raster_data(raster_values, data_type, raster_path)

    def aggregate_raster_to_hexes(self, data: np.ndarray, transform, 
                                hexes: List[Dict]) -> List[Dict]:
        """Aggregate raster values to H3 hexagons."""
        values = []

        for hex_info in hexes:
            h3_index = hex_info['h3_index']

            # Get hexagon boundary
            hex_boundary = h3.cell_to_boundary(h3_index)

            # Convert to raster coordinates
            raster_coords = []
            for lat, lon in hex_boundary:
                row, col = rasterio.transform.rowcol(transform, lon, lat)
                if 0 <= row < data.shape[0] and 0 <= col < data.shape[1]:
                    raster_coords.append((row, col))

            if raster_coords:
                # Create mask for hexagon
                mask = self.create_hex_mask(raster_coords, data.shape)

                # Extract values within hexagon
                hex_values = data[mask]

                # Calculate statistics
                if len(hex_values) > 0:
                    stats = {
                        'h3_index': h3_index,
                        'value': np.mean(hex_values),
                        'min_value': np.min(hex_values),
                        'max_value': np.max(hex_values),
                        'std_value': np.std(hex_values),
                        'count': len(hex_values)
                    }
                    values.append(stats)

        return values

    def create_hex_mask(self, coords: List[Tuple[int, int]], 
                       shape: Tuple[int, int]) -> np.ndarray:
        """Create mask for hexagon in raster."""
        mask = np.zeros(shape, dtype=bool)

        # Simple polygon filling (in practice, use more sophisticated method)
        for row, col in coords:
            if 0 <= row < shape[0] and 0 <= col < shape[1]:
                mask[row, col] = True

        return mask

    def store_hexes(self, hexes: List[Dict]) -> None:
        """Store H3 hexagons in database."""
        with self.engine.connect() as conn:
            for hex_info in hexes:
                conn.execute(text("""
                    INSERT INTO h3_hexes (h3_index, resolution, lat, lon, geom)
                    VALUES (:h3_index, :resolution, :lat, :lon, ST_GeomFromText(:geom, 4326))
                    ON CONFLICT (h3_index) DO NOTHING
                """), hex_info)
            conn.commit()

    def store_raster_data(self, values: List[Dict], data_type: str, 
                         source: str) -> None:
        """Store raster data in database."""
        with self.engine.connect() as conn:
            for value_info in values:
                conn.execute(text("""
                    INSERT INTO raster_data (h3_index, resolution, value, data_type, source, metadata)
                    VALUES (:h3_index, :resolution, :value, :data_type, :source, :metadata)
                """), {
                    'h3_index': value_info['h3_index'],
                    'resolution': 9,  # Default resolution
                    'value': value_info['value'],
                    'data_type': data_type,
                    'source': source,
                    'metadata': json.dumps({
                        'min_value': value_info['min_value'],
                        'max_value': value_info['max_value'],
                        'std_value': value_info['std_value'],
                        'count': value_info['count']
                    })
                })
            conn.commit()

    def process_multiple_rasters(self, raster_configs: List[Dict]) -> None:
        """Process multiple raster files."""
        for config in raster_configs:
            try:
                self.process_raster_to_h3(
                    config['path'],
                    config['data_type'],
                    config.get('resolution', 9)
                )
                self.logger.info(f"Processed {config['path']}")
            except Exception as e:
                self.logger.error(f"Error processing {config['path']}: {e}")

# Example usage
if __name__ == "__main__":
    processor = RasterToH3Processor(
        postgres_url=os.getenv("POSTGRES_URL", "postgresql://postgres:postgres@localhost:5432/h3_raster")
    )

    # Process sample rasters
    raster_configs = [
        {
            'path': '/data/elevation.tif',
            'data_type': 'elevation',
            'resolution': 9
        },
        {
            'path': '/data/ndvi.tif',
            'data_type': 'ndvi',
            'resolution': 9
        },
        {
            'path': '/data/temperature.tif',
            'data_type': 'temperature',
            'resolution': 8
        }
    ]

    processor.process_multiple_rasters(raster_configs)

H3 Aggregation Functions

SQL Aggregation Queries

-- H3 hex aggregation by resolution
SELECT 
    resolution,
    COUNT(*) as hex_count,
    AVG(value) as avg_value,
    MIN(value) as min_value,
    MAX(value) as max_value
FROM raster_data rd
JOIN h3_hexes hh ON rd.h3_index = hh.h3_index
GROUP BY resolution
ORDER BY resolution;

-- H3 hex statistics by data type
SELECT 
    data_type,
    COUNT(DISTINCT h3_index) as unique_hexes,
    AVG(value) as avg_value,
    STDDEV(value) as std_value,
    PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY value) as median_value
FROM raster_data
GROUP BY data_type
ORDER BY data_type;

-- H3 hex spatial clustering
SELECT 
    h3_index,
    lat,
    lon,
    value,
    ST_AsText(geom) as geometry
FROM raster_data rd
JOIN h3_hexes hh ON rd.h3_index = hh.h3_index
WHERE rd.data_type = 'elevation'
ORDER BY value DESC
LIMIT 100;

-- H3 hex density analysis
SELECT 
    h3_index,
    COUNT(*) as data_points,
    AVG(value) as avg_value,
    STDDEV(value) as std_value
FROM raster_data
GROUP BY h3_index
HAVING COUNT(*) > 1
ORDER BY data_points DESC;

H3 Resolution Analysis

Python H3 Resolution Analysis

# notebooks/h3_resolution_analysis.ipynb
import h3
import pandas as pd
import geopandas as gpd
import matplotlib.pyplot as plt
import seaborn as sns
from sqlalchemy import create_engine

def analyze_h3_resolutions(postgres_url: str):
    """Analyze H3 data across different resolutions."""
    engine = create_engine(postgres_url)

    # Get data for each resolution
    resolutions = [6, 7, 8, 9, 10, 11]
    results = []

    for res in resolutions:
        query = f"""
        SELECT 
            COUNT(*) as hex_count,
            AVG(value) as avg_value,
            STDDEV(value) as std_value,
            MIN(value) as min_value,
            MAX(value) as max_value
        FROM raster_data rd
        JOIN h3_hexes hh ON rd.h3_index = hh.h3_index
        WHERE hh.resolution = {res}
        """

        df = pd.read_sql(query, engine)
        df['resolution'] = res
        results.append(df)

    # Combine results
    combined_df = pd.concat(results, ignore_index=True)

    # Plot resolution analysis
    fig, axes = plt.subplots(2, 2, figsize=(15, 10))

    # Hex count by resolution
    axes[0, 0].bar(combined_df['resolution'], combined_df['hex_count'])
    axes[0, 0].set_title('Hex Count by Resolution')
    axes[0, 0].set_xlabel('Resolution')
    axes[0, 0].set_ylabel('Hex Count')

    # Average value by resolution
    axes[0, 1].plot(combined_df['resolution'], combined_df['avg_value'])
    axes[0, 1].set_title('Average Value by Resolution')
    axes[0, 1].set_xlabel('Resolution')
    axes[0, 1].set_ylabel('Average Value')

    # Standard deviation by resolution
    axes[1, 0].plot(combined_df['resolution'], combined_df['std_value'])
    axes[1, 0].set_title('Standard Deviation by Resolution')
    axes[1, 0].set_xlabel('Resolution')
    axes[1, 0].set_ylabel('Standard Deviation')

    # Value range by resolution
    axes[1, 1].plot(combined_df['resolution'], combined_df['max_value'] - combined_df['min_value'])
    axes[1, 1].set_title('Value Range by Resolution')
    axes[1, 1].set_xlabel('Resolution')
    axes[1, 1].set_ylabel('Value Range')

    plt.tight_layout()
    plt.show()

    return combined_df

# Usage
results = analyze_h3_resolutions("postgresql://postgres:postgres@localhost:5432/h3_raster")

H3 Visualization

viz/Dockerfile

FROM node:18-alpine

WORKDIR /app
COPY package.json .
RUN npm install

COPY . .
EXPOSE 3000

CMD ["npm", "start"]

viz/package.json

{
  "name": "h3-raster-viz",
  "version": "1.0.0",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "^4.18.2",
    "pg": "^8.11.3",
    "h3-js": "^4.1.0"
  }
}

viz/server.js

const express = require('express');
const { Pool } = require('pg');
const h3 = require('h3-js');

const app = express();
const port = 3000;

// PostgreSQL connection
const pool = new Pool({
    user: 'postgres',
    host: 'postgres',
    database: 'h3_raster',
    password: 'postgres',
    port: 5432
});

// Serve static files
app.use(express.static('public'));

// API endpoint for H3 hex data
app.get('/api/hexes/:resolution', async (req, res) => {
    try {
        const { resolution } = req.params;
        const { data_type } = req.query;

        let query = `
            SELECT 
                rd.h3_index,
                hh.lat,
                hh.lon,
                rd.value,
                rd.data_type,
                ST_AsText(hh.geom) as geometry
            FROM raster_data rd
            JOIN h3_hexes hh ON rd.h3_index = hh.h3_index
            WHERE hh.resolution = $1
        `;

        const params = [resolution];

        if (data_type) {
            query += ' AND rd.data_type = $2';
            params.push(data_type);
        }

        const result = await pool.query(query, params);

        // Convert to GeoJSON
        const geojson = {
            type: 'FeatureCollection',
            features: result.rows.map(row => ({
                type: 'Feature',
                properties: {
                    h3_index: row.h3_index,
                    value: row.value,
                    data_type: row.data_type
                },
                geometry: {
                    type: 'Polygon',
                    coordinates: [JSON.parse(row.geometry.replace('POLYGON((', '[[').replace('))', ']]'))]
                }
            }))
        };

        res.json(geojson);

    } catch (error) {
        console.error('Error fetching hex data:', error);
        res.status(500).json({ error: 'Internal server error' });
    }
});

// API endpoint for H3 statistics
app.get('/api/stats/:resolution', async (req, res) => {
    try {
        const { resolution } = req.params;

        const query = `
            SELECT 
                data_type,
                COUNT(*) as hex_count,
                AVG(value) as avg_value,
                MIN(value) as min_value,
                MAX(value) as max_value,
                STDDEV(value) as std_value
            FROM raster_data rd
            JOIN h3_hexes hh ON rd.h3_index = hh.h3_index
            WHERE hh.resolution = $1
            GROUP BY data_type
        `;

        const result = await pool.query(query, [resolution]);
        res.json(result.rows);

    } catch (error) {
        console.error('Error fetching stats:', error);
        res.status(500).json({ error: 'Internal server error' });
    }
});

app.listen(port, () => {
    console.log(`H3 raster visualization server running on port ${port}`);
});

viz/public/index.html

<!DOCTYPE html>
<html>
<head>
    <title>H3 Raster Visualization</title>
    <script src="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.js"></script>
    <link href="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.css" rel="stylesheet">
    <style>
        body { margin: 0; padding: 0; }
        #map { height: 100vh; width: 100vw; }
        #controls {
            position: absolute;
            top: 10px;
            left: 10px;
            background: white;
            padding: 10px;
            border-radius: 5px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
    </style>
</head>
<body>
    <div id="map"></div>
    <div id="controls">
        <label for="resolution">Resolution:</label>
        <select id="resolution">
            <option value="6">6</option>
            <option value="7">7</option>
            <option value="8">8</option>
            <option value="9" selected>9</option>
            <option value="10">10</option>
            <option value="11">11</option>
        </select>

        <label for="dataType">Data Type:</label>
        <select id="dataType">
            <option value="">All</option>
            <option value="elevation">Elevation</option>
            <option value="ndvi">NDVI</option>
            <option value="temperature">Temperature</option>
        </select>

        <button onclick="loadHexData()">Load Data</button>
    </div>

    <script>
        const map = new maplibregl.Map({
            container: 'map',
            style: 'https://demotiles.maplibre.org/style.json',
            center: [-74.0, 40.7],
            zoom: 10
        });

        let hexLayer = null;

        function loadHexData() {
            const resolution = document.getElementById('resolution').value;
            const dataType = document.getElementById('dataType').value;

            const url = `/api/hexes/${resolution}${dataType ? `?data_type=${dataType}` : ''}`;

            fetch(url)
                .then(response => response.json())
                .then(data => {
                    // Remove existing layer
                    if (hexLayer) {
                        map.removeLayer(hexLayer);
                        map.removeSource('hexes');
                    }

                    // Add new layer
                    map.addSource('hexes', {
                        type: 'geojson',
                        data: data
                    });

                    map.addLayer({
                        id: 'hexes',
                        type: 'fill',
                        source: 'hexes',
                        paint: {
                            'fill-color': [
                                'interpolate',
                                ['linear'],
                                ['get', 'value'],
                                0, '#0000ff',
                                0.5, '#00ff00',
                                1, '#ff0000'
                            ],
                            'fill-opacity': 0.7
                        }
                    });

                    hexLayer = 'hexes';
                })
                .catch(error => {
                    console.error('Error loading hex data:', error);
                });
        }

        // Load initial data
        map.on('load', () => {
            loadHexData();
        });
    </script>
</body>
</html>

H3 Performance Optimization

H3 Indexing Strategies

-- Create H3 index for fast lookups
CREATE INDEX idx_h3_hexes_h3_index ON h3_hexes(h3_index);
CREATE INDEX idx_h3_hexes_resolution ON h3_hexes(resolution);
CREATE INDEX idx_h3_hexes_geom ON h3_hexes USING GIST(geom);

-- Create composite index for common queries
CREATE INDEX idx_raster_data_h3_type ON raster_data(h3_index, data_type);
CREATE INDEX idx_raster_data_resolution_type ON raster_data(resolution, data_type);

-- Create partial index for specific data types
CREATE INDEX idx_raster_data_elevation ON raster_data(h3_index, value) 
WHERE data_type = 'elevation';

H3 Query Optimization

-- Optimized H3 hex lookup
SELECT 
    h3_index,
    value,
    data_type
FROM raster_data
WHERE h3_index IN (
    SELECT h3_index 
    FROM h3_hexes 
    WHERE resolution = 9
    AND ST_Intersects(geom, ST_MakeEnvelope(-74.1, 40.6, -73.9, 40.8, 4326))
);

-- H3 hex aggregation with spatial filtering
SELECT 
    h3_index,
    AVG(value) as avg_value,
    COUNT(*) as point_count
FROM raster_data
WHERE h3_index IN (
    SELECT h3_index 
    FROM h3_hexes 
    WHERE resolution = 9
    AND ST_DWithin(geom, ST_Point(-74.0, 40.7), 0.1)
)
GROUP BY h3_index
ORDER BY avg_value DESC;

Failure Modes & Runbook

Raster Processing Issues

  • Problem: Large raster files cause memory issues
  • Solution: Process in chunks, use streaming
  • Prevention: Monitor memory usage, implement chunking

H3 Resolution Mismatch

  • Problem: Inconsistent hex sizes across datasets
  • Solution: Standardize on resolution 9 for most use cases
  • Debug: Validate H3 indices with h3.is_valid_cell()

Spatial Index Performance

  • Problem: Slow spatial queries on large datasets
  • Solution: Use proper spatial indexes, optimize queries
  • Monitoring: Check query execution plans

Data Quality Issues

  • Problem: Invalid or missing raster values
  • Solution: Implement data validation, handle NoData values
  • Prevention: Check raster metadata before processing

TL;DR Runbook

# 1. Start core stack
docker compose up -d

# 2. Initialize database schema
psql -h localhost -U postgres -d h3_raster -f schema.sql

# 3. Add processing service
docker compose --profile processing up -d

# 4. Process sample rasters
docker compose exec processor python processor.py

# 5. Add visualization
docker compose --profile viz up -d

# 6. View results
# - Jupyter: http://localhost:8888
# - Visualization: http://localhost:3000
# - Database: psql -h localhost -U postgres -d h3_raster

# 7. Query H3 hex data
# - Get hexes by resolution: SELECT * FROM h3_hexes WHERE resolution = 9
# - Get raster data: SELECT * FROM raster_data WHERE data_type = 'elevation'
# - Spatial query: SELECT * FROM h3_hexes WHERE ST_Intersects(geom, ST_MakeEnvelope(-74.1, 40.6, -73.9, 40.8, 4326))

This tutorial provides the complete machinery for converting raster data to H3 hexagonal grids. Each component is production-ready, copy-paste runnable, and designed to handle large-scale geospatial data processing with efficient spatial indexing and aggregation.