Generating ISO 19115 Metadata from GeoTIFF Headers

Use rasterio to read spatial attributes from a GeoTIFF’s embedded tags, map them to the MD_Metadata hierarchy with lxml, supplement missing administrative fields from agency defaults, and serialize a validated ISO 19139-encoded XML record — all in a single self-contained Python script.

This operation is non-trivial because GeoTIFF headers are optimized for spatial referencing, not catalog compliance. The TIFF tag model (ModelPixelScale, ModelTiepoint, GeoKeyDirectoryTag) stores projection and geometry faithfully, but the ISO 19115-1:2014 standard mandates dozens of additional elements — contact, resourceConstraints, lineage, useLimitation — that raster headers simply do not carry. Bridging this gap deterministically, without human re-entry, is the core engineering problem.

This page addresses that specific extraction-and-mapping task. It sits within the broader ISO 19115 Metadata Template Generation workflow, which covers the full template lifecycle from namespace setup to XSD-gated publication. Both fit inside the Automated Metadata Generation & Schema Mapping compliance domain, which governs how spatial datasets acquire machine-readable catalog records at ingestion time.

Header-to-Schema Mapping

The diagram below shows which GeoTIFF attributes rasterio can extract deterministically, which ISO 19115 elements they populate, and which mandatory fields require supplementation from external sources.

GeoTIFF Header to ISO 19115 Element Mapping A flow diagram showing three columns: GeoTIFF rasterio attributes on the left, ISO 19115 XML elements in the centre, and supplementation sources on the right. Arrows connect each source attribute to its target element, with dashed arrows indicating fields that require agency defaults or sidecar files. GeoTIFF / rasterio ISO 19115 element Supplementation source src.bounds src.crs.to_epsg() src.res (pixel size) src.count / src.driver AREA_OR_POINT tag EX_Geographic BoundingBox MD_ReferenceSystem / RS_Identifier MD_Resolution / abstract note MD_SpatialRepresentation TypeCode (grid/image) spatialRepresentationType contact, resourceConstraints, lineage, useLimitation (gco:nilReason if absent) Agency defaults / YAML Sidecar .json / DB lookup

The table below lists the exact rasterio attribute, the ISO 19115 element it maps to, and any transformation required before serialization.

GeoTIFF attribute ISO 19115 element Required transformation
src.bounds EX_GeographicBoundingBox Reproject to EPSG:4326 if CRS is projected
src.crs.to_epsg() MD_ReferenceSystem / RS_Identifier Fall back to crs.to_wkt() if EPSG lookup returns None
src.res abstract text / MD_Resolution Format as "{x} x {y} map units" string
src.count + src.driver MD_SpatialRepresentationTypeCode Encode as grid for multi-band; image for single-band imagery
src.tags()['AREA_OR_POINT'] spatialRepresentationType Default to Area when tag is absent
(not in header) contact, resourceConstraints, lineage Inject from agency defaults; mark absent values with gco:nilReason

Automated Python Implementation

The script below is self-contained and runnable. It uses rasterio 1.3+, lxml 4.9+, and pyproj 3.6+ for the projected-CRS reproject path. Install with pip install rasterio lxml pyproj.

"""
generate_iso19115.py — Extract GeoTIFF headers and write an ISO 19139-encoded
ISO 19115-1:2014 XML record. Requires: rasterio>=1.3, lxml>=4.9, pyproj>=3.6
"""
import os
from datetime import datetime
from typing import Optional

import rasterio
from rasterio.errors import RasterioError
from pyproj import Transformer, CRS
from lxml import etree
from lxml.builder import ElementMaker

# ── Namespace declarations ────────────────────────────────────────────────────
GMD = "http://www.isotc211.org/2005/gmd"
GCO = "http://www.isotc211.org/2005/gco"
XSI = "http://www.w3.org/2001/XMLSchema-instance"
NSMAP = {"gmd": GMD, "gco": GCO, "xsi": XSI}

G = ElementMaker(namespace=GMD, nsmap=NSMAP)
C = ElementMaker(namespace=GCO, nsmap=NSMAP)

# ── Agency defaults (replace with YAML/env-var config in production) ─────────
DEFAULTS = {
    "org_name": "Default Agency",
    "contact_email": "metadata@example.gov",
    "license": "CC-BY 4.0",
    "lineage": "Derived from operational raster archive; automated extraction.",
}


