Skip to main content

Raster data management — imagery, elevation, and gridded coverage

Raster data represents the world as a continuous grid of cells or pixels, each containing a measured or derived value. In Danish municipal applications, raster data includes aerial photography, satellite imagery, digital elevation models, temperature surfaces, noise maps, and countless other gridded datasets. Understanding how to effectively manage these diverse raster types in GeoPackage enables comprehensive spatial analysis and decision-making.

Types of Raster Data

Imagery Data

Imagery data captures visual information about the Earth's surface through photographs or sensors.

Aerial Photography (Orthophotos)

Aerial photographs corrected for camera tilt and terrain relief, providing a geometrically accurate "map-like" view.

Danish municipal applications:

  • Planning and development review
  • Property assessment
  • Infrastructure inspection
  • Change detection over time
  • Public information portals

Characteristics:

  • True color (RGB) or color infrared (CIR)
  • Typically 10-25cm ground resolution in Denmark
  • Large file sizes (municipality coverage = 10-100GB)
  • JPEG compression suitable
  • Updated every 1-3 years

Example: Orthophoto specification

Resolution: 10cm (0.1m per pixel)
Spectral bands: Red, Green, Blue (RGB)
Bit depth: 8 bits per band
Format in GeoPackage: JPEG tiles
Coverage: Entire municipality
File size: ~40GB for medium-sized kommune

Satellite Imagery

Images captured by satellites, offering broader coverage but typically lower resolution than aerial photos.

Use cases:

  • Regional context and overview
  • Agricultural monitoring
  • Environmental assessment
  • Change detection at larger scales

Characteristics:

  • Resolution: 0.5m (commercial) to 30m (Landsat)
  • Multiple spectral bands (visible, infrared, thermal)
  • Regular revisit times (days to weeks)
  • Can include analysis products (NDVI, land cover)

Scanned Historical Maps

Digitized historical maps providing temporal context.

Applications:

  • Historical land use research
  • Cultural heritage documentation
  • Legal property boundary research
  • Public history projects

Considerations:

  • PNG format preserves detail
  • May require georeferencing
  • Variable quality and accuracy
  • Important metadata: original scale, scan resolution, georeferencing method

Elevation Data

Elevation data represents the height of the Earth's surface.

Digital Terrain Model (DTM)

Bare earth elevation, with vegetation and buildings removed.

Danish municipal uses:

  • Flood risk modeling
  • Drainage planning
  • Cut-and-fill calculations
  • Sight line analysis
  • Infrastructure design

Characteristics:

  • 0.4m grid resolution (typical Danish standard)
  • 32-bit floating point values (elevation in meters)
  • Accuracy: ±5-15cm vertical
  • Derived from LiDAR point clouds
  • Updated every 3-5 years

Example storage:

Resolution: 0.4m grid
Data type: 32-bit float
Value range: -2.5m to +180m (typical Danish range)
Tile format: PNG with elevation encoding or TIFF
Compression: Minimal (preserve precision)

Digital Surface Model (DSM)

Elevation of the surface including buildings, vegetation, and other features.

Applications:

  • 3D city modeling
  • Solar potential analysis
  • Visual impact assessment
  • Communication tower planning
  • Wind modeling

Height Models

Difference between DSM and DTM showing above-ground feature heights.

Uses:

  • Building height extraction
  • Tree height mapping
  • Vegetation monitoring
  • Urban growth analysis

Slope and Aspect

Derived products showing terrain gradient and orientation.

Applications:

  • Erosion risk assessment
  • Road planning
  • Solar panel placement
  • Agricultural suitability

Example calculation:

Slope: Rate of elevation change (degrees or percent)
Aspect: Compass direction of slope (0-360 degrees)
Storage: 8-bit integer (0-90 for slope degrees, 0-360 for aspect)

Gridded Coverage Data

Continuous surfaces representing various phenomena.

Environmental Data

  • Temperature surfaces
  • Precipitation patterns
  • Air quality indices
  • Noise levels
  • Groundwater levels
  • Soil types and properties

Infrastructure Coverage

  • Cellular network coverage
  • Broadband availability
  • Emergency service response times
  • Public transit accessibility
  • Street lighting coverage

Planning and Analysis Surfaces

  • Population density
  • Land value surfaces
  • Development suitability
  • Risk assessment scores
  • Service area coverage

Temporal Data

  • Change detection surfaces
  • Time series analysis
  • Seasonal variations
  • Growth patterns

Storing Raster Data in GeoPackage

GeoPackage handles raster data through its tile mechanism, but different raster types require different approaches.

Approach 1: Standard RGB Imagery

For true color aerial photos and similar imagery.

-- Create tile pyramid for orthophotos
INSERT INTO gpkg_contents (
    table_name, data_type, identifier, description,
    last_change, min_x, min_y, max_x, max_y, srs_id
) VALUES (
    'orthophotos_2024',
    'tiles',
    'Orthophotos 2024',
    'True color aerial photography, 10cm resolution, RGB, captured April-May 2024',
    datetime('now'),
    550000, 6195000, 575000, 6215000,
    25832
);

-- Define tile matrix set and zoom levels
INSERT INTO gpkg_tile_matrix_set VALUES (
    'orthophotos_2024', 25832,
    550000, 6195000, 575000, 6215000
);

-- Zoom levels from overview to full resolution
INSERT INTO gpkg_tile_matrix VALUES 
    ('orthophotos_2024', 10, 32, 32, 256, 256, 3.052, 3.052),
    ('orthophotos_2024', 14, 512, 512, 256, 256, 0.191, 0.191);

