panagiotis427's picture
Deploy current UI (code + fixture + figures; gradio 6.18)
b4e56df verified
Raw
History Blame Contribute Delete
5.49 kB
"""
Dataset-agnostic core for temporal change retrieval.
Defines a structural Protocol (`TemporalDataset`) that every dataset loader
must satisfy, plus the lightweight value types (`PairKey`, `PairLabel`) that
move through the rest of the pipeline.
Using `typing.Protocol` (not ABC) so existing dict-based loaders can be
adapted via thin wrappers without forced inheritance.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import (
Dict,
Generator,
List,
NamedTuple,
Optional,
Protocol,
Tuple,
runtime_checkable,
)
import numpy as np
import pandas as pd
from PIL import Image
class PairKey(NamedTuple):
"""Identifies a single bi-temporal pair within a dataset.
Attributes:
location_id: Identifier of the spatial tile / AOI.
t1_key: Encoded time-step identifier for T1 (e.g. ``"2018-01-01"``,
``"t1"``, or ``"timepoint_0"``). The encoding is dataset-specific.
t2_key: Same as ``t1_key`` for the second observation.
"""
location_id: str
t1_key: str
t2_key: str
@dataclass
class PairLabel:
"""Ground-truth annotation for a bi-temporal pair.
Returned by `TemporalDataset.get_pair_label`. May be `None` for unlabeled
pairs; implementations should return `None` rather than a default-filled
instance to make label availability explicit.
Attributes:
change_type: Coarse string label such as ``"stable"`` or
``"forest->impervious_surface"``.
stable: True iff the pair shows no significant change (definition is
dataset-specific; for DEN we use ``total_change < stable_threshold``).
dominant_t1_class: Most frequent class in T1's label tile (if labels are
pixel-wise) or None.
dominant_t2_class: Same for T2.
class_change_mask_fraction: Per-class ``{"gained_fraction": float,
"lost_fraction": float}`` summary. Empty for snapshot datasets.
"""
change_type: str
stable: bool
dominant_t1_class: Optional[str] = None
dominant_t2_class: Optional[str] = None
class_change_mask_fraction: Dict[str, Dict[str, float]] = field(default_factory=dict)
@runtime_checkable
class TemporalDataset(Protocol):
"""Protocol for any dataset usable as a retrieval corpus.
Downstream code (`temporal_pairing.pair_temporally_from_dataset`, the
Gradio app, the training loop) only depends on this interface.
Required attributes:
name: Short identifier, e.g. ``"dynamic_earthnet"``, ``"levir_cc"``.
temporal_axis_type: One of ``"fixed-5"`` | ``"daily"`` | ``"snapshot"`` | ``"pair"``.
Hint for the pairing strategy; not enforced.
"""
name: str
temporal_axis_type: str
# Discovery
def list_locations(self) -> List[str]: ...
def list_pairs(self) -> List[PairKey]: ...
# Data access (lazy)
def load_image(self, location_id: str, t_key: str) -> Image.Image: ...
def load_pair_images(self, pair: PairKey) -> Tuple[Image.Image, Image.Image]: ...
# Metadata
def load_metadata(self) -> pd.DataFrame:
"""Return a DataFrame with the columns the rest of the pipeline relies on.
Required columns: ``location``, ``timestamp``, ``t_key``, ``pair_id``,
``dataset_name``.
"""
...
# Labels (optional per pair)
def get_pair_label(self, pair: PairKey) -> Optional[PairLabel]: ...
def metadata_from_dataset(dataset: TemporalDataset) -> pd.DataFrame:
"""Convenience helper: realise a dataset's metadata frame, validating it
has the columns the rest of the pipeline expects.
"""
df = dataset.load_metadata()
required = {"location", "timestamp", "t_key", "pair_id", "dataset_name"}
missing = required - set(df.columns)
if missing:
raise ValueError(
f"Dataset '{dataset.name}' metadata is missing required columns: {sorted(missing)}"
)
return df
def pair_iter_from_dataset(
dataset: TemporalDataset,
embedding_lookup: Dict[str, np.ndarray],
) -> Generator[Tuple[PairKey, np.ndarray, np.ndarray], None, None]:
"""Yield ``(pair_key, emb_t1, emb_t2)`` for every pair in the dataset.
``embedding_lookup`` is keyed by ``location_id`` with arrays shaped
``[n_timepoints, embed_dim]``. Per-location ordering must match the order
in which time-steps were originally extracted; the loader's `load_metadata`
DataFrame is authoritative for the index of each ``t_key`` within that
array.
"""
metadata = metadata_from_dataset(dataset)
# location_id -> {t_key: row index within that location}
rank_within_location: Dict[str, Dict[str, int]] = {}
for loc, grp in metadata.sort_values(["location", "timestamp"]).groupby("location"):
rank_within_location[loc] = {t_key: i for i, t_key in enumerate(grp["t_key"].tolist())}
for pair in dataset.list_pairs():
emb_array = embedding_lookup.get(pair.location_id)
if emb_array is None:
continue
ranks = rank_within_location.get(pair.location_id, {})
i1 = ranks.get(pair.t1_key)
i2 = ranks.get(pair.t2_key)
if i1 is None or i2 is None or i1 >= len(emb_array) or i2 >= len(emb_array):
continue
yield pair, emb_array[i1], emb_array[i2]