def _wgs84_bounds(src: rasterio.DatasetReader) -> tuple[float, float, float, float]:
    """Return (west, east, south, north) in WGS84 degrees.

    ISO 19115 EX_GeographicBoundingBox must be in EPSG:4326.  If the raster CRS
    is already geographic and equivalent to WGS84 this is a no-op; otherwise
    pyproj reprojects the four corner coordinates.
    """
    b = src.bounds
    src_crs = CRS(src.crs)
    wgs84 = CRS("EPSG:4326")

    if src_crs == wgs84:
        return b.left, b.right, b.bottom, b.top

    # always_xy=True ensures (longitude, latitude) output order
    t = Transformer.from_crs(src_crs, wgs84, always_xy=True)
    west, south = t.transform(b.left, b.bottom)
    east, north = t.transform(b.right, b.top)
    return west, east, south, north


def _nil(reason: str = "unknown") -> dict:
    """Return the xsi:nil + gco:nilReason attribute dict for absent mandatory fields."""
    return {
        f"{{{XSI}}}nil": "true",
        "nilReason": reason,
    }


def generate_iso19115(
    tiff_path: str,
    output_xml: str,
    defaults: Optional[dict] = None,
    sidecar_json: Optional[str] = None,
) -> None:
    """Generate an ISO 19139-encoded ISO 19115-1:2014 record from a GeoTIFF.

    Args:
        tiff_path:    Absolute path to the source GeoTIFF.
        output_xml:   Destination path for the XML record.
        defaults:     Dict overriding DEFAULTS (org_name, contact_email, license, lineage).
        sidecar_json: Optional path to a sidecar JSON file with extra metadata fields.
    """
    if not os.path.exists(tiff_path):
        raise FileNotFoundError(f"GeoTIFF not found: {tiff_path}")

    cfg = {**DEFAULTS, **(defaults or {})}

    # ── 1. Parse sidecar for supplemental fields ──────────────────────────────
    sidecar: dict = {}
    if sidecar_json and os.path.exists(sidecar_json):
        import json
        with open(sidecar_json) as fh:
            sidecar = json.load(fh)

    # ── 2. Extract raster header attributes ──────────────────────────────────
    try:
        with rasterio.open(tiff_path) as src:
            west, east, south, north = _wgs84_bounds(src)
            epsg: int = src.crs.to_epsg() or 0
            crs_code = str(epsg) if epsg else src.crs.to_wkt()
            crs_space = "EPSG" if epsg else "OGC"
            res_x, res_y = src.res
            band_count = src.count
            driver = src.driver           # e.g. "GTiff"
            area_or_point = src.tags().get("AREA_OR_POINT", "Area")
    except RasterioError as exc:
        raise RuntimeError(f"Failed to read GeoTIFF headers: {exc}") from exc

    spatial_repr = "grid" if band_count > 1 else "image"
    basename = os.path.basename(tiff_path)
    title = sidecar.get("title", os.path.splitext(basename)[0])
    abstract_text = sidecar.get(
        "abstract",
        (
            f"Raster dataset extracted from {basename}. "
            f"Resolution: {res_x:.6g} x {res_y:.6g} map units. "
            f"Bands: {band_count}. "
            f"Area/Point representation: {area_or_point}."
        ),
    )
    today = datetime.utcnow().strftime("%Y-%m-%d")
    org = sidecar.get("org_name", cfg["org_name"])
    email = sidecar.get("contact_email", cfg["contact_email"])
    license_text = sidecar.get("license", cfg["license"])
    lineage_text = sidecar.get("lineage", cfg["lineage"])

    # ── 3. Build ISO 19139 XML tree ───────────────────────────────────────────
    metadata = G.MD_Metadata(
        # File identifier uses the filename as a local unique key
        G.fileIdentifier(C.CharacterString(basename)),
        G.language(C.CharacterString("eng")),
        G.characterSet(G.MD_CharacterSetCode(codeListValue="utf8")),
        G.hierarchyLevel(G.MD_ScopeCode(codeListValue="dataset")),
        G.dateStamp(C.Date(today)),

        # ── Contact (responsible party) ───────────────────────────────────────
        G.contact(G.CI_ResponsibleParty(
            G.organisationName(C.CharacterString(org)),
            G.contactInfo(G.CI_Contact(
                G.address(G.CI_Address(
                    G.electronicMailAddress(C.CharacterString(email))
                ))
            )),
            G.role(G.CI_RoleCode(codeListValue="pointOfContact", codeList="")),
        )),

        # ── Identification ────────────────────────────────────────────────────
        G.identificationInfo(G.MD_DataIdentification(
            G.citation(G.CI_Citation(
                G.title(C.CharacterString(title)),
                G.date(G.CI_Date(
                    G.date(C.Date(today)),
                    G.dateType(G.CI_DateTypeCode(codeListValue="publication", codeList="")),
                )),
            )),
            G.abstract(C.CharacterString(abstract_text)),
            G.language(C.CharacterString("eng")),
            G.spatialRepresentationType(
                G.MD_SpatialRepresentationTypeCode(
                    codeListValue=spatial_repr, codeList=""
                )
            ),
            G.resourceConstraints(G.MD_LegalConstraints(
                G.useLimitation(C.CharacterString(license_text)),
                G.accessConstraints(
                    G.MD_RestrictionCode(codeListValue="license", codeList="")
                ),
            )),
            G.extent(G.EX_Extent(
                G.geographicElement(G.EX_GeographicBoundingBox(
                    G.extentTypeCode(C.Boolean("true")),
                    G.westBoundLongitude(C.Decimal(f"{west:.8f}")),
                    G.eastBoundLongitude(C.Decimal(f"{east:.8f}")),
                    G.southBoundLatitude(C.Decimal(f"{south:.8f}")),
                    G.northBoundLatitude(C.Decimal(f"{north:.8f}")),
                ))
            )),
        )),

        # ── Spatial reference system ──────────────────────────────────────────
        G.referenceSystemInfo(G.MD_ReferenceSystem(
            G.referenceSystemIdentifier(G.RS_Identifier(
                G.code(C.CharacterString(crs_code)),
                G.codeSpace(C.CharacterString(crs_space)),
            ))
        )),

        # ── Distribution format ───────────────────────────────────────────────
        G.distributionInfo(G.MD_Distribution(
            G.distributionFormat(G.MD_Format(
                G.name(C.CharacterString(driver)),
                G.version(C.CharacterString("1.0")),
            ))
        )),

        # ── Data quality / lineage ────────────────────────────────────────────
        G.dataQualityInfo(G.DQ_DataQuality(
            G.scope(G.DQ_Scope(
                G.level(G.MD_ScopeCode(codeListValue="dataset", codeList=""))
            )),
            G.lineage(G.LI_Lineage(
                G.statement(C.CharacterString(lineage_text))
            )),
        )),
    )

    # ── 4. Serialize with indentation and XML declaration ─────────────────────
    tree = etree.ElementTree(metadata)
    etree.indent(tree, space="  ")
    tree.write(output_xml, xml_declaration=True, encoding="UTF-8", pretty_print=True)
    print(f"Wrote: {output_xml}")