-- Create tile table
CREATE TABLE orthophotos_2024 (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    zoom_level INTEGER NOT NULL,
    tile_column INTEGER NOT NULL,
    tile_row INTEGER NOT NULL,
    tile_data BLOB NOT NULL,
    UNIQUE (zoom_level, tile_column, tile_row)
);

-- Tiles stored as JPEG (good compression for photos)
-- Quality 85, RGB, 256x256 pixels

Approach 2: Elevation Data with Float Values

For elevation models requiring precise values.

Method A: Elevation as Grayscale PNG

Encode elevation values in PNG pixel values for single-band data.

-- Create DTM tile pyramid
INSERT INTO gpkg_contents (
    table_name, data_type, identifier, description,
    last_change, min_x, min_y, max_x, max_y, srs_id
) VALUES (
    'dtm_2024',
    'tiles',
    'Digital Terrain Model 2024',
    'Bare earth elevation model, 0.4m resolution, elevations in meters above DVR90',
    datetime('now'),
    550000, 6195000, 575000, 6215000,
    25832
);

INSERT INTO gpkg_tile_matrix_set VALUES (
    'dtm_2024', 25832,
    550000, 6195000, 575000, 6215000
);

-- Fewer zoom levels needed (elevation changes gradually)
INSERT INTO gpkg_tile_matrix VALUES 
    ('dtm_2024', 12, 128, 128, 256, 256, 0.762, 0.762),
    ('dtm_2024', 14, 512, 512, 256, 256, 0.191, 0.191);

CREATE TABLE dtm_2024 (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    zoom_level INTEGER NOT NULL,
    tile_column INTEGER NOT NULL,
    tile_row INTEGER NOT NULL,
    tile_data BLOB NOT NULL,  -- PNG with encoded elevation
    UNIQUE (zoom_level, tile_column, tile_row)
);

-- Add metadata about encoding
CREATE TABLE dtm_2024_metadata (
    property TEXT PRIMARY KEY,
    value TEXT
);

INSERT INTO dtm_2024_metadata VALUES
    ('encoding', 'grayscale_16bit'),
    ('min_elevation', '-2.5'),
    ('max_elevation', '180.0'),
    ('scale_factor', '0.01'),  -- pixel_value * 0.01 = elevation in meters
    ('offset', '-2.5'),
    ('nodata_value', '65535'),
    ('vertical_datum', 'DVR90'),
    ('units', 'meters');

Encoding elevation in PNG:

import numpy as np
from PIL import Image

def encode_elevation_tile(elevation_array, min_elev, max_elev):
    """
    Encode elevation values as 16-bit grayscale PNG
    
    elevation_array: 256x256 numpy array of elevation values (meters)
    Returns: PNG image data as bytes
    """
    # Scale elevation to 0-65535 range
    # Reserve 65535 for NODATA
    scaled = ((elevation_array - min_elev) / (max_elev - min_elev) * 65534).astype(np.uint16)
    
    # Handle NODATA
    scaled[np.isnan(elevation_array)] = 65535
    
    # Create PNG
    img = Image.fromarray(scaled, mode='I;16')
    
    # Save to bytes
    import io
    buffer = io.BytesIO()
    img.save(buffer, format='PNG')
    return buffer.getvalue()

def decode_elevation_tile(png_bytes, min_elev, max_elev):
    """
    Decode PNG back to elevation values
    """
    img = Image.open(io.BytesIO(png_bytes))
    pixel_values = np.array(img, dtype=np.uint16)
    
    # Convert back to elevations
    elevations = (pixel_values / 65534.0) * (max_elev - min_elev) + min_elev
    
    # Restore NODATA
    elevations[pixel_values == 65535] = np.nan
    
    return elevations

Method B: External TIFF with GeoPackage Reference

For very high precision requirements, store original TIFF files and reference them in GeoPackage.

-- Table referencing external raster files
CREATE TABLE dtm_tiles_external (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    tile_name TEXT UNIQUE NOT NULL,
    file_path TEXT NOT NULL,
    min_x REAL, min_y REAL, max_x REAL, max_y REAL,
    min_elevation REAL,
    max_elevation REAL,
    capture_date DATE,
    processing_date DATE
);

INSERT INTO dtm_tiles_external VALUES (
    1,
    'DTM_550_6195',
    './elevation_data/dtm_550000_6195000.tif',
    550000, 6195000, 551000, 6196000,
    12.5, 45.8,
    '2024-03-15',
    '2024-04-20'
);

-- This approach keeps full precision but loses single-file portability

Approach 3: Multi-band Imagery

For imagery with multiple spectral bands (e.g., multispectral satellite imagery).

-- Satellite imagery with multiple bands
INSERT INTO gpkg_contents VALUES (
    'sentinel2_2024',
    'tiles',
    'Sentinel-2 Imagery',
    'Sentinel-2 multispectral imagery, 10m resolution, bands: Red, Green, Blue, NIR',
    datetime('now'),
    550000, 6195000, 575000, 6215000,
    25832
);

-- Standard tile pyramid setup
-- ...

CREATE TABLE sentinel2_2024 (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    zoom_level INTEGER NOT NULL,
    tile_column INTEGER NOT NULL,
    tile_row INTEGER NOT NULL,
    tile_data BLOB NOT NULL,  -- PNG with RGB composite or analysis product
    UNIQUE (zoom_level, tile_column, tile_row)
);

