SAMSpade / models /detector.py
AthithyaLogan's picture
feat: SamSpade dashboard deployment
eebd638 verified
Raw History Blame Contribute Delete
6.64 kB
"""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"])