# ── CLI entry point ───────────────────────────────────────────────────────────
if __name__ == "__main__":
    import argparse

    ap = argparse.ArgumentParser(description="Generate ISO 19115 XML from a GeoTIFF.")
    ap.add_argument("tiff", help="Path to source GeoTIFF")
    ap.add_argument("output", help="Destination XML path")
    ap.add_argument("--org", default="Default Agency", help="Organisation name")
    ap.add_argument("--sidecar", help="Optional sidecar JSON with extra fields")
    args = ap.parse_args()

    generate_iso19115(
        args.tiff,
        args.output,
        defaults={"org_name": args.org},
        sidecar_json=args.sidecar,
    )

Key implementation decisions:

  • Projected CRS reprojection. The _wgs84_bounds helper uses pyproj.Transformer to convert corner coordinates to EPSG:4326. Catalog harvesters (GeoNetwork, CKAN) reject records where westBoundLongitude falls outside [-180, 180] — a common failure mode for UTM-projected rasters.
  • gco:nilReason discipline. Rather than omitting mandatory elements, the script includes contact, resourceConstraints, and lineage in all cases, populated from defaults. If a sidecar is absent, the lineage statement is at minimum a machine-generated provenance note rather than a structurally invalid record.
  • Namespace prefix consistency. Declaring nsmap at the root MD_Metadata element ensures GeoNetwork and ArcGIS catalog harvesters see clean gmd:, gco:, and xsi: prefixes throughout — a prerequisite for OGC CSW harvest.
  • etree.indent. Available from lxml 4.5+, this call normalises whitespace in one pass, producing the two-space indentation that ISO 19139 validation toolchains expect.

Validation & Pipeline Integration

Raw XML generation is only half the workflow. Before publishing to a catalog, validate the output against the OGC ISO 19139 XSD and run it through a Python assertion that confirms mandatory elements are present.

Step 1 — Download the OGC XSD bundle.

The ISO 19139 schemas are published by OGC. Unpack the bundle under a local path such as /opt/schemas/iso19139/ so the XSD can be referenced offline in CI.

Step 2 — Validate with lxml.

from lxml import etree

def validate_iso19139(xml_path: str, xsd_path: str) -> bool:
    """Return True if xml_path validates against the ISO 19139 XSD."""
    schema_doc = etree.parse(xsd_path)
    schema = etree.XMLSchema(schema_doc)
    doc = etree.parse(xml_path)
    valid = schema.validate(doc)
    if not valid:
        for err in schema.error_log:
            print(f"  [{err.line}] {err.message}")
    return valid