-- Can store different band combinations in different tile tables
CREATE TABLE sentinel2_2024_ndvi (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    zoom_level INTEGER NOT NULL,
    tile_column INTEGER NOT NULL,
    tile_row INTEGER NOT NULL,
    tile_data BLOB NOT NULL,  -- NDVI rendered as color ramp
    UNIQUE (zoom_level, tile_column, tile_row)
);

Approach 4: Categorical Raster Data

For land cover, soil types, zoning, etc.

-- Land cover classification
INSERT INTO gpkg_contents VALUES (
    'land_cover_2024',
    'tiles',
    'Land Cover 2024',
    'Land cover classification: Urban, Agriculture, Forest, Water, Wetland',
    datetime('now'),
    550000, 6195000, 575000, 6215000,
    25832
);

CREATE TABLE land_cover_2024 (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    zoom_level INTEGER NOT NULL,
    tile_column INTEGER NOT NULL,
    tile_row INTEGER NOT NULL,
    tile_data BLOB NOT NULL,  -- PNG with indexed colors
    UNIQUE (zoom_level, tile_column, tile_row)
);

-- Classification legend
CREATE TABLE land_cover_classes (
    class_value INTEGER PRIMARY KEY,
    class_name TEXT NOT NULL,
    description_da TEXT,
    color_hex TEXT,
    display_order INTEGER
);

INSERT INTO land_cover_classes VALUES
    (1, 'Urban', 'Byområde', '#FF0000', 1),
    (2, 'Agriculture', 'Landbrugsareal', '#FFFF00', 2),
    (3, 'Forest', 'Skov', '#00FF00', 3),
    (4, 'Water', 'Vand', '#0000FF', 4),
    (5, 'Wetland', 'Vådområde', '#00FFFF', 5);

Approach 5: Hillshade and Visualization Products

Derived visualization products for terrain representation.

-- Hillshade for terrain visualization
INSERT INTO gpkg_contents VALUES (
    'hillshade_2024',
    'tiles',
    'Terrain Hillshade',
    'Analytical hillshade from DTM, illumination from NW, altitude 45°',
    datetime('now'),
    550000, 6195000, 575000, 6215000,
    25832
);

CREATE TABLE hillshade_2024 (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    zoom_level INTEGER NOT NULL,
    tile_column INTEGER NOT NULL,
    tile_row INTEGER NOT NULL,
    tile_data BLOB NOT NULL,  -- Grayscale PNG
    UNIQUE (zoom_level, tile_column, tile_row)
);

-- Hillshade generation parameters
CREATE TABLE hillshade_parameters (
    parameter TEXT PRIMARY KEY,
    value TEXT
);

INSERT INTO hillshade_parameters VALUES
    ('azimuth', '315'),  -- Light from northwest
    ('altitude', '45'),   -- 45 degrees above horizon
    ('z_factor', '1.0'),  -- Vertical exaggeration
    ('algorithm', 'Horn'),
    ('source_dtm', 'dtm_2024');

Raster Data Workflows

Workflow 1: Processing Aerial Photography

Complete workflow from source imagery to GeoPackage tiles.

Step 1: Prepare source imagery

# Check source file information
gdalinfo source_orthophoto.tif

# Output example:
# Size: 250000 x 200000 pixels
# Pixel Size: 0.10m x 0.10m
# Coordinate System: EPSG:25832
# Bands: 3 (RGB)

Step 2: Create GeoPackage and define pyramid

-- Create GeoPackage structure (as shown in previous sections)
-- Define gpkg_contents, gpkg_tile_matrix_set, gpkg_tile_matrix

Step 3: Generate tiles using GDAL

# Convert to GeoPackage tiles
gdal_translate \
    -of GPKG \
    -co TILE_FORMAT=JPEG \
    -co QUALITY=85 \
    -co BLOCKXSIZE=256 \
    -co BLOCKYSIZE=256 \
    -co RASTER_TABLE=orthophotos_2024 \
    source_orthophoto.tif \
    municipality.gpkg

# Build pyramid (overview levels)
gdaladdo \
    -r average \
    -ro \  # Read-only mode
    --config COMPRESS_OVERVIEW JPEG \
    --config JPEG_QUALITY_OVERVIEW 85 \
    municipality.gpkg \
    2 4 8 16 32  # Overview factors

Step 4: Verify and optimize

-- Count tiles per zoom level
SELECT 
    zoom_level,
    COUNT(*) AS tile_count,
    ROUND(SUM(LENGTH(tile_data)) / 1024.0 / 1024.0, 2) AS size_mb
FROM orthophotos_2024
GROUP BY zoom_level;

-- Optimize database
VACUUM;
ANALYZE;

Workflow 2: Creating Elevation Tile Pyramid

Processing DTM data into GeoPackage.

Step 1: Prepare elevation data

# Check DTM file
gdalinfo dtm_source.tif

# Output:
# Size: 62500 x 50000
# Pixel Size: 0.40m x 0.40m
# Data Type: Float32
# Band 1: Min=-2.5, Max=180.3

Step 2: Process elevation to tiles

import numpy as np
from osgeo import gdal
import sqlite3
from PIL import Image
import io

