Spaces:
Sleeping
Sleeping
| """BIT_CD: Bitemporal Image Transformer for change detection (vendored). | |
| Self-contained port of the ``base_transformer_pos_s4_dd8_dedim8`` network from | |
| https://github.com/justchenhao/BIT_CD (Chen et al., "Remote Sensing Image | |
| Change Detection with Transformers", TGRS 2021), rewritten without the einops | |
| dependency and using the torchvision ResNet18 backbone. | |
| Weights are NOT bundled. Place a LEVIR-CD checkpoint at | |
| ``app/cd_models/weights/bit_cd_levir.pth`` (or point ``BIT_CD_WEIGHTS`` at it). | |
| Both the raw training checkpoint (``model_G_state_dict`` key, from the | |
| official repo's best_ckpt.pt) and a plain converted state_dict are accepted. | |
| Used by ``model_inference`` as an optional second model ensembled with | |
| AdaptFormer when ``DETECTION_ENSEMBLE=on``. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| from pathlib import Path | |
| from typing import Optional | |
| import numpy as np | |
| logger = logging.getLogger(__name__) | |
| _BIT_MODEL = None | |
| _BIT_DEVICE = None | |
| _BIT_FAILED = False | |
| WEIGHTS_DIR = Path(__file__).resolve().parent / "weights" | |
| DEFAULT_WEIGHTS = WEIGHTS_DIR / "bit_cd_levir.pth" | |
| TILE_SIZE = 256 # LEVIR-CD native patch size | |
| def weights_path() -> Path: | |
| env = os.environ.get("BIT_CD_WEIGHTS", "").strip() | |
| return Path(env) if env else DEFAULT_WEIGHTS | |
| def weights_available() -> bool: | |
| p = weights_path() | |
| return p.is_file() and p.stat().st_size > 1_000_000 | |
| def _build_modules(): | |
| import torch | |
| from torch import nn | |
| class TwoLayerConv2d(nn.Sequential): | |
| def __init__(self, in_channels, out_channels, kernel_size=3): | |
| super().__init__( | |
| nn.Conv2d(in_channels, in_channels, kernel_size=kernel_size, | |
| padding=kernel_size // 2, stride=1, bias=False), | |
| nn.BatchNorm2d(in_channels), | |
| nn.ReLU(), | |
| nn.Conv2d(in_channels, out_channels, kernel_size=kernel_size, | |
| padding=kernel_size // 2, stride=1), | |
| ) | |
| class Residual(nn.Module): | |
| def __init__(self, fn): | |
| super().__init__() | |
| self.fn = fn | |
| def forward(self, x, **kw): | |
| return self.fn(x, **kw) + x | |
| class Residual2(nn.Module): | |
| def __init__(self, fn): | |
| super().__init__() | |
| self.fn = fn | |
| def forward(self, x, m, **kw): | |
| return self.fn(x, m, **kw) + x | |
| class PreNorm(nn.Module): | |
| def __init__(self, dim, fn): | |
| super().__init__() | |
| self.norm = nn.LayerNorm(dim) | |
| self.fn = fn | |
| def forward(self, x, **kw): | |
| return self.fn(self.norm(x), **kw) | |
| class PreNorm2(nn.Module): | |
| def __init__(self, dim, fn): | |
| super().__init__() | |
| self.norm = nn.LayerNorm(dim) | |
| self.fn = fn | |
| def forward(self, x, m, **kw): | |
| return self.fn(self.norm(x), self.norm(m), **kw) | |
| class FeedForward(nn.Module): | |
| def __init__(self, dim, hidden_dim): | |
| super().__init__() | |
| self.net = nn.Sequential( | |
| nn.Linear(dim, hidden_dim), nn.GELU(), nn.Dropout(0.0), | |
| nn.Linear(hidden_dim, dim), nn.Dropout(0.0), | |
| ) | |
| def forward(self, x): | |
| return self.net(x) | |
| def _split_heads(t, heads): | |
| b, n, hd = t.shape | |
| d = hd // heads | |
| return t.view(b, n, heads, d).permute(0, 2, 1, 3) | |
| def _merge_heads(t): | |
| b, h, n, d = t.shape | |
| return t.permute(0, 2, 1, 3).reshape(b, n, h * d) | |
| class Attention(nn.Module): | |
| def __init__(self, dim, heads=8, dim_head=64): | |
| super().__init__() | |
| inner = dim_head * heads | |
| self.heads = heads | |
| self.scale = dim ** -0.5 | |
| self.to_qkv = nn.Linear(dim, inner * 3, bias=False) | |
| self.to_out = nn.Sequential(nn.Linear(inner, dim), nn.Dropout(0.0)) | |
| def forward(self, x): | |
| q, k, v = self.to_qkv(x).chunk(3, dim=-1) | |
| q, k, v = (_split_heads(t, self.heads) for t in (q, k, v)) | |
| attn = (q @ k.transpose(-1, -2) * self.scale).softmax(dim=-1) | |
| return self.to_out(_merge_heads(attn @ v)) | |
| class CrossAttention(nn.Module): | |
| def __init__(self, dim, heads=8, dim_head=64, softmax=True): | |
| super().__init__() | |
| inner = dim_head * heads | |
| self.heads = heads | |
| self.scale = dim ** -0.5 | |
| self.softmax = softmax | |
| self.to_q = nn.Linear(dim, inner, bias=False) | |
| self.to_k = nn.Linear(dim, inner, bias=False) | |
| self.to_v = nn.Linear(dim, inner, bias=False) | |
| self.to_out = nn.Sequential(nn.Linear(inner, dim), nn.Dropout(0.0)) | |
| def forward(self, x, m): | |
| q = _split_heads(self.to_q(x), self.heads) | |
| k = _split_heads(self.to_k(m), self.heads) | |
| v = _split_heads(self.to_v(m), self.heads) | |
| dots = q @ k.transpose(-1, -2) * self.scale | |
| attn = dots.softmax(dim=-1) if self.softmax else dots | |
| return self.to_out(_merge_heads(attn @ v)) | |
| class Transformer(nn.Module): | |
| def __init__(self, dim, depth, heads, dim_head, mlp_dim): | |
| super().__init__() | |
| self.layers = nn.ModuleList([ | |
| nn.ModuleList([ | |
| Residual(PreNorm(dim, Attention(dim, heads, dim_head))), | |
| Residual(PreNorm(dim, FeedForward(dim, mlp_dim))), | |
| ]) for _ in range(depth) | |
| ]) | |
| def forward(self, x): | |
| for attn, ff in self.layers: | |
| x = attn(x) | |
| x = ff(x) | |
| return x | |
| class TransformerDecoder(nn.Module): | |
| def __init__(self, dim, depth, heads, dim_head, mlp_dim, softmax=True): | |
| super().__init__() | |
| self.layers = nn.ModuleList([ | |
| nn.ModuleList([ | |
| Residual2(PreNorm2(dim, CrossAttention(dim, heads, dim_head, softmax))), | |
| Residual(PreNorm(dim, FeedForward(dim, mlp_dim))), | |
| ]) for _ in range(depth) | |
| ]) | |
| def forward(self, x, m): | |
| for attn, ff in self.layers: | |
| x = attn(x, m) | |
| x = ff(x) | |
| return x | |
| class DilatedBasicBlock(nn.Module): | |
| """torchvision BasicBlock with dilation allowed (as in BIT_CD's resnet).""" | |
| expansion = 1 | |
| def __init__(self, inplanes, planes, stride=1, downsample=None, dilation=1): | |
| super().__init__() | |
| self.conv1 = nn.Conv2d(inplanes, planes, 3, stride=stride, | |
| padding=dilation, dilation=dilation, bias=False) | |
| self.bn1 = nn.BatchNorm2d(planes) | |
| self.relu = nn.ReLU(inplace=True) | |
| self.conv2 = nn.Conv2d(planes, planes, 3, padding=dilation, | |
| dilation=dilation, bias=False) | |
| self.bn2 = nn.BatchNorm2d(planes) | |
| self.downsample = downsample | |
| def forward(self, x): | |
| identity = x | |
| out = self.relu(self.bn1(self.conv1(x))) | |
| out = self.bn2(self.conv2(out)) | |
| if self.downsample is not None: | |
| identity = self.downsample(x) | |
| return self.relu(out + identity) | |
| class DilatedResNet18(nn.Module): | |
| """ResNet18 trunk with replace_stride_with_dilation=[False, True, True]. | |
| Same module naming as torchvision so BIT_CD checkpoints load directly. | |
| """ | |
| def __init__(self): | |
| super().__init__() | |
| self.inplanes = 64 | |
| self.dilation = 1 | |
| self.conv1 = nn.Conv2d(3, 64, 7, stride=2, padding=3, bias=False) | |
| self.bn1 = nn.BatchNorm2d(64) | |
| self.relu = nn.ReLU(inplace=True) | |
| self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) | |
| # replace_stride_with_dilation=[False, True, True]: layer2 keeps | |
| # stride 2; layers 3-4 trade stride for dilation. | |
| self.layer1 = self._make_layer(64, 2, stride=1, dilate=False) | |
| self.layer2 = self._make_layer(128, 2, stride=2, dilate=False) | |
| self.layer3 = self._make_layer(256, 2, stride=2, dilate=True) | |
| self.layer4 = self._make_layer(512, 2, stride=2, dilate=True) | |
| def _make_layer(self, planes, blocks, stride, dilate): | |
| downsample = None | |
| previous_dilation = self.dilation | |
| if dilate: | |
| self.dilation *= stride | |
| stride = 1 | |
| if stride != 1 or self.inplanes != planes: | |
| downsample = nn.Sequential( | |
| nn.Conv2d(self.inplanes, planes, 1, stride=stride, bias=False), | |
| nn.BatchNorm2d(planes), | |
| ) | |
| layers = [DilatedBasicBlock(self.inplanes, planes, stride, | |
| downsample, previous_dilation)] | |
| self.inplanes = planes | |
| for _ in range(1, blocks): | |
| layers.append(DilatedBasicBlock(planes, planes, dilation=self.dilation)) | |
| return nn.Sequential(*layers) | |
| class BITTransformer(nn.Module): | |
| """ResNet18 (4 stages, dilated) + token transformer + difference head.""" | |
| def __init__(self, output_nc=2, token_len=4, enc_depth=1, dec_depth=8, | |
| dim_head=64, decoder_dim_head=8): | |
| super().__init__() | |
| self.resnet = DilatedResNet18() | |
| self.relu = nn.ReLU() | |
| self.upsamplex2 = nn.Upsample(scale_factor=2) | |
| self.upsamplex4 = nn.Upsample(scale_factor=4, mode="bilinear") | |
| self.classifier = TwoLayerConv2d(in_channels=32, out_channels=output_nc) | |
| self.conv_pred = nn.Conv2d(256, 32, kernel_size=3, padding=1) | |
| self.token_len = token_len | |
| dim = 32 | |
| self.conv_a = nn.Conv2d(dim, token_len, kernel_size=1, padding=0, bias=False) | |
| self.pos_embedding = nn.Parameter(torch.randn(1, token_len * 2, dim)) | |
| self.transformer = Transformer(dim, enc_depth, 8, dim_head, 2 * dim) | |
| self.transformer_decoder = TransformerDecoder( | |
| dim, dec_depth, 8, decoder_dim_head, 2 * dim, softmax=True) | |
| def forward_single(self, x): | |
| x = self.resnet.conv1(x) | |
| x = self.resnet.bn1(x) | |
| x = self.resnet.relu(x) | |
| x = self.resnet.maxpool(x) | |
| x = self.resnet.layer1(x) | |
| x = self.resnet.layer2(x) | |
| x = self.resnet.layer3(x) # stages_num=4 -> stop before layer4 | |
| x = self.upsamplex2(x) | |
| return self.conv_pred(x) | |
| def _semantic_tokens(self, x): | |
| b, c, h, w = x.shape | |
| att = self.conv_a(x).view(b, self.token_len, -1).softmax(dim=-1) | |
| flat = x.view(b, c, -1) | |
| return torch.einsum("bln,bcn->blc", att, flat) | |
| def _decode(self, x, m): | |
| b, c, h, w = x.shape | |
| seq = x.flatten(2).transpose(1, 2) | |
| seq = self.transformer_decoder(seq, m) | |
| return seq.transpose(1, 2).reshape(b, c, h, w) | |
| def forward(self, x1, x2): | |
| x1 = self.forward_single(x1) | |
| x2 = self.forward_single(x2) | |
| t1 = self._semantic_tokens(x1) | |
| t2 = self._semantic_tokens(x2) | |
| tokens = torch.cat([t1, t2], dim=1) + self.pos_embedding | |
| tokens = self.transformer(tokens) | |
| t1, t2 = tokens.chunk(2, dim=1) | |
| x1 = self._decode(x1, t1) | |
| x2 = self._decode(x2, t2) | |
| x = torch.abs(x1 - x2) | |
| x = self.upsamplex4(x) | |
| return self.classifier(x) | |
| return BITTransformer | |
| def _extract_state_dict(checkpoint) -> dict: | |
| if isinstance(checkpoint, dict): | |
| for key in ("model_G_state_dict", "state_dict", "model_state_dict"): | |
| if key in checkpoint and isinstance(checkpoint[key], dict): | |
| checkpoint = checkpoint[key] | |
| break | |
| return {k[7:] if k.startswith("module.") else k: v for k, v in checkpoint.items()} | |
| def load_bit_model(): | |
| """Load BIT_CD with LEVIR weights. Returns None when unavailable.""" | |
| global _BIT_MODEL, _BIT_DEVICE, _BIT_FAILED | |
| if _BIT_MODEL is not None: | |
| return _BIT_MODEL | |
| if _BIT_FAILED or not weights_available(): | |
| return None | |
| try: | |
| import torch | |
| BITTransformer = _build_modules() | |
| model = BITTransformer() | |
| checkpoint = torch.load(weights_path(), map_location="cpu", weights_only=False) | |
| state = _extract_state_dict(checkpoint) | |
| missing, unexpected = model.load_state_dict(state, strict=False) | |
| real_missing = [k for k in missing if "num_batches_tracked" not in k] | |
| if len(real_missing) > 20: | |
| raise RuntimeError( | |
| f"checkpoint mismatch: {len(real_missing)} missing keys " | |
| f"(e.g. {real_missing[:4]}), {len(unexpected)} unexpected") | |
| if real_missing or unexpected: | |
| logger.warning("BIT_CD loaded with %d missing / %d unexpected keys", | |
| len(real_missing), len(unexpected)) | |
| _BIT_DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| model.to(_BIT_DEVICE) | |
| model.eval() | |
| _BIT_MODEL = model | |
| logger.info("BIT_CD loaded from %s on %s", weights_path(), _BIT_DEVICE) | |
| return _BIT_MODEL | |
| except Exception as exc: | |
| _BIT_FAILED = True | |
| logger.error("BIT_CD load failed: %s", exc) | |
| return None | |
| def bit_score_map(img1: np.ndarray, img2: np.ndarray) -> Optional[np.ndarray]: | |
| """Tiled BIT_CD change probability map in [0,1] at (h, w), or None.""" | |
| model = load_bit_model() | |
| if model is None: | |
| return None | |
| try: | |
| import torch | |
| from .model_utils import tiled_score_map | |
| # ImageNet-style normalization matching the BIT_CD training transforms | |
| mean = np.array([0.5, 0.5, 0.5], dtype=np.float32) | |
| std = np.array([0.5, 0.5, 0.5], dtype=np.float32) | |
| def _to_tensor(arr): | |
| t = (arr.astype(np.float32) / 255.0 - mean) / std | |
| return torch.from_numpy(t.transpose(2, 0, 1)).unsqueeze(0).to(_BIT_DEVICE) | |
| def _score_tile(t1, t2): | |
| with torch.no_grad(): | |
| logits = model(_to_tensor(t1), _to_tensor(t2)) | |
| prob = torch.softmax(logits, dim=1)[0, 1] | |
| return prob.cpu().numpy().astype(np.float32) | |
| return tiled_score_map(_score_tile, img1, img2, | |
| tile_size=TILE_SIZE, overlap=TILE_SIZE // 4) | |
| except Exception as exc: | |
| logger.warning("BIT_CD scoring failed: %s", exc) | |
| return None | |
| def bit_status() -> dict: | |
| return { | |
| "weightsPath": str(weights_path()), | |
| "weightsAvailable": weights_available(), | |
| "loaded": _BIT_MODEL is not None, | |
| "loadFailed": _BIT_FAILED, | |
| } | |