#!/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())