def create_elevation_tiles(source_tif, gpkg_path, table_name, zoom_level):
    """Generate elevation tiles from source DTM"""
    
    # Open source raster
    ds = gdal.Open(source_tif)
    band = ds.GetRasterBand(1)
    
    # Get elevation range
    stats = band.GetStatistics(True, True)
    min_elev, max_elev = stats[0], stats[1]
    
    print(f"Elevation range: {min_elev:.2f}m to {max_elev:.2f}m")
    
    # Calculate tile parameters
    pixel_size = 0.4  # meters
    tile_size = 256  # pixels
    tile_ground_size = tile_size * pixel_size  # 102.4m
    
    # Get geotransform
    gt = ds.GetGeoTransform()
    origin_x, origin_y = gt[0], gt[3]
    
    # Connect to GeoPackage
    conn = sqlite3.connect(gpkg_path)
    cursor = conn.cursor()
    
    # Calculate number of tiles
    width, height = ds.RasterXSize, ds.RasterYSize
    n_tiles_x = int(np.ceil(width / tile_size))
    n_tiles_y = int(np.ceil(height / tile_size))
    
    print(f"Generating {n_tiles_x} x {n_tiles_y} = {n_tiles_x * n_tiles_y} tiles")
    
    # Generate tiles
    for tile_row in range(n_tiles_y):
        for tile_col in range(n_tiles_x):
            # Calculate pixel offsets
            x_off = tile_col * tile_size
            y_off = tile_row * tile_size
            
            # Read elevation data
            elevation = band.ReadAsArray(
                x_off, y_off,
                min(tile_size, width - x_off),
                min(tile_size, height - y_off)
            ).astype(np.float32)
            
            # Pad if necessary
            if elevation.shape != (tile_size, tile_size):
                padded = np.full((tile_size, tile_size), np.nan, dtype=np.float32)
                padded[:elevation.shape[0], :elevation.shape[1]] = elevation
                elevation = padded
            
            # Encode to 16-bit PNG
            scaled = ((elevation - min_elev) / (max_elev - min_elev) * 65534).astype(np.uint16)
            scaled[np.isnan(elevation)] = 65535
            
            img = Image.fromarray(scaled, mode='I;16')
            buffer = io.BytesIO()
            img.save(buffer, format='PNG', compress_level=6)
            tile_data = buffer.getvalue()
            
            # Insert into database
            cursor.execute(f'''
                INSERT INTO {table_name} 
                (zoom_level, tile_column, tile_row, tile_data)
                VALUES (?, ?, ?, ?)
            ''', (zoom_level, tile_col, tile_row, tile_data))
        
        if tile_row % 10 == 0:
            print(f"Progress: {tile_row}/{n_tiles_y} rows")
            conn.commit()
    
    conn.commit()
    conn.close()
    
    print("Tile generation complete")

# Usage
create_elevation_tiles(
    'dtm_source.tif',
    'municipality.gpkg',
    'dtm_2024',
    14  # Zoom level for 0.4m resolution
)

Step 3: Generate overview tiles

def generate_elevation_overviews(gpkg_path, table_name, from_zoom, to_zoom):
    """Generate lower zoom level tiles from higher zoom tiles"""
    
    conn = sqlite3.connect(gpkg_path)
    cursor = conn.cursor()
    
    # Get elevation encoding parameters
    cursor.execute('''
        SELECT value FROM dtm_2024_metadata 
        WHERE property IN ('min_elevation', 'max_elevation')
    ''')
    min_elev, max_elev = [float(row[0]) for row in cursor.fetchall()]
    
    # For each zoom level from high to low
    for zoom in range(from_zoom - 1, to_zoom - 1, -1):
        print(f"Generating zoom level {zoom}")
        
        # Get matrix dimensions
        cursor.execute('''
            SELECT matrix_width, matrix_height
            FROM gpkg_tile_matrix
            WHERE table_name = ? AND zoom_level = ?
        ''', (table_name, zoom))
        
        matrix_width, matrix_height = cursor.fetchone()
        
        # Generate each overview tile
        for tile_row in range(matrix_height):
            for tile_col in range(matrix_width):
                # Fetch 4 source tiles from next zoom level
                src_zoom = zoom + 1
                src_col = tile_col * 2
                src_row = tile_row * 2
                
                cursor.execute(f'''
                    SELECT tile_column, tile_row, tile_data
                    FROM {table_name}
                    WHERE zoom_level = ?
                      AND tile_column IN (?, ?)
                      AND tile_row IN (?, ?)
                ''', (src_zoom, src_col, src_col + 1, src_row, src_row + 1))
                
                source_tiles = cursor.fetchall()
                
                if not source_tiles:
                    continue  # No source data
                
                # Combine and resample
                combined = np.full((512, 512), np.nan, dtype=np.float32)
                
                for s_col, s_row, s_data in source_tiles:
                    img = Image.open(io.BytesIO(s_data))
                    pixels = np.array(img, dtype=np.uint16)
                    
                    # Decode elevation
                    elevations = (pixels / 65534.0) * (max_elev - min_elev) + min_elev
                    elevations[pixels == 65535] = np.nan
                    
                    # Place in combined array
                    x = (s_col - src_col) * 256
                    y = (s_row - src_row) * 256
                    combined[y:y+256, x:x+256] = elevations
                
                # Resample to 256x256 using averaging
                from scipy.ndimage import zoom
                resampled = zoom(combined, 0.5, order=1)  # Bilinear
                
                # Encode to PNG
                scaled = ((resampled - min_elev) / (max_elev - min_elev) * 65534).astype(np.uint16)
                scaled[np.isnan(resampled)] = 65535
                
                img = Image.fromarray(scaled, mode='I;16')
                buffer = io.BytesIO()
                img.save(buffer, format='PNG', compress_level=6)
                tile_data = buffer.getvalue()
                
                # Insert overview tile
                cursor.execute(f'''
                    INSERT INTO {table_name}
                    (zoom_level, tile_column, tile_row, tile_data)
                    VALUES (?, ?, ?, ?)
                ''', (zoom, tile_col, tile_row, tile_data))
        
        conn.commit()
        print(f"Zoom level {zoom} complete")
    
    conn.close()