# Usage
ok = validate_iso19139("sample_metadata.xml", "/opt/schemas/iso19139/gmd/gmd.xsd")
assert ok, "ISO 19139 validation failed — fix errors before catalog ingestion"

Step 3 — Assert mandatory element presence.

XSD validation confirms structural correctness but will pass records where required business-logic fields are nil-filled. Add a lightweight assertion layer:

from lxml import etree

GMD = "http://www.isotc211.org/2005/gmd"
GCO = "http://www.isotc211.org/2005/gco"

def assert_mandatory_fields(xml_path: str) -> None:
    """Raise AssertionError if any mandatory ISO 19115 field is absent or nil."""
    doc = etree.parse(xml_path).getroot()

    def text(xpath: str) -> str:
        nodes = doc.xpath(xpath, namespaces={"gmd": GMD, "gco": GCO})
        return nodes[0].text.strip() if nodes and nodes[0].text else ""

    assert text(".//gmd:title/gco:CharacterString"), "Missing: title"
    assert text(".//gmd:abstract/gco:CharacterString"), "Missing: abstract"
    assert text(".//gmd:organisationName/gco:CharacterString"), "Missing: organisationName"
    assert text(".//gmd:westBoundLongitude/gco:Decimal"), "Missing: westBoundLongitude"
    assert text(".//gmd:code/gco:CharacterString"), "Missing: CRS code"
    print("Mandatory field check passed.")

assert_mandatory_fields("sample_metadata.xml")

Step 4 — CI integration (GitHub Actions).

For teams using spatial schema linting in CI, wire these checks into the same pipeline gate that validates setting up GitHub Actions for ISO 19115 validation:

# .github/workflows/metadata_check.yml
steps:
  - name: Generate and validate ISO 19115 metadata
    run: |
      python generate_iso19115.py data/sample.tif output/sample_metadata.xml \
          --org "Agency GIS Team" \
          --sidecar data/sample.json
      python -c "
      from validate import validate_iso19139, assert_mandatory_fields
      assert validate_iso19139('output/sample_metadata.xml', 'schemas/gmd/gmd.xsd')
      assert_mandatory_fields('output/sample_metadata.xml')
      "

Step 5 — Batch processing.

Raster I/O is disk-bound. For directories containing hundreds of GeoTIFFs, parallelise generation using concurrent.futures.ProcessPoolExecutor:

from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path

tiffs = list(Path("data/rasters").glob("*.tif"))

with ProcessPoolExecutor(max_workers=4) as pool:
    futures = {
        pool.submit(generate_iso19115, str(t), str(t.with_suffix(".xml"))): t
        for t in tiffs
    }
    for f in as_completed(futures):
        tiff = futures[f]
        try:
            f.result()
        except Exception as exc:
            print(f"FAILED {tiff.name}: {exc}")

Long-term Compliance Best Practices

  • Separate extraction from enrichment. Store the raw header-derived XML skeleton under a _raw/ directory before merging agency defaults. This preserves an auditable chain showing which values came from the file versus which were injected — a requirement in EU INSPIRE compliance audits and US federal data catalogue submissions.
  • Pin CRS fallback behaviour. When src.crs.to_epsg() returns None (common with custom projections, older datasets, or files processed by proprietary pipelines), the WKT fallback must be tested in CI. A WKT string is valid for the RS_Identifier.code element but will cause downstream catalog harvesters to skip CRS-based spatial searches unless explicitly handled.
  • Version your sidecar schema. The supplemental JSON files that carry contact, license, and lineage data are themselves compliance artefacts. Treat them as code: store them in version control, add a JSON Schema linter step (using the jsonschema library), and increment a schema_version field when the structure changes.
  • Use filename conventions that survive archive transfer. ISO 19115 fileIdentifier should be globally unique. For rasters that move across data centres or partner organisations, prefix the identifier with a registered domain or organization URN (urn:org.example:dataset:<uuid>) rather than a bare filename.
  • Automate re-generation on reprojection. Any pipeline step that reprojects, clips, or mosaics a raster changes bounds, crs, and potentially res. Wire a post-transform hook that re-runs the extraction and regenerates the XML record rather than inheriting stale metadata from the source file. This pattern is fundamental to FGDC to ISO 19115 conversion pipelines that batch-convert legacy North American records.
  • Guard against header stripping. Some compression commands (gdal_translate -co COMPRESS=DEFLATE -co TILED=YES) strip non-essential tags or alter the internal metadata domains. Always assert src.crs is not None and src.bounds is not None before proceeding with extraction; fail fast with a meaningful error rather than writing an empty or structurally broken XML record.