Spaces:
Sleeping
Sleeping
Download models/detector.py from AthithyaLogan/SAMSpade: direct link, hf CLI and curl.
- Browser
- Download file 6.64 kB
-
https://huggingface.co/spaces/AthithyaLogan/SAMSpade/resolve/main/models/detector.py
- Command line
-
hf download hf://spaces/AthithyaLogan/SAMSpade/models/detector.py
-
curl -L -o detector.py https://huggingface.co/spaces/AthithyaLogan/SAMSpade/resolve/main/models/detector.py
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 | |
| # ------------------------------------------------------------------ | |
| 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"]) | |