CD-Models / setup.py
Dineth Perera
Publish tested dataset winners and benchmark rankings
ce209f5
Raw
History Blame Contribute Delete
19.3 kB
#!/usr/bin/env python3
"""
setup.py - One-command setup for the CD-Models benchmark suite.
Clones all model repositories, downloads pretrained backbone weights,
verifies the Python environment, and optionally prepares datasets.
Usage:
python setup.py # full setup (clone + weights + env check)
python setup.py --skip-weights # clone only, skip weight downloads
python setup.py --env-check-only # only check Python packages
python setup.py --status # print current setup status without changes
python setup.py --dataset levir_cd # prepare list files for a specific dataset
"""
from __future__ import annotations
import argparse
import hashlib
import importlib
import os
import subprocess
import sys
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent
DATASET_CONFIGS = ROOT / "configs" / "datasets"
MODEL_REPOS = ROOT / "model_repos"
IMG_EXTS = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp"}
ROOT_LEVEL_REPOS = {
"BIT_CD",
"ChangeFormer",
"STANet",
"Change3D",
"BiFA",
"CDMamba",
"RSM-CD",
"SChanger",
"IFNet",
"Siam-NestedUNet",
}
REQUIRED_PACKAGES = [
("torch", "torch", "all models", False),
("torchvision", "torchvision", "all models", False),
("numpy", "numpy", "all models", True),
("PIL", "Pillow", "all models", True),
("cv2", "opencv-python", "all models", True),
("yaml", "pyyaml", "all models", True),
("tqdm", "tqdm", "weight downloads", True),
("timm", "timm", "TinyCD, ELGC-Net, ChangeFormer", True),
("einops", "einops", "BIT_CD, ChangeFormer", True),
("sklearn", "scikit-learn", "metrics", True),
("gdown", "gdown", "GDrive weight fallback", True),
("mmengine", "mmengine==0.10.1", "ChangeMamba, Changer", False),
("mmcv", "mmcv==2.1.0", "ChangeMamba, Changer", False),
("mmseg", "mmsegmentation==1.2.2", "ChangeMamba, Changer", False),
]
CONFIRMED_REPOS = {
"BIT_CD": "https://github.com/justchenhao/BIT_CD.git",
"ChangeFormer": "https://github.com/wgcban/ChangeFormer.git",
"STANet": "https://github.com/justchenhao/STANet.git",
"Change3D": "https://github.com/Z-Zheng/Change3D.git",
"BiFA": "https://github.com/zmoka-zht/BiFA.git",
"CDMamba": "https://github.com/zmoka-zht/CDMamba.git",
"RSM-CD": "https://github.com/walking-shadow/Official_Remote_Sensing_Mamba.git",
"SChanger": None,
"IFNet": "https://github.com/GeoZcx/A-deeply-supervised-image-fusion-network-for-change-detection.git",
"Siam-NestedUNet": "https://github.com/likyoo/Siam-NestedUNet.git",
"fully_convolutional_change_detection": "https://github.com/rcdaudt/fully_convolutional_change_detection.git",
"ChangeMamba": "https://github.com/ChenHongruixuan/ChangeMamba.git",
"DSAMNet": "https://github.com/liumency/DSAMNet.git",
"Tiny_model_4_CD": "https://github.com/AndreaCodegoni/Tiny_model_4_CD.git",
"HANet-CD": "https://github.com/ChengxiHAN/HANet-CD.git",
"CGNet-CD": "https://github.com/ChengxiHAN/CGNet-CD.git",
"open-cd": "https://github.com/likyoo/open-cd.git",
"elgcnet": "https://github.com/techmn/elgcnet.git",
}
ZENODO_WEIGHTS = {
"vmamba_tiny": {
"record_id": "14037770",
"filename": "vssmtiny_dp01_ckpt_epoch_292.pth",
"dest_dir": MODEL_REPOS / "ChangeMamba" / "pretrained_weight",
"sha256": None,
"notes": "VMamba-Tiny for ChangeMamba",
},
"vmamba_small": {
"record_id": "14037770",
"filename": "vssmsmall_dp03_ckpt_epoch_238.pth",
"dest_dir": MODEL_REPOS / "ChangeMamba" / "pretrained_weight",
"sha256": None,
"notes": "VMamba-Small for ChangeMamba",
},
"vmamba_base": {
"record_id": "14037770",
"filename": "vssmbase_dp06_ckpt_epoch_241.pth",
"dest_dir": MODEL_REPOS / "ChangeMamba" / "pretrained_weight",
"sha256": None,
"notes": "VMamba-Base for ChangeMamba",
},
}
TIMM_WEIGHTS_TO_PREFETCH = [
"efficientnet_b4",
"mit_b0",
"mit_b1",
"mit_b4",
]
TORCHVISION_WEIGHTS_TO_PREFETCH = [
("resnet18", "IMAGENET1K_V1"),
("resnet50", "IMAGENET1K_V1"),
("vgg16", "IMAGENET1K_V1"),
]
TIMM_ALIASES = {
"efficientnet_b4": ("efficientnet_b4", "tf_efficientnet_b4", "tf_efficientnet_b4_ns"),
"mit_b0": ("mit_b0", "segformer_b0"),
"mit_b1": ("mit_b1", "segformer_b1"),
"mit_b4": ("mit_b4", "segformer_b4"),
}
def run(cmd: list[str]) -> subprocess.CompletedProcess:
return subprocess.run(cmd, capture_output=True, text=True, check=False)
def import_status(import_name: str) -> tuple[bool, str]:
try:
module = importlib.import_module(import_name)
except Exception as exc:
return False, str(exc)
return True, getattr(module, "__version__", "present")
def pip_install(package: str) -> bool:
print(f" [INSTALL] pip install {package}")
result = run([sys.executable, "-m", "pip", "install", package])
if result.returncode != 0:
print(f" [FAIL] {package}: {result.stderr[-500:]}")
return False
print(f" [OK] {package} installed")
return True
def check_environment(status_only: bool = False) -> dict[str, int]:
print("\nPYTHON ENVIRONMENT")
print("------------------")
print(f"Python executable: {sys.executable}")
print(f"Python version: {sys.version.split()[0]}")
present = missing = installed = warnings = 0
for import_name, pip_name, required_for, auto_install in REQUIRED_PACKAGES:
ok, detail = import_status(import_name)
if ok:
present += 1
print(f" [OK] {import_name:<12} {detail:<18} required for {required_for}")
continue
missing += 1
if auto_install and not status_only:
if pip_install(pip_name):
installed += 1
continue
if not auto_install:
warnings += 1
print(f" [WARN] {import_name:<12} missing; install manually: pip install {pip_name}")
else:
print(f" [MISSING] {import_name:<12} install: pip install {pip_name}")
if warnings:
print("\nVersion-sensitive packages are intentionally not auto-installed.")
print("For ChangeMamba / Changer:")
print(" pip install mmengine==0.10.1")
print(" pip install mmcv==2.1.0 -f https://download.openmmlab.com/mmcv/dist/cu124/torch2.6/index.html")
print(" pip install mmsegmentation==1.2.2 mmdet==3.3.0 mmpretrain==1.2.0")
return {"present": present, "missing": missing, "installed": installed, "warnings": warnings}
def clone_repo(name: str, url: str | None, dest_dir: Path = MODEL_REPOS, status_only: bool = False) -> bool:
dest = (ROOT / name) if name in ROOT_LEVEL_REPOS else (dest_dir / name)
if dest.is_dir():
print(f" [OK] {name} already cloned")
return True
if url is None:
print(f" [SKIP] {name} - URL not confirmed, clone manually")
return False
if status_only:
print(f" [MISSING] {name} -> {url}")
return False
print(f" [CLONE] {name}...")
dest.parent.mkdir(parents=True, exist_ok=True)
result = run(["git", "clone", "--depth", "1", url, str(dest)])
if result.returncode == 0:
print(f" [OK] {name} cloned")
return True
print(f" [FAIL] {name}: {result.stderr[:300]}")
return False
def clone_repositories(status_only: bool = False) -> dict[str, int]:
print("\nMODEL REPOSITORIES")
print("------------------")
ok = missing = skipped = 0
for name, url in CONFIRMED_REPOS.items():
result = clone_repo(name, url, status_only=status_only)
if result:
ok += 1
elif url is None:
skipped += 1
else:
missing += 1
return {"ok": ok, "missing": missing, "skipped": skipped, "total": len(CONFIRMED_REPOS)}
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def download_zenodo_weight(
record_id: str,
filename: str,
dest_dir: Path,
expected_sha256: str | None = None,
status_only: bool = False,
) -> Path | None:
dest_dir.mkdir(parents=True, exist_ok=True)
dest_path = dest_dir / filename
if dest_path.exists():
if expected_sha256:
actual = sha256_file(dest_path)
if actual != expected_sha256:
print(f" [FAIL] {filename} sha256 mismatch")
return None
print(f" [OK] {filename} already present")
return dest_path
if status_only:
print(f" [MISSING] {filename}")
return None
try:
from tqdm import tqdm
except Exception:
tqdm = None
url = f"https://zenodo.org/records/{record_id}/files/{filename}"
print(f" [DOWNLOAD] {filename} from Zenodo record {record_id}")
class ProgressHook:
def __init__(self) -> None:
self.pbar = None
def __call__(self, count: int, block_size: int, total_size: int) -> None:
if tqdm is None:
return
if self.pbar is None:
self.pbar = tqdm(total=total_size, unit="B", unit_scale=True, desc=filename)
self.pbar.update(count * block_size - self.pbar.n)
hook = ProgressHook()
try:
urllib.request.urlretrieve(url, dest_path, reporthook=hook)
if hook.pbar:
hook.pbar.close()
if expected_sha256:
actual = sha256_file(dest_path)
if actual != expected_sha256:
dest_path.unlink(missing_ok=True)
print(f" [FAIL] {filename}: sha256 mismatch")
return None
print(f" [OK] {filename} downloaded")
return dest_path
except Exception as exc:
if hook.pbar:
hook.pbar.close()
dest_path.unlink(missing_ok=True)
print(f" [FAIL] {filename}: {exc}")
return None
def prefetch_timm_weights(model_name: str, status_only: bool = False) -> bool:
ok, detail = import_status("timm")
if not ok:
print(f" [SKIP] timm:{model_name} - timm missing ({detail})")
return False
if status_only:
print(f" [CHECK] timm:{model_name} cache status cannot be proven without instantiation")
return True
import timm
candidates = TIMM_ALIASES.get(model_name, (model_name,))
for candidate in candidates:
try:
model = timm.create_model(candidate, pretrained=True, num_classes=0)
del model
print(f" [OK] timm {model_name} via {candidate}")
return True
except RuntimeError as exc:
if "Unknown model" not in str(exc):
print(f" [FAIL] timm {model_name}: {exc}")
return False
print(f" [WARN] timm {getattr(timm, '__version__', 'unknown')} does not provide {model_name}")
return False
def prefetch_torchvision_weights(model_name: str, weight_name: str, status_only: bool = False) -> bool:
ok, detail = import_status("torchvision.models")
if not ok:
print(f" [SKIP] torchvision:{model_name} - torchvision missing ({detail})")
return False
if status_only:
print(f" [CHECK] torchvision:{model_name} cache status cannot be proven without instantiation")
return True
import torchvision.models as models
builder = getattr(models, model_name)
enum_name = "".join(part.capitalize() for part in model_name.split("_")) + "_Weights"
weights_enum = getattr(models, enum_name, None)
try:
if weights_enum is not None:
weights = getattr(weights_enum, weight_name, weights_enum.DEFAULT)
model = builder(weights=weights)
else:
model = builder(pretrained=True)
del model
print(f" [OK] torchvision {model_name}")
return True
except Exception as exc:
print(f" [FAIL] torchvision {model_name}: {exc}")
return False
def download_weights(status_only: bool = False) -> dict[str, int]:
print("\nPRETRAINED WEIGHTS")
print("------------------")
ok = missing = 0
for spec in ZENODO_WEIGHTS.values():
path = download_zenodo_weight(
spec["record_id"],
spec["filename"],
spec["dest_dir"],
expected_sha256=spec["sha256"],
status_only=status_only,
)
ok += int(path is not None)
missing += int(path is None)
for model_name in TIMM_WEIGHTS_TO_PREFETCH:
ok += int(prefetch_timm_weights(model_name, status_only=status_only))
for model_name, weight_name in TORCHVISION_WEIGHTS_TO_PREFETCH:
ok += int(prefetch_torchvision_weights(model_name, weight_name, status_only=status_only))
return {"ok": ok, "missing": missing, "total": len(ZENODO_WEIGHTS) + len(TIMM_WEIGHTS_TO_PREFETCH) + len(TORCHVISION_WEIGHTS_TO_PREFETCH)}
def parse_simple_yaml(path: Path) -> dict[str, object]:
data: dict[str, object] = {}
stack: list[tuple[int, dict[str, object]]] = [(-1, data)]
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.split("#", 1)[0].rstrip()
if not line.strip() or ":" not in line:
continue
indent = len(line) - len(line.lstrip(" "))
key, value = line.strip().split(":", 1)
while stack and indent <= stack[-1][0]:
stack.pop()
current = stack[-1][1]
value = value.strip()
if not value:
child: dict[str, object] = {}
current[key] = child
stack.append((indent, child))
continue
if value.startswith("[") and value.endswith("]"):
current[key] = [x.strip().strip("'\"") for x in value[1:-1].split(",") if x.strip()]
elif value.isdigit():
current[key] = int(value)
else:
current[key] = value.strip("'\"")
return data
def dataset_root_from_config(cfg: dict[str, object]) -> Path:
value = str(cfg.get("data_root", ""))
data_root = os.environ.get("DATA_ROOT", str(ROOT.parent / "Datasets"))
return Path(value.replace("${DATA_ROOT}", data_root)).expanduser()
def prepare_dataset_lists(data_root: Path, image_a_folder: str = "A", splits: tuple[str, ...] = ("train", "val", "test")) -> dict[str, int]:
list_dir = data_root / "list"
list_dir.mkdir(parents=True, exist_ok=True)
counts: dict[str, int] = {}
for split in splits:
out = list_dir / f"{split}.txt"
if out.exists():
counts[split] = sum(1 for _ in out.open("r", encoding="utf-8"))
print(f" [OK] {out} already exists ({counts[split]} entries)")
continue
split_dir = data_root / split
img_dir = None
for candidate in (image_a_folder, "A", "T1", "t1", "img", "images", "image"):
if (split_dir / candidate).is_dir():
img_dir = split_dir / candidate
break
if img_dir is None:
counts[split] = 0
print(f" [WARN] no image folder found under {split_dir}")
continue
names = sorted(p.name for p in img_dir.iterdir() if p.is_file() and p.suffix.lower() in IMG_EXTS)
out.write_text("\n".join(names) + ("\n" if names else ""), encoding="utf-8")
counts[split] = len(names)
print(f" [OK] {out}: {len(names)} entries")
return counts
def prepare_datasets(selected: str | None = None, status_only: bool = False) -> dict[str, int]:
print("\nDATASETS")
print("--------")
exists = missing = prepared = 0
paths = sorted(DATASET_CONFIGS.glob("*.yaml"))
for path in paths:
if selected and path.stem != selected:
continue
cfg = parse_simple_yaml(path)
root = dataset_root_from_config(cfg)
if root.is_dir():
exists += 1
print(f" [EXISTS] {path.stem}: {root}")
if not status_only:
split_keys = tuple((cfg.get("splits") or {"train": "train", "val": "val", "test": "test"}).keys())
prepare_dataset_lists(root, image_a_folder=str(cfg.get("image_a_folder", "A")), splits=split_keys)
prepared += 1
else:
missing += 1
print(f" [MISSING] {path.stem}: {root}")
return {"exists": exists, "missing": missing, "prepared": prepared, "total": len(paths)}
def print_final_status(env: dict[str, int], repos: dict[str, int], weights: dict[str, int] | None, datasets: dict[str, int]) -> None:
print("\nSETUP STATUS")
print("Component Status Notes")
print("-----------------------------|-------------|----------------------------------")
env_status = "COMPLETE" if env["missing"] == 0 else "CHECK"
print(f"Python environment {env_status:<12} {env['installed']} packages auto-installed; {env['warnings']} manual warnings")
print(f"Model repos ({repos['total']} total) {repos['ok']} / {repos['total']:<6} {repos['skipped']} URL-unconfirmed entries")
if weights is None:
print("Backbone weights SKIPPED run: python setup.py --weights-only")
else:
print(f"Backbone weights {weights['ok']} / {weights['total']:<6} Zenodo, timm, and torchvision warmups")
print(f"Datasets {datasets['exists']} / {datasets['total']:<6} roots found under DATA_ROOT/default search")
print("\nNext steps:")
print(" export DATA_ROOT=/path/to/datasets")
print(" python run_training.py --model bifa --dataset levir_cd --dry-run")
print(" python run_training.py --model bifa --dataset levir_cd")
print(" For ChangeMamba/Changer, install the MMSeg stack shown above.")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="One-command setup for the CD-Models benchmark suite.")
parser.add_argument("--skip-weights", action="store_true")
parser.add_argument("--weights-only", action="store_true")
parser.add_argument("--env-check-only", action="store_true")
parser.add_argument("--status", action="store_true")
parser.add_argument("--dataset", default=None, help="Prepare list files for one dataset config name.")
return parser.parse_args()
def main() -> int:
args = parse_args()
status_only = args.status
env = check_environment(status_only=status_only)
if args.env_check_only:
print_final_status(env, {"ok": 0, "missing": 0, "skipped": 0, "total": len(CONFIRMED_REPOS)}, None, {"exists": 0, "missing": 0, "prepared": 0, "total": 0})
return 0 if env["warnings"] == 0 else 1
repos = {"ok": 0, "missing": 0, "skipped": 0, "total": len(CONFIRMED_REPOS)}
if not args.weights_only:
repos = clone_repositories(status_only=status_only)
weights = None
if not args.skip_weights:
weights = download_weights(status_only=status_only)
datasets = prepare_datasets(selected=args.dataset, status_only=status_only)
print_final_status(env, repos, weights, datasets)
return 0
if __name__ == "__main__":
raise SystemExit(main())