CD-Models / utils /qualitative.py
Dineth Perera
Publish tested dataset winners and benchmark rankings
ce209f5
Raw
History Blame Contribute Delete
6.63 kB
from __future__ import annotations
import json
import random
import re
from pathlib import Path
from typing import Iterable
import numpy as np
import torch
from PIL import Image, ImageDraw
from datasets.cd_dataset import CDDataset
ROOT = Path(__file__).resolve().parents[1]
def safe_sample_id(sample_id: str) -> str:
return re.sub(r"[^A-Za-z0-9_.-]+", "_", str(sample_id))
def _boundary_density(mask: np.ndarray) -> float:
mask = mask.astype(bool)
if not mask.any():
return 0.0
horiz = np.zeros_like(mask, dtype=bool)
vert = np.zeros_like(mask, dtype=bool)
horiz[:, 1:] = mask[:, 1:] != mask[:, :-1]
vert[1:, :] = mask[1:, :] != mask[:-1, :]
return float((horiz | vert).sum()) / float(mask.size)
def qualitative_manifest_path(dataset_name: str) -> Path:
return ROOT / "results" / "qualitative_samples" / dataset_name / "sample_manifest.json"
def select_or_load_manifest(
dataset_cfg: dict,
count: int = 20,
seed: int = 3407,
force: bool = False,
) -> dict:
dataset_name = dataset_cfg["name"]
path = qualitative_manifest_path(dataset_name)
if path.exists() and not force:
with path.open("r", encoding="utf-8") as f:
return json.load(f)
ds = CDDataset(dataset_cfg["data_root"], "test", cfg=dataset_cfg, normalize=False, return_format="tuple")
rng = random.Random(seed)
rows = []
for index, (a_path, b_path, mask_path, sample_id) in enumerate(ds.samples):
if not (a_path.is_file() and b_path.is_file() and mask_path.is_file()):
continue
mask = np.asarray(Image.open(mask_path).convert("L").resize((ds.image_size, ds.image_size), Image.NEAREST))
binary = mask > (0 if mask.max() <= 1 else ds.threshold)
ratio = float(binary.mean())
boundary = _boundary_density(binary)
if ratio <= 0.0 or ratio >= 0.85 or boundary <= 0.0:
continue
rows.append({
"index": index,
"sample_id": sample_id,
"a_path": str(a_path),
"b_path": str(b_path),
"mask_path": str(mask_path),
"changed_ratio": ratio,
"boundary_density": boundary,
"tie": rng.random(),
})
if len(ds) >= count and len(rows) < count:
raise RuntimeError(
f"Only {len(rows)} visually useful test samples found for {dataset_name}, "
f"but {count} are required from {len(ds)} total test samples."
)
rows = sorted(rows, key=lambda r: (-r["boundary_density"], -r["changed_ratio"], r["sample_id"], r["tie"]))
selected = rows[: min(count, len(rows))]
for rank, row in enumerate(selected, start=1):
row["rank"] = rank
row.pop("tie", None)
path.parent.mkdir(parents=True, exist_ok=True)
manifest = {
"dataset": dataset_name,
"split": "test",
"seed": seed,
"requested_count": count,
"selected_count": len(selected),
"total_test_samples": len(ds),
"selection_rule": "non-empty mask, changed_ratio < 0.85, positive boundary density, sorted deterministically",
"samples": selected,
}
with path.open("w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2, sort_keys=True)
return manifest
def manifest_ids(manifest: dict) -> set[str]:
return {str(row["sample_id"]) for row in manifest.get("samples", [])}
def denormalize(tensor: torch.Tensor, mean: list[float], std: list[float]) -> torch.Tensor:
if tensor.ndim == 3:
mean_t = torch.tensor(mean, dtype=tensor.dtype, device=tensor.device).view(3, 1, 1)
std_t = torch.tensor(std, dtype=tensor.dtype, device=tensor.device).view(3, 1, 1)
else:
mean_t = torch.tensor(mean, dtype=tensor.dtype, device=tensor.device).view(1, 3, 1, 1)
std_t = torch.tensor(std, dtype=tensor.dtype, device=tensor.device).view(1, 3, 1, 1)
return tensor * std_t + mean_t
def tensor_to_rgb_image(tensor: torch.Tensor) -> Image.Image:
if tensor.ndim == 4:
tensor = tensor[0]
arr = tensor.detach().cpu().float().clamp(0, 1).numpy()
if arr.shape[0] == 1:
arr = np.repeat(arr, 3, axis=0)
arr = np.transpose(arr[:3], (1, 2, 0))
return Image.fromarray((arr * 255).astype(np.uint8), mode="RGB")
def mask_to_image(mask: torch.Tensor | np.ndarray) -> Image.Image:
if torch.is_tensor(mask):
arr = mask.detach().cpu().numpy()
else:
arr = mask
arr = np.squeeze(arr)
arr = (arr > 0).astype(np.uint8) * 255
return Image.fromarray(arr, mode="L").convert("RGB")
def overlay_image(gt: torch.Tensor, pred: torch.Tensor) -> Image.Image:
gt_arr = np.squeeze(gt.detach().cpu().numpy()).astype(bool)
pred_arr = np.squeeze(pred.detach().cpu().numpy()).astype(bool)
rgb = np.zeros((*gt_arr.shape, 3), dtype=np.uint8)
rgb[gt_arr & pred_arr] = (255, 255, 255)
rgb[pred_arr & ~gt_arr] = (255, 64, 64)
rgb[gt_arr & ~pred_arr] = (64, 160, 255)
return Image.fromarray(rgb, mode="RGB")
def save_binary_prediction(pred: torch.Tensor, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
mask_to_image(pred).convert("L").save(path)
def save_probability_map(prob: torch.Tensor, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
arr = np.squeeze(prob.detach().cpu().float().clamp(0, 1).numpy())
Image.fromarray((arr * 255).astype(np.uint8), mode="L").save(path)
def save_visual_panel(
a: torch.Tensor,
b: torch.Tensor,
gt: torch.Tensor,
pred: torch.Tensor,
out_path: Path,
prob: torch.Tensor | None = None,
) -> None:
tiles = [
("A", tensor_to_rgb_image(a)),
("B", tensor_to_rgb_image(b)),
("GT", mask_to_image(gt)),
("Pred", mask_to_image(pred)),
("Overlay", overlay_image(gt, pred)),
]
if prob is not None:
tiles.insert(4, ("Score", mask_to_image((prob >= 0.5).float())))
tile_w, tile_h = tiles[0][1].size
label_h = 24
panel = Image.new("RGB", (tile_w * len(tiles), tile_h + label_h), "white")
draw = ImageDraw.Draw(panel)
for i, (label, img) in enumerate(tiles):
x = i * tile_w
panel.paste(img.resize((tile_w, tile_h), Image.NEAREST), (x, label_h))
draw.text((x + 6, 5), label, fill=(0, 0, 0))
out_path.parent.mkdir(parents=True, exist_ok=True)
panel.save(out_path)
def rank_for_sample(manifest: dict, sample_id: str) -> int | None:
for row in manifest.get("samples", []):
if str(row["sample_id"]) == str(sample_id):
return int(row["rank"])
return None