# Generate overviews from zoom 14 down to zoom 10
generate_elevation_overviews('municipality.gpkg', 'dtm_2024', 14, 10)

Workflow 3: Creating Derived Products

Generating hillshade, slope, and aspect from elevation.

def generate_hillshade_tiles(dtm_table, hillshade_table, gpkg_path, zoom_level,
                              azimuth=315, altitude=45):
    """Generate hillshade tiles from DTM tiles"""
    import numpy as np
    from osgeo import gdal
    
    conn = sqlite3.connect(gpkg_path)
    cursor = conn.cursor()
    
    # Get DTM parameters
    cursor.execute('''
        SELECT value FROM dtm_2024_metadata 
        WHERE property IN ('min_elevation', 'max_elevation', 'scale_factor')
    ''')
    params = {row[0]: float(row[1]) for row in cursor.fetchall()}
    
    # Get all tiles at this zoom level
    cursor.execute(f'''
        SELECT tile_column, tile_row, tile_data
        FROM {dtm_table}
        WHERE zoom_level = ?
        ORDER BY tile_row, tile_column
    ''', (zoom_level,))
    
    tiles = cursor.fetchall()
    
    for tile_col, tile_row, tile_data in tiles:
        # Decode elevation
        img = Image.open(io.BytesIO(tile_data))
        pixels = np.array(img, dtype=np.uint16)
        elevation = (pixels / 65534.0) * (params['max_elevation'] - params['min_elevation']) + params['min_elevation']
        elevation[pixels == 65535] = np.nan
        
        # Calculate hillshade
        # Simple algorithm (better to use GDAL's DEMProcessing)
        azimuth_rad = np.radians(azimuth)
        altitude_rad = np.radians(altitude)
        
        # Calculate gradients (dx, dy)
        dx = np.gradient(elevation, axis=1)
        dy = np.gradient(elevation, axis=0)
        
        # Calculate slope and aspect
        slope = np.arctan(np.sqrt(dx**2 + dy**2))
        aspect = np.arctan2(-dy, dx)
        
        # Calculate hillshade
        hillshade = 255 * (
            (np.cos(altitude_rad) * np.cos(slope)) +
            (np.sin(altitude_rad) * np.sin(slope) * 
             np.cos(azimuth_rad - aspect))
        )
        
        hillshade = np.clip(hillshade, 0, 255).astype(np.uint8)
        hillshade[np.isnan(elevation)] = 0
        
        # Create PNG
        img = Image.fromarray(hillshade, mode='L')
        buffer = io.BytesIO()
        img.save(buffer, format='PNG')
        tile_data_hillshade = buffer.getvalue()
        
        # Insert hillshade tile
        cursor.execute(f'''
            INSERT INTO {hillshade_table}
            (zoom_level, tile_column, tile_row, tile_data)
            VALUES (?, ?, ?, ?)
        ''', (zoom_level, tile_col, tile_row, tile_data_hillshade))
    
    conn.commit()
    conn.close()
    print(f"Hillshade generation complete for zoom {zoom_level}")

# Generate hillshade
generate_hillshade_tiles('dtm_2024', 'hillshade_2024', 'municipality.gpkg', 14)

Workflow 4: Temporal Analysis

Managing time series of raster data.

-- Store multiple time periods
CREATE TABLE orthophotos_2020 (...);
CREATE TABLE orthophotos_2022 (...);
CREATE TABLE orthophotos_2024 (...);

-- Temporal metadata table
CREATE TABLE imagery_temporal_index (
    table_name TEXT PRIMARY KEY,
    capture_date_start DATE,
    capture_date_end DATE,
    season TEXT,
    year INTEGER,
    description TEXT,
    FOREIGN KEY (table_name) REFERENCES gpkg_contents(table_name)
);

INSERT INTO imagery_temporal_index VALUES
    ('orthophotos_2020', '2020-04-15', '2020-05-20', 'Spring', 2020, '10cm RGB orthophotos'),
    ('orthophotos_2022', '2022-05-01', '2022-05-25', 'Spring', 2022, '10cm RGB orthophotos'),
    ('orthophotos_2024', '2024-04-10', '2024-05-15', 'Spring', 2024, '10cm RGB orthophotos');

-- Query available imagery
SELECT 
    table_name,
    year,
    capture_date_start,
    julianday(capture_date_end) - julianday(capture_date_start) AS capture_duration_days
FROM imagery_temporal_index
ORDER BY year;

-- Find imagery closest to a specific date
SELECT table_name, year, capture_date_start,
       ABS(julianday('2023-05-01') - julianday(capture_date_start)) AS days_difference
FROM imagery_temporal_index
ORDER BY days_difference
LIMIT 1;

Analysis and Extraction

Extracting Values at Points

