Spaces:
Sleeping
Sleeping
File size: 6,639 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 | """ConstructionChangeDetector β top-level model.
Architecture:
1. Shared frozen SAM2 Hiera+FPN image encoder encodes both the 2022 and 2024 tiles.
2. AppearanceScorer: cosine-similarity of 2024 features vs K corpus prototypes
β appear_map [B, 1, H_f, W_f]
3. ChangeScorer: feature delta between 2022 and 2024 features
β change_map [B, 1, H_f, W_f]
4. Spatial routing via the rasterised 2022 construction mask:
preexisting = appear_map Γ mask_2022
new_sites = appear_map Γ change_map Γ (1 β mask_2022)
combined = preexisting + new_sites β no pixel counted twice
This ensures the change signal is NOT used for preexisting sites (where it
would be low and would suppress valid detections) and IS required for new
sites (to avoid flagging stable non-construction areas).
Trainable: AppearanceScorer, ChangeScorer
Frozen: SAM2 image encoder (Hiera backbone + FPN neck)
"""
from __future__ import annotations
import logging
from typing import Dict, Optional
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch import Tensor
from models.appearance_scorer import AppearanceScorer
from models.change_scorer import ChangeScorer
logger = logging.getLogger(__name__)
class ConstructionChangeDetector(nn.Module):
def __init__(self, k_prototypes: int = 20) -> None:
super().__init__()
# --- SAM2 image encoder (loaded lazily to avoid import side-effects) ---
self.image_encoder: Optional[nn.Module] = None
self._k = k_prototypes
# --- Trainable heads ---
self.appearance_scorer = AppearanceScorer(
feature_dim=256, num_prototypes=k_prototypes
)
self.change_scorer = ChangeScorer(feature_dim=256)
# ------------------------------------------------------------------
# Initialisation helpers
# ------------------------------------------------------------------
def load_sam2(self, hf_id: str = "facebook/sam2.1-hiera-large") -> None:
"""Load the SAM2 image encoder from HuggingFace and freeze it.
Call this once before training / inference.
Args:
hf_id: HuggingFace model ID for SAM2 (avoids local Hydra config).
"""
from sam2.build_sam import build_sam2_hf # type: ignore
device = next(self.parameters()).device if len(list(self.parameters())) > 0 else torch.device("cpu")
logger.info("Loading SAM2 encoder from HuggingFace: %s (device=%s) β¦", hf_id, device)
sam2 = build_sam2_hf(hf_id, device=str(device))
self.sam2_model = sam2 # full model β needed for SAM2ImagePredictor
self.image_encoder = sam2.image_encoder
# Freeze completely β only AppearanceScorer and ChangeScorer are trained
for p in self.image_encoder.parameters():
p.requires_grad_(False)
self.image_encoder.eval()
logger.info("SAM2 encoder frozen (%d parameters).",
sum(p.numel() for p in self.image_encoder.parameters()))
# ------------------------------------------------------------------
# Core methods
# ------------------------------------------------------------------
@torch.no_grad()
def encode_image(self, image: Tensor) -> Tensor:
"""Encode a batch of images through the frozen SAM2 encoder.
Args:
image: [B, 3, H, W] float tensor, ImageNet-normalised.
Returns:
vision_features: [B, 256, H/16, W/16]
"""
assert self.image_encoder is not None, (
"Call load_sam2() before encode_image()."
)
out = self.image_encoder(image)
return out["vision_features"]
def forward(
self,
tile_2022: Tensor, # [B, 3, H, W]
tile_2024: Tensor, # [B, 3, H, W]
mask_2022: Tensor, # [B, 1, H, W] rasterised 2022 construction polygons
prototypes: Tensor, # [K, 256] corpus prototypes (loaded from .pt)
) -> Dict[str, Tensor]:
"""Full forward pass.
Returns a dict with three probability maps β all at the same spatial
resolution as the input tiles [B, 1, H, W]:
``prob_map`` β combined detection (primary output for GeoTIFF export)
``preexisting`` β Path A: ongoing construction from 2022
``new_sites`` β Path B: construction that started after 2022
"""
B, _, H, W = tile_2024.shape
# Move prototypes to the same device
prototypes = prototypes.to(tile_2024.device)
# --- Encode (frozen, no gradients needed) ---
feats_2022 = self.encode_image(tile_2022) # [B, 256, H_f, W_f]
feats_2024 = self.encode_image(tile_2024) # [B, 256, H_f, W_f]
_, _, Hf, Wf = feats_2022.shape
# --- Downsample routing mask to feature resolution ---
mask_feat = F.interpolate(
mask_2022.float(), size=(Hf, Wf), mode="nearest"
) # [B, 1, H_f, W_f] binary
# --- Score heads ---
appear = self.appearance_scorer(feats_2024, prototypes) # [B,1,H_f,W_f]
change = self.change_scorer(feats_2022, feats_2024) # [B,1,H_f,W_f]
# --- Spatial routing (disjoint paths, no pixel counted twice) ---
preexisting = appear * mask_feat # PATH A
new_sites = appear * change * (1.0 - mask_feat) # PATH B
combined = preexisting + new_sites # [B,1,H_f,W_f]
# --- Upsample to input resolution ---
def _up(x: Tensor) -> Tensor:
return F.interpolate(x, size=(H, W), mode="bilinear", align_corners=False)
return {
"prob_map": _up(combined), # primary
"preexisting": _up(preexisting), # band 2
"new_sites": _up(new_sites), # band 3
}
# ------------------------------------------------------------------
# Checkpoint helpers (save / load only trainable weights)
# ------------------------------------------------------------------
def state_dict_trainable(self) -> dict:
"""State dict containing only trainable parameters."""
return {
"appearance_scorer": self.appearance_scorer.state_dict(),
"change_scorer": self.change_scorer.state_dict(),
}
def load_trainable_state_dict(self, state: dict) -> None:
self.appearance_scorer.load_state_dict(state["appearance_scorer"])
self.change_scorer.load_state_dict(state["change_scorer"])
|