A model cannot read pixels, so the pixels have to become a sentence — and every choice in that conversion is an opportunity to imply precision the data does not have. This guide produces statements a model can quote safely, carrying the resolution, the date and the share of the shape that actually had data, as the reporting stage of vector-raster hybrid processing.
When to Use This Approach
Summarise whenever a raster informs an answer. Send the numbers, never the grid: a model handed a matrix of class codes will produce arithmetic that looks like analysis.
| Raster kind | Summary | Never |
|---|---|---|
| Categorical, e.g. land cover | Class proportions, top few | The full class histogram |
| Continuous, e.g. elevation | Median and range | The mean alone |
| Binary mask | Covered share | A count of pixels |
| Time series | Change between two dates | Every date |
| Any | With resolution, date, coverage | A bare percentage |
The last row is the one that turns a correct number into a usable one. “Sixty-eight per cent impermeable” is unquotable; the same figure with “from thirty-metre data captured in 2023, over 94% of the parcel” can be repeated by a reader without misleading anyone.
Implementation
The summariser computes proportions at a precision the sample supports, formats the top few classes, and appends the provenance.
import logging
from collections import Counter
from dataclasses import dataclass
from typing import Mapping, Sequence
log = logging.getLogger("raster_summary")
@dataclass(frozen=True)
class Statement:
text: str
classes: tuple[tuple[str, float], ...]
coverage: float
cells: int
def digits_for(cells: int) -> int:
"""Decimal places a proportion from this many cells can honestly carry."""
if cells < 100:
return 0
if cells < 10_000:
return 1
return 2
def proportions(values: Sequence[int], cells: int) -> list[tuple[int, float]]:
"""Class proportions, rounded to what the sample supports."""
if not values:
return []
digits = digits_for(cells)
counts = Counter(int(v) for v in values)
total = sum(counts.values())
return [(cls, round(100.0 * n / total, digits)) for cls, n in counts.most_common()]
def to_statement(name: str, values: Sequence[int], labels: Mapping[int, str],
pixel_size_m: float, captured_year: int | None,
coverage: float, top_n: int = 4) -> Statement:
"""One sentence a model can quote, with nothing implied that was not measured."""
cells = len(values)
if cells == 0:
return Statement(f"{name}: no usable raster values within the shape.", (), coverage, 0)
props = proportions(values, cells)
named = tuple((labels.get(cls, f"class {cls}"), pct) for cls, pct in props[:top_n])
body = ", ".join(f"{label} {pct:g}%" for label, pct in named)
if len(props) > top_n:
remainder = round(sum(p for _, p in props[top_n:]), digits_for(cells))
body += f", other classes {remainder:g}%"
date = f", captured {captured_year}" if captured_year else ""
cover = "" if coverage > 0.98 else f", from {coverage * 100:.0f}% of the shape"
text = f"{name}: {body} (from {pixel_size_m:g} m data{date}{cover})."
if coverage <= 0.5:
log.info("%s summarised from only %.0f%% coverage", name, coverage * 100)
return Statement(text, named, coverage, cells)
Reporting the remainder rather than silently truncating the class list is what keeps the proportions summing to a hundred. A statement listing four classes that add to seventy-one per cent invites the reader to wonder what the other twenty-nine were, and a model will occasionally invent an answer.
Continuous rasters need different statistics, and the difference matters more than it looks.
import statistics
def continuous_statement(name: str, values: Sequence[float], unit: str,
pixel_size_m: float, captured_year: int | None,
coverage: float) -> Statement:
"""Median and range, not the mean — edge pixels move a mean and not a median."""
if not values:
return Statement(f"{name}: no usable values within the shape.", (), coverage, 0)
ordered = sorted(values)
median = statistics.median(ordered)
lo, hi = ordered[int(0.05 * (len(ordered) - 1))], ordered[int(0.95 * (len(ordered) - 1))]
date = f", captured {captured_year}" if captured_year else ""
cover = "" if coverage > 0.98 else f", from {coverage * 100:.0f}% of the shape"
text = (f"{name}: median {median:.1f} {unit}, most values between {lo:.1f} and "
f"{hi:.1f} {unit} (from {pixel_size_m:g} m data{date}{cover}).")
return Statement(text, (), coverage, len(values))
A median and a percentile range survive the handful of edge cells that belong to something else, which a mean does not. Clipping a shape almost always includes a few pixels of a neighbouring surface, and those are exactly the values a mean moves toward.
Validation & Testing
def test_proportions_sum_to_a_hundred_with_the_remainder():
values = [1] * 50 + [2] * 30 + [3] * 10 + [4] * 6 + [5] * 4
st = to_statement("cover", values, LABELS, 10.0, 2023, 1.0, top_n=3)
assert "other classes" in st.text
def test_precision_matches_the_sample_size():
small = to_statement("cover", [1] * 30 + [2] * 10, LABELS, 30.0, 2023, 1.0)
assert "." not in small.text.split("%")[0].split()[-1]
def test_low_coverage_is_stated():
st = to_statement("cover", [1] * 100, LABELS, 10.0, 2023, coverage=0.62)
assert "62% of the shape" in st.text
def test_empty_values_produce_a_refusal_not_a_zero():
st = to_statement("cover", [], LABELS, 10.0, 2023, 1.0)
assert "no usable raster values" in st.text and st.classes == ()
def test_continuous_uses_median_not_mean():
values = [10.0] * 99 + [10_000.0] # one outlier from an adjacent surface
st = continuous_statement("elevation", values, "m", 10.0, 2023, 1.0)
assert "median 10.0" in st.text
The last test is the one that justifies the choice of statistic. A single pixel of a neighbouring surface moves a mean of a hundred values by a hundred metres and moves the median by nothing, and clipped shapes contain those pixels routinely.
Keep the statement object alongside the text rather than discarding it. The prose is what reaches the prompt, and the structured proportions are what a chart, an export or a later comparison needs — and regenerating them from the sentence is both awkward and a source of disagreement between the two surfaces.
Gotchas & Edge Cases
Nodata counted as a class. Missing values become a large proportion of something that was never measured. Exclude them from the denominator and report the coverage separately.
A class list truncated without a remainder. Proportions that do not sum to a hundred invite invention. Always state the remainder, even when it is small.
Percentages formatted with a fixed number of decimals. A formatting string with two decimal places reports 68.00% from forty cells, which is worse than reporting 68% because the zeros look like measurement. Derive the format from the sample size.
Coverage omitted when it is high but not complete. Ninety-four per cent coverage is worth stating and is easy to suppress with a threshold set too low. Suppress only when coverage is effectively total.
Class labels missing for codes present in the data. A statement mentioning “class 17” is unusable and usually means the class scheme has changed upstream. Fail the build on an unknown code rather than printing the number.
A statement built per pixel rather than per shape. Summaries are cheap and prompts are not; one sentence per shape is the unit, and a summary per tile or per band multiplies the prompt without adding information.
Frequently Asked Questions
How many classes should a statement name?
Three or four, plus the remainder. Beyond that the sentence stops being readable and the tail classes are usually below the precision the sample supports anyway. If a specific minor class matters to the question — a protected habitat type, say — name it explicitly regardless of its rank, and say why it is being singled out.
Should the statement include the class codes as well as the labels?
Only if something downstream needs to key on them, and then in metadata rather than in the sentence. Codes in prose are noise to a reader and an invitation to a model to reason about them numerically. The label is what carries meaning; the code is an implementation detail of the source product.
What should be said when two rasters disagree?
Both statements, with their dates and resolutions, and no attempt to reconcile them. Disagreement between a recent coarse product and an older fine one is information — it may be genuine change, or a resolution artefact — and averaging them destroys the only signal a reader has. If one must be preferred, that choice belongs in the catalog fitness scoring where its reasons are recorded.
Is it worth including a small chart instead of a sentence?
Not in a prompt. A model reads the sentence reliably and a chart not at all, and any chart would have to be described in text to be usable anyway. Charts are worth producing for the human-facing surface alongside the answer, built from the same statement object so the two cannot disagree.
Should the statement be generated by code or by the model?
By code, always, and quoted by the model. A model handed proportions and asked to phrase them will occasionally round differently, drop the coverage caveat, or convert a class name into something more readable and less accurate. Generating the sentence deterministically and instructing the model to quote it verbatim removes an entire category of drift between what was measured and what was said.
Related
- Up to the parent topic: Vector-Raster Hybrid Processing
- Aligning Raster Tiles with Vector Masks
- Related topic: Context-Window Optimization for Maps
- Related topic: Spatial Metadata and Catalog Indexing