def extract_elevation_at_point(gpkg_path, dtm_table, x, y, zoom_level=14):
    """Extract elevation value at a specific coordinate"""
    
    conn = sqlite3.connect(gpkg_path)
    cursor = conn.cursor()
    
    # Get tile matrix parameters
    cursor.execute('''
        SELECT 
            tms.min_x, tms.max_y,
            tm.pixel_x_size, tm.pixel_y_size,
            tm.tile_width, tm.tile_height
        FROM gpkg_tile_matrix tm
        JOIN gpkg_tile_matrix_set tms ON tm.table_name = tms.table_name
        WHERE tm.table_name = ? AND tm.zoom_level = ?
    ''', (dtm_table, zoom_level))
    
    origin_x, origin_y, pixel_x, pixel_y, tile_w, tile_h = cursor.fetchone()
    
    # Calculate tile coordinates
    tile_col = int((x - origin_x) / (pixel_x * tile_w))
    tile_row = int((origin_y - y) / (pixel_y * tile_h))
    
    # Get the tile
    cursor.execute(f'''
        SELECT tile_data FROM {dtm_table}
        WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?
    ''', (zoom_level, tile_col, tile_row))
    
    result = cursor.fetchone()
    if not result:
        return None
    
    # Decode tile
    cursor.execute('''
      SELECT value FROM dtm_2024_metadata
      WHERE property IN ('min_elevation', 'max_elevation')
    ''')
    min_elev, max_elev = [float(row[0]) for row in cursor.fetchall()]
    img = Image.open(io.BytesIO(result[0]))
    pixels = np.array(img, dtype=np.uint16)
    elevation = (pixels / 65534.0) * (max_elev - min_elev) + min_elev
    
    # Calculate pixel within tile
    tile_origin_x = origin_x + tile_col * tile_w * pixel_x
    tile_origin_y = origin_y - tile_row * tile_h * pixel_y
    
    pixel_col = int((x - tile_origin_x) / pixel_x)
    pixel_row = int((tile_origin_y - y) / pixel_y)
    
    if 0 <= pixel_row < tile_h and 0 <= pixel_col < tile_w:
        elev_value = elevation[pixel_row, pixel_col]
        return elev_value if not np.isnan(elev_value) else None
    
    return None
    Usage
    elevation = extract_elevation_at_point(
      'municipality.gpkg',
      'dtm_2024',
      565000,  # X coordinate
      6205000,  # Y coordinate
      zoom_level=14
    )
    print(f"Elevation: {elevation:.2f}m")

Calculating Statistics

def calculate_elevation_statistics(gpkg_path, dtm_table, polygon_wkt, zoom_level):
    """Calculate elevation statistics within a polygon"""
    from shapely import wkt
    from shapely.geometry import box
    
    conn = sqlite3.connect(gpkg_path)
    cursor = conn.cursor()
    
    # Parse polygon
    polygon = wkt.loads(polygon_wkt)
    bounds = polygon.bounds  # (minx, miny, maxx, maxy)
    
    # Get tile parameters
    cursor.execute('''
        SELECT 
            tms.min_x, tms.max_y,
            tm.pixel_x_size, tm.pixel_y_size,
            tm.tile_width, tm.tile_height
        FROM gpkg_tile_matrix tm
        JOIN gpkg_tile_matrix_set tms ON tm.table_name = tms.table_name
        WHERE tm.table_name = ? AND tm.zoom_level = ?
    ''', (dtm_table, zoom_level))
    
    origin_x, origin_y, pixel_x, pixel_y, tile_w, tile_h = cursor.fetchone()
    
    # Calculate tile range
    min_col = int((bounds[0] - origin_x) / (pixel_x * tile_w))
    max_col = int((bounds[2] - origin_x) / (pixel_x * tile_w))
    min_row = int((origin_y - bounds[3]) / (pixel_y * tile_h))
    max_row = int((origin_y - bounds[1]) / (pixel_y * tile_h))
    
    # Get encoding parameters
    cursor.execute('''
        SELECT value FROM dtm_2024_metadata
        WHERE property IN ('min_elevation', 'max_elevation')
    ''')
    min_elev, max_elev = [float(row[0]) for row in cursor.fetchall()]
    
    # Collect elevation values
    all_elevations = []
    
    for tile_row in range(min_row, max_row + 1):
        for tile_col in range(min_col, max_col + 1):
            # Get tile
            cursor.execute(f'''
                SELECT tile_data FROM {dtm_table}
                WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?
            ''', (zoom_level, tile_col, tile_row))
            
            result = cursor.fetchone()
            if not result:
                continue
            
            # Decode
            img = Image.open(io.BytesIO(result[0]))
            pixels = np.array(img, dtype=np.uint16)
            elevation = (pixels / 65534.0) * (max_elev - min_elev) + min_elev
            
            # Mask by polygon (simplified - assumes polygon covers entire tiles)
            valid_elevations = elevation[pixels != 65535]
            all_elevations.extend(valid_elevations.flatten())
    
    # Calculate statistics
    elevations = np.array(all_elevations)
    
    stats = {
        'min': float(np.min(elevations)),
        'max': float(np.max(elevations)),
        'mean': float(np.mean(elevations)),
        'median': float(np.median(elevations)),
        'std': float(np.std(elevations)),
        'count': len(elevations)
    }
    
    conn.close()
    return stats

# Usage
polygon_wkt = 'POLYGON((565000 6205000, 566000 6205000, 566000 6206000, 565000 6206000, 565000 6205000))'
stats = calculate_elevation_statistics(
    'municipality.gpkg',
    'dtm_2024',
    polygon_wkt,
    14
)
print(f"Elevation statistics: {stats}")

Best Practices for Raster Data Management

1. Choose Appropriate Resolutions

-- Match zoom levels to source data resolution
-- Don't create finer resolution tiles than source data supports

-- Example for 10cm aerial photos:
-- Zoom 16: 0.095m/pixel (matches 10cm source)
-- Zoom 15: 0.19m/pixel (2x resample)
-- Zoom 14: 0.38m/pixel (4x resample)
-- ...
-- Don't create zoom 17 (0.047m/pixel) - beyond source resolution

2. Organize by Theme and Time

