satdetect-dev / scripts /generate_candidate_masks.py
coderuday21's picture
Cursor
Deploy satdetect-dev with Priyanka audit/exception logs.
d70361b
Raw
History Blame Contribute Delete
4.51 kB
"""
Day 2: generate candidate ground-truth change masks for every pair in
docs/delhi_eval/manifest.json, as a starting point for hand-review — NOT a
substitute for it. Unsupervised Change Vector Analysis (pixel-wise RGB
distance) + Otsu thresholding + light morphological cleanup.
These candidates WILL contain false signal (illumination differences, sensor
noise, cloud edges) alongside real change. Review each one before promoting
it to a real ground-truth label — see scripts/accept_candidate_mask.py.
Usage:
python scripts/generate_candidate_masks.py
python scripts/generate_candidate_masks.py --pair-id delhi_0001
"""
import argparse
import json
from pathlib import Path
import cv2
import numpy as np
from PIL import Image
ROOT = Path(__file__).resolve().parent.parent
MANIFEST_PATH = ROOT / "docs" / "delhi_eval" / "manifest.json"
CANDIDATES_DIR = ROOT / "docs" / "delhi_eval" / "labels" / "candidates"
PREVIEWS_DIR = ROOT / "docs" / "delhi_eval" / "labels" / "candidates" / "previews"
def _load_rgb(path: Path) -> np.ndarray:
if path.suffix.lower() in (".tif", ".tiff"):
import rasterio
with rasterio.open(path) as ds:
return np.transpose(ds.read([1, 2, 3]), (1, 2, 0))
return np.array(Image.open(path).convert("RGB"))
def cva_otsu_mask(before: np.ndarray, after: np.ndarray,
percentile: float = 92.0, min_blob_px: int = 40) -> np.ndarray:
"""Pixel-wise change magnitude (Euclidean RGB distance) -> conservative
top-percentile threshold (Otsu was splitting near the noise floor and
flagging 20-35% of every scene as 'changed' on this agricultural-heavy
imagery — a percentile cut is stricter) -> morphological cleanup ->
connected-component area filter to drop small speckle (crop-texture
noise tends to be small scattered flecks; real structural change tends
to be a solid contiguous blob)."""
diff = before.astype(np.float32) - after.astype(np.float32)
magnitude = np.sqrt(np.sum(diff ** 2, axis=-1))
threshold_value = np.percentile(magnitude, percentile)
mask = (magnitude >= threshold_value).astype(np.uint8) * 255
kernel = np.ones((3, 3), np.uint8)
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8)
cleaned = np.zeros_like(mask)
for label in range(1, num_labels): # 0 = background
if stats[label, cv2.CC_STAT_AREA] >= min_blob_px:
cleaned[labels == label] = 255
return cleaned
def _save_preview(pair_id, before, after, mask, out_dir):
overlay = after.copy()
overlay[mask > 0] = (0.4 * overlay[mask > 0] + 0.6 * np.array([255, 40, 40])).astype(np.uint8)
strip = np.concatenate([before, after, np.dstack([mask] * 3), overlay], axis=1)
Image.fromarray(strip).save(out_dir / f"{pair_id}.png")
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--pair-id", default="", help="only process this one pair (default: all)")
args = parser.parse_args()
CANDIDATES_DIR.mkdir(parents=True, exist_ok=True)
PREVIEWS_DIR.mkdir(parents=True, exist_ok=True)
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
pairs = manifest["pairs"]
if args.pair_id:
pairs = [p for p in pairs if p["pair_id"] == args.pair_id]
if not pairs:
raise SystemExit(f"No such pair_id: {args.pair_id}")
print(f"Generating candidate masks for {len(pairs)} pair(s)...\n")
for pair in pairs:
pair_id = pair["pair_id"]
before = _load_rgb(ROOT / pair["before_path"])
after = _load_rgb(ROOT / pair["after_path"])
mask = cva_otsu_mask(before, after)
candidate_path = CANDIDATES_DIR / f"{pair_id}.png"
Image.fromarray(mask).save(candidate_path)
_save_preview(pair_id, before, after, mask, PREVIEWS_DIR)
change_pct = 100.0 * np.mean(mask > 0)
print(f" {pair_id} change%={change_pct:5.1f} -> {candidate_path.relative_to(ROOT)}"
f" (preview: {(PREVIEWS_DIR / f'{pair_id}.png').relative_to(ROOT)})")
print(f"\nReview previews in {PREVIEWS_DIR.relative_to(ROOT)}/ (before | after | mask | overlay strips).")
print("Then promote good ones with: python scripts/accept_candidate_mask.py --pair-id <id>")
if __name__ == "__main__":
main()