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.
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_boundshelper usespyproj.Transformerto convert corner coordinates to EPSG:4326. Catalog harvesters (GeoNetwork, CKAN) reject records wherewestBoundLongitudefalls outside[-180, 180]— a common failure mode for UTM-projected rasters. gco:nilReasondiscipline. Rather than omitting mandatory elements, the script includescontact,resourceConstraints, andlineagein 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
nsmapat the rootMD_Metadataelement ensures GeoNetwork and ArcGIS catalog harvesters see cleangmd:,gco:, andxsi:prefixes throughout — a prerequisite for OGC CSW harvest. etree.indent. Available fromlxml4.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()returnsNone(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 theRS_Identifier.codeelement 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
jsonschemalibrary), and increment aschema_versionfield when the structure changes. - Use filename conventions that survive archive transfer. ISO 19115
fileIdentifiershould 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 potentiallyres. 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 assertsrc.crs is not Noneandsrc.bounds is not Nonebefore proceeding with extraction; fail fast with a meaningful error rather than writing an empty or structurally broken XML record.
Related
- ISO 19115 Metadata Template Generation — the parent workflow covering full template lifecycle, namespace setup, and XSD-gated publication
- Automated Metadata Generation & Schema Mapping — the compliance domain this page belongs to
- FGDC to ISO 19115 Conversion Pipelines — batch conversion of legacy FGDC records to the same ISO 19115 XML output format
- Validating FGDC Metadata Against XML Schemas — XSD validation patterns applicable to the ISO 19139 validation step above
- Setting Up GitHub Actions for ISO 19115 Validation — CI gate configuration that pairs with the validation snippet in this page