-- Clear naming convention
orthophotos_2024_spring
orthophotos_2024_fall
dtm_2024
dsm_2024
hillshade_2024
land_cover_2024

-- Not:
raster1, raster2, tiles_v3

3. Document Thoroughly

-- Comprehensive metadata
INSERT INTO gpkg_contents (
    table_name, data_type, identifier, description, ...
) VALUES (
    'orthophotos_2024',
    'tiles',
    'Aerial Photos 2024',
    'True color RGB aerial photography. Resolution: 10cm. Captured: April 15-May 20, 2024. Coverage: entire municipality. Contractor: GeoDanmark. Coordinate system: EPSG:25832. Accuracy: horizontal ±15cm, vertical N/A. Format: JPEG quality 85. Bands: Red, Green, Blue. Radiometric: 8-bit per band.',
    ...
);

-- Additional metadata tables
CREATE TABLE raster_metadata (
    table_name TEXT NOT NULL,
    property TEXT NOT NULL,
    value TEXT,
    PRIMARY KEY (table_name, property)
);

INSERT INTO raster_metadata VALUES
    ('orthophotos_2024', 'contractor', 'GeoDanmark'),
    ('orthophotos_2024', 'capture_method', 'Aerial photography'),
    ('orthophotos_2024', 'camera_model', 'UltraCamX'),
    ('orthophotos_2024', 'processing_software', 'Agisoft Metashape'),
    ('orthophotos_2024', 'horizontal_accuracy_cm', '15'),
    ('orthophotos_2024', 'sun_angle_min_deg', '35'),
    ('orthophotos_2024', 'cloud_coverage_max_pct', '5');

4. Validate Data Quality

-- Check for completeness
SELECT 
    tm.table_name,
    tm.zoom_level,
    tm.matrix_width * tm.matrix_height AS expected_tiles,
    COUNT(t.id) AS actual_tiles,
    100.0 * COUNT(t.id) / (tm.matrix_width * tm.matrix_height) AS completeness_pct
FROM gpkg_tile_matrix tm
LEFT JOIN orthophotos_2024 t ON tm.zoom_level = t.zoom_level
WHERE tm.table_name = 'orthophotos_2024'
GROUP BY tm.table_name, tm.zoom_level;

-- Check for anomalies
SELECT 
    zoom_level,
    MIN(LENGTH(tile_data)) AS min_size,
    AVG(LENGTH(tile_data)) AS avg_size,
    MAX(LENGTH(tile_data)) AS max_size
FROM orthophotos_2024
GROUP BY zoom_level;

-- Find outliers (suspiciously small or large tiles)
SELECT 
    zoom_level, tile_column, tile_row,
    LENGTH(tile_data) AS size_bytes
FROM orthophotos_2024
WHERE LENGTH(tile_data) < 5000  -- Unusually small
   OR LENGTH(tile_data) > 150000  -- Unusually large
ORDER BY LENGTH(tile_data);

5. Optimize Storage

-- Monitor storage usage
SELECT 
    c.table_name,
    c.identifier,
    COUNT(t.id) AS tile_count,
    ROUND(SUM(LENGTH(t.tile_data)) / 1024.0 / 1024.0 / 1024.0, 2) AS size_gb,
    ROUND(AVG(LENGTH(t.tile_data)) / 1024.0, 1) AS avg_tile_kb
FROM gpkg_contents c
JOIN orthophotos_2024 t ON c.table_name = 'orthophotos_2024'
WHERE c.data_type = 'tiles'
GROUP BY c.table_name;

-- Identify storage-heavy zoom levels
SELECT 
    zoom_level,
    COUNT(*) AS tiles,
    ROUND(SUM(LENGTH(tile_data)) / 1024.0 / 1024.0, 2) AS size_mb,
    ROUND(100.0 * SUM(LENGTH(tile_data)) / 
        (SELECT SUM(LENGTH(tile_data)) FROM orthophotos_2024), 1) AS pct_of_total
FROM orthophotos_2024
GROUP BY zoom_level
ORDER BY zoom_level;

6. Maintain Audit Trail

-- Track data lineage
CREATE TABLE raster_processing_log (
    log_id INTEGER PRIMARY KEY AUTOINCREMENT,
    table_name TEXT NOT NULL,
    processing_date DATETIME DEFAULT CURRENT_TIMESTAMP,
    operation TEXT NOT NULL,
    source_file TEXT,
    parameters TEXT,  -- JSON
    operator TEXT,
    notes TEXT
);

INSERT INTO raster_processing_log (
    table_name, operation, source_file, parameters, operator, notes
) VALUES (
    'orthophotos_2024',
    'tile_generation',
    'vejle_ortho_2024_utm32.tif',
    '{"tile_format": "JPEG", "quality": 85, "tile_size": 256}',
    'jsmith',
    'Initial tile generation from contractor deliverable'
);

Summary

Raster data management in GeoPackage requires understanding different raster types—imagery, elevation, and gridded coverage—and applying appropriate storage strategies for each. Aerial photography uses JPEG-compressed RGB tiles, elevation models require precision encoding (often as 16-bit PNG), and derived products like hillshade provide visualization support. Effective workflows include preprocessing source data, generating multi-resolution tile pyramids, creating derived analytical products, and implementing robust quality assurance. Proper documentation, validation, and optimization ensure raster datasets remain accessible, performant, and useful for Danish municipal planning, analysis, and decision-making.


Knowledge Check Quiz

Test your understanding of raster data management in GeoPackage:

Question 1: What is the primary advantage of storing aerial photography as JPEG tiles rather than PNG?

