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