Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,33 @@ Extends the [Label Extension](https://stac-extensions.github.io/label/v1.0.1/sch

**Required assets:** `chips`, `labels`

### How labels are encoded

The [Label Extension](https://github.com/stac-extensions/label) defines `label:properties` as
"the names of the property field(s) in each `Feature` of the label asset's `FeatureCollection`
that contains the classes", and a Class Object's `name` as "the property key within the asset's
each `Feature` corresponding to class labels". Both must therefore name a property that is
actually present on the features in `labels.geojson`.

`fair.datasets` writes those features. For each OSM feature it matches, it stamps a 1-based class
index onto `properties.label` (`LABEL_CLASS_PROPERTY`), reserving `0` for background. Items built
by `build_dataset_item` declare exactly that:

```json
"label:type": "vector",
"label:properties": ["label"],
"label:classes": [{"name": "label", "classes": [1, 2]}],
"label:description": "Chips and labels.\n\nClass values in `label` (0 = background): 1 = building=yes|house; 2 = highway=*."
```

Note that the `label_classes` argument is the OSM **filter** spec (tag key -> accepted tag values)
and is not what the label file contains, so it is not published verbatim. The tag mapping it
carries is preserved in `label:description`, which is the only field that can express it.

A caller that passes `label_properties` explicitly is describing its own label file and is left
alone: its `label_classes` and `label_description` are published unchanged. `label:properties` is
`null` for `label:type: "raster"`, per the extension.

## Local Model (Finetuned)

Extends the base model schema with training provenance: links to the base model and dataset, evaluation metrics, training duration, and ZenML artifact references.
Expand Down
5 changes: 3 additions & 2 deletions fair/datasets.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from typing import Any

from fair.stac.builders import _bbox_from_coords, _flatten_coords
from fair.stac.constants import LABEL_CLASS_PROPERTY

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -61,13 +62,13 @@ def _osm_filters(label_classes: list[dict[str, Any]], geometry_type: str) -> dic


def _stamp_class_label(feature: dict[str, Any], label_classes: list[dict[str, Any]]) -> int | None:
"""Stamp `properties.label = i` for the first matching class (1-based; 0 = background)."""
"""Stamp `properties[LABEL_CLASS_PROPERTY] = i` for the first match (1-based; 0 = background)."""
tags = (feature.get("properties") or {}).get("tags") or {}
for index, cls in enumerate(label_classes, start=1):
key = cls["name"]
values = cls["classes"]
if key in tags and (values == ["*"] or tags[key] in values):
feature.setdefault("properties", {})["label"] = index
feature.setdefault("properties", {})[LABEL_CLASS_PROPERTY] = index
return index
return None

Expand Down
41 changes: 38 additions & 3 deletions fair/stac/builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
BASE_MODEL_EXTENSIONS,
CONTAINER_REGISTRIES,
DATASET_EXTENSIONS,
LABEL_CLASS_PROPERTY,
LOCAL_MODEL_EXTENSIONS,
OCI_IMAGE_INDEX_TYPE,
)
Expand Down Expand Up @@ -231,6 +232,28 @@ def _raster_bands_from_model_input(mlm_input: list[dict[str, Any]]) -> list[dict
return bands or None


def _class_objects_for_stamped_labels(label_classes: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Class Objects describing what the label file actually carries.

`label_classes` as passed in is the OSM *filter* spec (tag key -> accepted tag
values). `fair.datasets._stamp_class_label` turns each matching feature into
``properties[LABEL_CLASS_PROPERTY] = <1-based index into that list>``, so the
published Class Object names that key and enumerates those indices. 0 is
reserved for background and never appears on a feature.
"""
return [{"name": LABEL_CLASS_PROPERTY, "classes": list(range(1, len(label_classes) + 1))}]


def _osm_class_mapping(label_classes: list[dict[str, Any]]) -> str:
"""Index -> OSM tag mapping, which `label:classes` alone cannot express."""
pairs = "; ".join(
f"{index} = {cls.get('name', LABEL_CLASS_PROPERTY)}"
f"={'|'.join(str(value) for value in (cls.get('classes') or ['*']))}"
for index, cls in enumerate(label_classes, start=1)
)
return f"Class values in `{LABEL_CLASS_PROPERTY}` (0 = background): {pairs}."


def build_dataset_item(
label_type: Literal["vector", "raster"],
label_tasks: list[str],
Expand Down Expand Up @@ -272,16 +295,26 @@ def build_dataset_item(

resolved_id = item_id if item_id is not None else _slugify(title)

# A caller that passes `label_properties` is describing its own label file and is
# left alone. Otherwise the labels came from `fair.datasets`, so declare the key
# that module stamps rather than "class", which appears on no feature it writes.
declares_own_labels = label_properties is not None
resolved_label_properties = (
label_properties if label_properties is not None else (None if label_type == "raster" else ["class"])
label_properties if declares_own_labels else (None if label_type == "raster" else [LABEL_CLASS_PROPERTY])
)
resolved_label_classes = label_classes
resolved_label_description = label_description
if not declares_own_labels and label_type == "vector" and label_classes:
resolved_label_classes = _class_objects_for_stamped_labels(label_classes)
if resolved_label_description is None:
resolved_label_description = f"{description}\n\n{_osm_class_mapping(label_classes)}"

properties: dict[str, Any] = {
"title": title,
"description": description,
"label:type": label_type,
"label:tasks": label_tasks,
"label:classes": label_classes,
"label:classes": resolved_label_classes,
"label:properties": resolved_label_properties,
"keywords": keywords,
"fair:user_id": user_id,
Expand All @@ -297,7 +330,9 @@ def build_dataset_item(
properties["fair:geometry_type"] = geometry_type
if license_id is not None:
properties["license"] = license_id
properties["label:description"] = label_description if label_description is not None else description
properties["label:description"] = (
resolved_label_description if resolved_label_description is not None else description
)
if label_methods is not None:
properties["label:methods"] = label_methods

Expand Down
7 changes: 7 additions & 0 deletions fair/stac/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@
]
DATASET_EXTENSIONS = [LABEL_SCHEMA, FILE_SCHEMA, VERSION_SCHEMA, FAIR_DATASET_SCHEMA]

# The GeoJSON property key that `fair.datasets._stamp_class_label` writes the class
# index onto, and therefore the key dataset items must name in `label:properties`
# and `label:classes[].name`. The Label extension requires both to name a property
# that is actually present on the label asset's features, so the writer and the
# STAC declaration have to read the same constant.
LABEL_CLASS_PROPERTY = "label"

# Backwards compat alias
MODEL_EXTENSIONS = BASE_MODEL_EXTENSIONS

Expand Down
79 changes: 79 additions & 0 deletions tests/test_builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
import pystac
import pytest

from fair.datasets import _stamp_class_label
from fair.stac.builders import (
_infer_runtime_media_type,
_slugify,
build_base_model_item,
build_dataset_item,
build_local_model_item,
)
from fair.stac.constants import LABEL_CLASS_PROPERTY

_GEOM = {"type": "Polygon", "coordinates": [[[0, -90], [180, -90], [180, 90], [0, 90], [0, -90]]]}
_MLM_INPUT = [
Expand Down Expand Up @@ -65,6 +67,10 @@ def geojson_path(tmp_path):
]

_PROVIDERS = [{"name": "HOTOSM", "roles": ["producer"], "url": "https://www.hotosm.org"}]
_OSM_LABEL_CLASSES = [
{"name": "building", "classes": ["yes", "house"]},
{"name": "highway", "classes": ["*"]},
]

_BASE_DEFAULTS: dict[str, Any] = {
"item_id": "example-unet",
Expand Down Expand Up @@ -98,6 +104,79 @@ def _base_model(**kw: Any) -> pystac.Item:
return build_base_model_item(**{**_BASE_DEFAULTS, **kw})


class TestDatasetLabelSemantics:
"""The Label extension requires `label:properties` and every
`label:classes[].name` to name a property that is present on the label
asset's features. These pin the item to what `fair.datasets` actually writes.
"""

def _item(self, geojson_path, **kw: Any) -> pystac.Item:
kwargs: dict[str, Any] = {
"label_type": "vector",
"label_tasks": ["segmentation"],
"label_classes": _OSM_LABEL_CLASSES,
"keywords": ["building"],
"chips_href": "chips/",
"labels_href": geojson_path,
"title": "Labelled dataset",
"description": "Chips and labels.",
"user_id": "osm-user-42",
"providers": _PROVIDERS,
}
kwargs.update(kw)
return build_dataset_item(**kwargs)

def test_declared_properties_exist_on_stamped_features(self, geojson_path):
"""Round-trip against the library's own materializer.

`_stamp_class_label` is what writes the label file this item describes, so
every key and value the item declares must be findable on its output.
"""
features = [
{"properties": {"osm_id": 1, "osm_type": "way", "tags": {"building": "house"}}},
{"properties": {"osm_id": 2, "osm_type": "way", "tags": {"highway": "residential"}}},
]
for feature in features:
assert _stamp_class_label(feature, _OSM_LABEL_CLASSES) is not None

properties = self._item(geojson_path).properties
for key in properties["label:properties"]:
for feature in features:
assert key in feature["properties"]
for class_object in properties["label:classes"]:
for feature in features:
assert class_object["name"] in feature["properties"]
assert feature["properties"][class_object["name"]] in class_object["classes"]

def test_osm_filter_sentinel_does_not_reach_the_catalog(self, geojson_path):
"""`["*"]` is a raw-data API wildcard, not a class value."""
assert "*" not in json.dumps(self._item(geojson_path).properties["label:classes"])

def test_osm_tag_mapping_is_preserved_in_label_description(self, geojson_path):
description = self._item(geojson_path).properties["label:description"]
assert "1 = building=yes|house" in description
assert "2 = highway=*" in description

def test_explicit_label_properties_are_left_alone(self, geojson_path):
"""A caller describing its own label file keeps full control."""
properties = self._item(
geojson_path,
label_properties=["building"],
label_description="Untouched.",
).properties
assert properties["label:properties"] == ["building"]
assert properties["label:classes"] == _OSM_LABEL_CLASSES
assert properties["label:description"] == "Untouched."

def test_raster_labels_still_declare_no_properties(self, geojson_path):
properties = self._item(geojson_path, label_type="raster").properties
assert properties["label:properties"] is None

def test_class_entry_without_values_does_not_raise(self, geojson_path):
properties = self._item(geojson_path, label_classes=[{"name": "building"}]).properties
assert properties["label:classes"] == [{"name": LABEL_CLASS_PROPERTY, "classes": [1]}]


class TestBuildDatasetItem:
def test_properties_assets_bbox(self, geojson_path):
item = build_dataset_item(
Expand Down
Loading