a) JPEG supports transparency
b) JPEG provides better compression for photographic imagery
c) JPEG is lossless
d) JPEG supports more colors

Question 2: For a Digital Terrain Model (DTM) with elevation values, why might you use 16-bit PNG encoding?

a) To support transparency
b) To preserve precision of floating-point elevation values
c) Because JPEG doesn't work for elevation
d) To reduce file size

Question 3: What is the typical ground resolution of modern Danish aerial orthophotos?

a) 1 meter
b) 50 centimeters
c) 10 centimeters
d) 1 centimeter

Question 4: What is the difference between a Digital Terrain Model (DTM) and a Digital Surface Model (DSM)?

a) DTM includes buildings and vegetation, DSM is bare earth
b) DTM is bare earth, DSM includes buildings and vegetation
c) DTM is lower resolution than DSM
d) There is no difference

Question 5: For elevation data stored as encoded PNG, what value is typically reserved to represent NODATA?

a) 0
b) -9999
c) 65535 (maximum value for 16-bit)
d) NULL

Question 6: What is hillshade used for?

a) Calculating slope
b) Visualizing terrain relief with simulated lighting
c) Measuring elevation
d) Classifying land cover

Question 7: When creating a tile pyramid from a 10cm resolution source image, which statement is correct?

a) Create as many zoom levels as possible for flexibility
b) Only create zoom levels down to approximately 10cm/pixel
c) Always create exactly 18 zoom levels
d) Zoom levels don't matter for imagery

Question 8: For multispectral satellite imagery with multiple bands, how might you store the data in GeoPackage?

a) Store each band in a separate GeoPackage file
b) GeoPackage cannot store multispectral data
c) Store RGB composites or derived products (like NDVI) as PNG tiles
d) Store as text files with references

Question 9: What is a typical JPEG quality setting that balances file size and visual quality for aerial photography?

a) 100 (maximum)
b) 85
c) 50
d) 25

Question 10: Why might you create fewer zoom levels for elevation data compared to aerial photography?

a) Elevation data is less important
b) Elevation changes gradually, so extreme detail at every zoom isn't necessary
c) Elevation data takes more storage
d) GeoPackage limits elevation zoom levels

Question 11: When storing land cover classification data, what tile format is most appropriate?

a) JPEG for compression
b) PNG with indexed colors matching classification values
c) WebP for modern browsers
d) TIFF for precision

Question 12: What does the term "radiometric resolution" refer to in imagery?

a) The spatial resolution (cm per pixel)
b) The number of bits per pixel/band (e.g., 8-bit, 16-bit)
c) The number of spectral bands
d) The geometric accuracy

Question 13: For managing temporal aerial photography (multiple years), what is a good practice?

a) Overwrite old imagery with new imagery
b) Store each year in a separate tile table with clear naming
c) Mix all years in one table
d) Delete old imagery to save space

Question 14: What Python library is commonly used for reading and processing raster data before tiling?

a) Pandas
b) NumPy only
c) GDAL/OGR
d) Matplotlib

Question 15: When encoding elevation values into PNG pixels, what mathematical operation converts pixel values back to elevations?

a) pixel_value * 100
b) (pixel_value / max_pixel_value) * (max_elev - min_elev) + min_elev
c) pixel_value + elevation_offset
d) sqrt(pixel_value)


Answer Key

  1. b) JPEG provides better compression for photographic imagery - JPEG's lossy compression is optimized for photographs, resulting in much smaller files than PNG with acceptable quality.
  2. b) To preserve precision of floating-point elevation values - 16-bit PNG allows encoding elevation values with sufficient precision by scaling to the 0-65535 range.
  3. c) 10 centimeters - Modern Danish aerial orthophotos typically have 10cm (0.1m) ground resolution, though some older datasets may be 15-25cm.
  4. b) DTM is bare earth, DSM includes buildings and vegetation - DTM represents the bare ground surface, while DSM includes all above-ground features.
  5. c) 65535 (maximum value for 16-bit) - The maximum 16-bit value (65535) is typically reserved for NODATA, leaving 0-65534 for actual elevation values.
  6. b) Visualizing terrain relief with simulated lighting - Hillshade creates a grayscale representation of terrain with simulated sun illumination for visualization.
  7. b) Only create zoom levels down to approximately 10cm/pixel - Creating finer resolution tiles than the source data provides no additional real detail.
  8. c) Store RGB composites or derived products (like NDVI) as PNG tiles - Multi-band data is typically processed into RGB composites or analytical products for tile storage.
  9. b) 85 - JPEG quality 85 provides a good balance between visual quality and file size for most aerial photography applications.
  10. b) Elevation changes gradually, so extreme detail at every zoom isn't necessary - Unlike imagery with fine details, elevation surfaces are continuous and smooth, requiring fewer zoom levels.
  11. b) PNG with indexed colors matching classification values - PNG supports indexed color palettes, perfect for categorical raster data with discrete classes.
  12. b) The number of bits per pixel/band (e.g., 8-bit, 16-bit) - Radiometric resolution defines how many distinct values can be recorded (e.g., 8-bit = 256 values, 16-bit = 65536 values).
  13. b) Store each year in a separate tile table with clear naming - Separate tables with naming like orthophotos_2024, orthophotos_2022 make temporal data management clear and organized.
  14. c) GDAL/OGR - GDAL is the standard library for geospatial raster (and vector) data processing in Python and many other languages.
  15. b) (pixel_value / max_pixel_value) * (max_elev - min_elev) + min_elev - This formula scales the pixel value range back to the original elevation range.
Updated on Jan 19, 2026