Spaces:
Running
Running
File size: 6,805 Bytes
eebd638 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | """ConstructionChangeDataset β loads (tile_2022, tile_2024, mask_2022, mask_2024) pairs.
Directory layout expected (produced by PDOKDownloader)::
tile_dir/
<site_id>/
tile_2022.tif
tile_2024.tif
mask_2022.png β binary mask from 2022 construction polygon
mask_2024.png β optional; falls back to mask_2022 if absent
meta.json β {"sample_type": "preexisting"|"new"|"negative"}
sample_type meanings:
preexisting β construction existed in 2022, may still be active in 2024
new β no construction in 2022 tile, but construction appeared by 2024
negative β non-construction tile (farmland, water, road, etc.)
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import numpy as np
import torch
from torch.utils.data import Dataset
from PIL import Image
from data.transforms import get_train_transforms, get_inference_transforms
try:
import rasterio
_HAS_RASTERIO = True
except ImportError:
_HAS_RASTERIO = False
logger = logging.getLogger(__name__)
class ConstructionChangeDataset(Dataset):
"""Paired (2022, 2024) aerial tile dataset for construction change detection."""
def __init__(
self,
tile_dir: str,
image_size: int = 1024,
augment: bool = True,
min_fg_pixels: int = 500,
split: Optional[str] = None,
val_split: float = 0.15,
seed: int = 42,
) -> None:
"""
Args:
tile_dir: Root directory of downloaded tiles.
image_size: Spatial size fed to the model.
augment: If True use training augmentations, else inference-only.
min_fg_pixels: Skip samples whose mask_2022 has fewer foreground px.
split: "train" | "val" | None (use all).
val_split: Fraction of sites to hold out for validation.
seed: Random seed for the train/val split.
"""
self.tile_dir = Path(tile_dir)
self.image_size = image_size
self.augment = augment
self.transforms = (
get_train_transforms(image_size) if augment
else get_inference_transforms(image_size)
)
all_samples = self._scan(min_fg_pixels)
if split is not None:
rng = np.random.default_rng(seed)
indices = rng.permutation(len(all_samples))
n_val = max(1, int(len(all_samples) * val_split))
val_idx = set(indices[:n_val].tolist())
if split == "val":
all_samples = [all_samples[i] for i in val_idx]
else:
all_samples = [s for i, s in enumerate(all_samples) if i not in val_idx]
self.samples: List[Dict] = all_samples
logger.info("Dataset (%s split): %d samples", split or "all", len(self.samples))
# ------------------------------------------------------------------
def __len__(self) -> int:
return len(self.samples)
def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
s = self.samples[idx]
tile_2024 = self._load_image(s["tile_b"]) # query year
tile_2022 = self._load_image(s["tile_a"]) # reference year
mask_2022 = self._load_mask(s["mask_a"])
mask_2024 = self._load_mask(s["mask_b"])
result = self.transforms(
image=tile_2024,
image2=tile_2022,
mask=mask_2024,
mask2=mask_2022,
)
return {
"tile_2024": result["image"], # [3,H,W]
"tile_2022": result["image2"], # [3,H,W]
"mask_2024": result["mask"].unsqueeze(0).float() / 255.0, # [1,H,W] {0,1}
"mask_2022": result["mask2"].unsqueeze(0).float() / 255.0, # [1,H,W] {0,1}
"sample_type": s["sample_type"],
"site_id": s["site_id"],
}
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _scan(self, min_fg_pixels: int) -> List[Dict]:
samples = []
for site_dir in sorted(self.tile_dir.iterdir()):
if not site_dir.is_dir():
continue
meta_path = site_dir / "meta.json"
meta = {}
if meta_path.exists():
with open(meta_path) as f:
meta = json.load(f)
year_a = meta.get("year_a", 2022)
year_b = meta.get("year_b", 2024)
tile_a = site_dir / f"tile_{year_a}.tif"
tile_b = site_dir / f"tile_{year_b}.tif"
mask_a = site_dir / f"mask_{year_a}.png"
# mask_2024 is optional β fall back to mask_2022 for preexisting sites
mask_b = site_dir / f"mask_{year_b}.png"
if not mask_b.exists():
mask_b = mask_a
if not (tile_a.exists() and tile_b.exists() and mask_a.exists()):
continue
# Skip tiny masks
mask_arr = np.array(Image.open(mask_a))
if mask_arr.sum() < min_fg_pixels * 255:
logger.debug("Skipping %s β mask too small", site_dir.name)
continue
samples.append({
"site_id": site_dir.name,
"tile_a": str(tile_a),
"tile_b": str(tile_b),
"mask_a": str(mask_a),
"mask_b": str(mask_b),
"sample_type": meta.get("sample_type", "preexisting"),
})
return samples
@staticmethod
def _load_image(path: str) -> np.ndarray:
"""Load image as HxWx3 uint8 numpy array.
Handles both standard images (.jpg, .png) and GeoTIFFs (.tif).
"""
if path.endswith(".tif") or path.endswith(".tiff"):
if _HAS_RASTERIO:
with rasterio.open(path) as src:
# Read first 3 bands (RGB)
n = min(src.count, 3)
bands = src.read(list(range(1, n + 1))) # [C, H, W]
img = np.transpose(bands, (1, 2, 0)) # [H, W, C]
if img.dtype != np.uint8:
img = np.clip(img, 0, 255).astype(np.uint8)
return img
return np.array(Image.open(path).convert("RGB"))
@staticmethod
def _load_mask(path: str) -> np.ndarray:
"""Load binary mask as HxW uint8 (values 0 or 255)."""
mask = np.array(Image.open(path).convert("L"))
return (mask > 127).astype(np.uint8) * 255
|