| """
|
| 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
|
|
|
|
|
| def list_locations(self) -> List[str]: ...
|
| def list_pairs(self) -> List[PairKey]: ...
|
|
|
|
|
| def load_image(self, location_id: str, t_key: str) -> Image.Image: ...
|
| def load_pair_images(self, pair: PairKey) -> Tuple[Image.Image, Image.Image]: ...
|
|
|
|
|
| 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``.
|
| """
|
| ...
|
|
|
|
|
| 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)
|
|
|
| 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]
|
|
|