| |
| """Train a small 2D hidden-mask adapter on the frozen accessibility split.""" |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import random |
| from pathlib import Path |
| from typing import Any |
|
|
| import numpy as np |
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
| from PIL import Image, ImageDraw, ImageOps |
| from torch.utils.data import DataLoader, Dataset |
|
|
|
|
| def read_jsonl(path: Path) -> list[dict[str, Any]]: |
| return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] |
|
|
|
|
| CANONICAL_CATEGORIES = ("curb_cut", "ramp", "stairs", "tactile_paving", "walkway") |
|
|
|
|
| def row_image_path(row: dict[str, Any]) -> Path: |
| if row.get("image_path"): |
| path = Path(str(row["image_path"])) |
| if path.is_file(): |
| return path |
| sample_dir = Path(row["sample_dir"]) |
| candidates = [path for path in (sample_dir / "image.jpg", sample_dir / "image.png") if path.is_file()] |
| if len(candidates) != 1: |
| raise FileNotFoundError(f"Expected exactly one RGB image in {sample_dir}") |
| return candidates[0] |
|
|
|
|
| def load_rgb(path: Path, size: int) -> np.ndarray: |
| |
| image = Image.open(path).convert("RGB") |
| return np.asarray(image.resize((size, size), Image.Resampling.BILINEAR), dtype=np.float32) / 255.0 |
|
|
|
|
| def load_mask(path: Path, size: int | None = None) -> np.ndarray: |
| image = Image.open(path).convert("L") |
| if size is not None: |
| image = image.resize((size, size), Image.Resampling.NEAREST) |
| return np.asarray(image) > 127 |
|
|
|
|
| def category_planes(category: str, size: int) -> np.ndarray: |
| planes = np.zeros((len(CANONICAL_CATEGORIES), size, size), dtype=np.float32) |
| index = CANONICAL_CATEGORIES.index(category) if category in CANONICAL_CATEGORIES else -1 |
| if index >= 0: |
| planes[index] = 1.0 |
| return planes |
|
|
|
|
| class AccessibilityMaskDataset(Dataset): |
| def __init__(self, rows: list[dict[str, Any]], size: int, augment: bool): |
| self.rows = rows |
| self.size = size |
| self.augment = augment |
|
|
| def __len__(self) -> int: |
| return len(self.rows) |
|
|
| def __getitem__(self, index: int) -> dict[str, Any]: |
| row = self.rows[index] |
| sample_dir = Path(row["sample_dir"]) |
| rgb = load_rgb(row_image_path(row), self.size) |
| visible = load_mask(sample_dir / "target_visible.png", self.size) |
| obstacle = load_mask(sample_dir / "obstacle.png", self.size) |
| hidden = load_mask(sample_dir / "hidden.png", self.size) |
| if self.augment and random.random() < 0.5: |
| rgb = rgb[:, ::-1].copy() |
| visible = visible[:, ::-1].copy() |
| obstacle = obstacle[:, ::-1].copy() |
| hidden = hidden[:, ::-1].copy() |
| if self.augment: |
| gain = random.uniform(0.88, 1.12) |
| bias = random.uniform(-0.05, 0.05) |
| rgb = np.clip(rgb * gain + bias, 0.0, 1.0) |
| inputs = np.concatenate( |
| [ |
| rgb.transpose(2, 0, 1), |
| visible[None].astype(np.float32), |
| obstacle[None].astype(np.float32), |
| category_planes(row["category"], self.size), |
| ], |
| axis=0, |
| ) |
| return { |
| "input": torch.from_numpy(inputs.astype(np.float32)), |
| "hidden": torch.from_numpy(hidden[None].astype(np.float32)), |
| "obstacle": torch.from_numpy(obstacle[None].astype(np.float32)), |
| "sample_id": row["sample_id"], |
| } |
|
|
|
|
| class ConvBlock(nn.Module): |
| def __init__(self, in_channels: int, out_channels: int): |
| super().__init__() |
| groups = min(8, out_channels) |
| self.block = nn.Sequential( |
| nn.Conv2d(in_channels, out_channels, 3, padding=1), |
| nn.GroupNorm(groups, out_channels), |
| nn.SiLU(), |
| nn.Conv2d(out_channels, out_channels, 3, padding=1), |
| nn.GroupNorm(groups, out_channels), |
| nn.SiLU(), |
| ) |
|
|
| def forward(self, inputs: torch.Tensor) -> torch.Tensor: |
| return self.block(inputs) |
|
|
|
|
| class TinyAmodalUNet(nn.Module): |
| def __init__(self, base: int = 24, in_channels: int = 5 + len(CANONICAL_CATEGORIES)): |
| super().__init__() |
| self.enc1 = ConvBlock(in_channels, base) |
| self.enc2 = ConvBlock(base, base * 2) |
| self.bottleneck = ConvBlock(base * 2, base * 4) |
| self.dec2 = ConvBlock(base * 4 + base * 2, base * 2) |
| self.dec1 = ConvBlock(base * 2 + base, base) |
| self.head = nn.Conv2d(base, 1, 1) |
|
|
| def forward(self, inputs: torch.Tensor) -> torch.Tensor: |
| first = self.enc1(inputs) |
| second = self.enc2(F.max_pool2d(first, 2)) |
| bottleneck = self.bottleneck(F.max_pool2d(second, 2)) |
| up_second = F.interpolate(bottleneck, size=second.shape[-2:], mode="bilinear", align_corners=False) |
| decoded_second = self.dec2(torch.cat([up_second, second], dim=1)) |
| up_first = F.interpolate(decoded_second, size=first.shape[-2:], mode="bilinear", align_corners=False) |
| return self.head(self.dec1(torch.cat([up_first, first], dim=1))) |
|
|
|
|
| def training_loss(logits: torch.Tensor, target: torch.Tensor, obstacle: torch.Tensor) -> torch.Tensor: |
| positive_weight = torch.tensor(8.0, device=logits.device) |
| bce = F.binary_cross_entropy_with_logits(logits, target, pos_weight=positive_weight) |
| probabilities = torch.sigmoid(logits) |
| intersection = (probabilities * target).sum(dim=(1, 2, 3)) |
| denominator = probabilities.sum(dim=(1, 2, 3)) + target.sum(dim=(1, 2, 3)) |
| dice_loss = 1.0 - ((2.0 * intersection + 1.0) / (denominator + 1.0)).mean() |
| outside_obstacle = (probabilities * (1.0 - obstacle)).mean() |
| return bce + dice_loss + 0.20 * outside_obstacle |
|
|
|
|
| def iou(left: np.ndarray, right: np.ndarray, empty_value: float = 1.0) -> float: |
| union = left | right |
| return float((left & right).sum() / union.sum()) if np.any(union) else empty_value |
|
|
|
|
| @torch.no_grad() |
| def predict_probability( |
| model: nn.Module, row: dict[str, Any], size: int, device: torch.device |
| ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: |
| sample_dir = Path(row["sample_dir"]) |
| image_path = row_image_path(row) |
| original = Image.open(image_path).convert("RGB") |
| width, height = original.size |
| rgb = load_rgb(image_path, size) |
| visible_small = load_mask(sample_dir / "target_visible.png", size) |
| obstacle_small = load_mask(sample_dir / "obstacle.png", size) |
| inputs = np.concatenate( |
| [ |
| rgb.transpose(2, 0, 1), |
| visible_small[None].astype(np.float32), |
| obstacle_small[None].astype(np.float32), |
| category_planes(row["category"], size), |
| ], |
| axis=0, |
| ) |
| logits = model(torch.from_numpy(inputs[None]).to(device)).sigmoid()[0, 0].cpu().numpy() |
| probability = np.asarray( |
| Image.fromarray(logits.astype(np.float32), mode="F").resize((width, height), Image.Resampling.BILINEAR) |
| ).copy() |
| visible = load_mask(sample_dir / "target_visible.png") |
| obstacle = load_mask(sample_dir / "obstacle.png") |
| hidden = load_mask(sample_dir / "hidden.png") |
| probability *= (obstacle & ~visible).astype(np.float32) |
| return probability, visible, obstacle, hidden |
|
|
|
|
| @torch.no_grad() |
| def evaluate_thresholds( |
| model: nn.Module, |
| rows: list[dict[str, Any]], |
| size: int, |
| device: torch.device, |
| thresholds: list[float], |
| ) -> tuple[float, dict[str, Any], dict[str, tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]]]: |
| cached = {row["sample_id"]: predict_probability(model, row, size, device) for row in rows} |
| reports = [] |
| for threshold in thresholds: |
| sample_rows = [] |
| for row in rows: |
| probability, visible, _, hidden = cached[row["sample_id"]] |
| predicted_hidden = probability >= threshold |
| predicted_amodal = visible | predicted_hidden |
| target_amodal = visible | hidden |
| sample_rows.append( |
| { |
| "sample_id": row["sample_id"], |
| "category": row["category"], |
| "hidden_gt_pixels": int(hidden.sum()), |
| "hidden_pred_pixels": int(predicted_hidden.sum()), |
| "hidden_iou": iou(predicted_hidden, hidden), |
| "amodal_iou": iou(predicted_amodal, target_amodal), |
| } |
| ) |
| nonempty = [item for item in sample_rows if item["hidden_gt_pixels"] > 0] |
| reports.append( |
| { |
| "threshold": threshold, |
| "mean_hidden_iou_nonempty": float(np.mean([item["hidden_iou"] for item in nonempty])) if nonempty else 1.0, |
| "mean_amodal_iou": float(np.mean([item["amodal_iou"] for item in sample_rows])), |
| "negative_control_false_positive_pixels": int( |
| sum(item["hidden_pred_pixels"] for item in sample_rows if item["hidden_gt_pixels"] == 0) |
| ), |
| "rows": sample_rows, |
| } |
| ) |
| best = max( |
| reports, |
| key=lambda item: ( |
| item["mean_hidden_iou_nonempty"], |
| item["mean_amodal_iou"], |
| -item["negative_control_false_positive_pixels"], |
| ), |
| ) |
| return float(best["threshold"]), best, cached |
|
|
|
|
| def save_mask(path: Path, mask: np.ndarray) -> None: |
| Image.fromarray(mask.astype(np.uint8) * 255, mode="L").save(path) |
|
|
|
|
| def save_predictions( |
| output: Path, |
| rows: list[dict[str, Any]], |
| cached: dict[str, tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]], |
| threshold: float, |
| ) -> None: |
| output.mkdir(parents=True, exist_ok=True) |
| for row in rows: |
| sample_output = output / row["sample_id"] |
| sample_output.mkdir(parents=True, exist_ok=True) |
| probability, visible, _, _ = cached[row["sample_id"]] |
| predicted_hidden = probability >= threshold |
| save_mask(sample_output / "target_visible_mask.png", visible) |
| save_mask(sample_output / "hidden_completion_mask.png", predicted_hidden) |
| save_mask(sample_output / "amodal_accessibility_mask.png", visible | predicted_hidden) |
| Image.fromarray(np.clip(probability * 255.0, 0, 255).astype(np.uint8), mode="L").save( |
| sample_output / "hidden_probability.png" |
| ) |
|
|
|
|
| def color_overlay(image: Image.Image, masks: list[tuple[np.ndarray, tuple[int, int, int], float]]) -> Image.Image: |
| array = np.asarray(image.convert("RGB"), dtype=np.float32).copy() |
| for mask, color, alpha in masks: |
| array[mask] = array[mask] * (1.0 - alpha) + np.asarray(color) * alpha |
| return Image.fromarray(np.clip(array, 0, 255).astype(np.uint8)) |
|
|
|
|
| def build_contact_sheet( |
| path: Path, |
| rows: list[dict[str, Any]], |
| cached: dict[str, tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]], |
| threshold: float, |
| ) -> None: |
| panel_size = (320, 180) |
| label_height = 30 |
| sheet = Image.new("RGB", (panel_size[0] * 3, (panel_size[1] + label_height) * len(rows)), "white") |
| draw = ImageDraw.Draw(sheet) |
| for row_index, row in enumerate(rows): |
| image = Image.open(row_image_path(row)).convert("RGB") |
| probability, visible, obstacle, hidden = cached[row["sample_id"]] |
| predicted = probability >= threshold |
| panels = ( |
| (row["sample_id"], image), |
| ("GT: green visible / blue hidden / red obstacle", color_overlay(image, [(visible, (20, 210, 70), 0.38), (hidden, (40, 100, 245), 0.72), (obstacle, (235, 40, 40), 0.25)])), |
| ("prediction: green visible / magenta hidden", color_overlay(image, [(visible, (20, 210, 70), 0.38), (predicted, (235, 40, 200), 0.75)])), |
| ) |
| y = row_index * (panel_size[1] + label_height) |
| for column, (label, panel) in enumerate(panels): |
| x = column * panel_size[0] |
| draw.text((x + 5, y + 7), label, fill="black") |
| fitted = ImageOps.fit(panel, panel_size, method=Image.Resampling.LANCZOS) |
| sheet.paste(fitted, (x, y + label_height)) |
| sheet.save(path, quality=92, subsampling=0) |
|
|
|
|
| def main() -> int: |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument("--split", default="output/accessibility_training_split_v1") |
| parser.add_argument("--output", default="output/accessibility_amodal_adapter_v1") |
| parser.add_argument("--epochs", type=int, default=100) |
| parser.add_argument("--patience", type=int, default=25) |
| parser.add_argument("--batch-size", type=int, default=4) |
| parser.add_argument("--image-size", type=int, default=256) |
| parser.add_argument("--learning-rate", type=float, default=3e-4) |
| parser.add_argument("--num-workers", type=int, default=2) |
| parser.add_argument("--seed", type=int, default=20260623) |
| parser.add_argument("--device", choices=["auto", "cuda", "cpu"], default="auto") |
| args = parser.parse_args() |
|
|
| random.seed(args.seed) |
| np.random.seed(args.seed) |
| torch.manual_seed(args.seed) |
| if torch.cuda.is_available(): |
| torch.cuda.manual_seed_all(args.seed) |
| split = Path(args.split) |
| split_summary = json.loads((split / "summary.json").read_text(encoding="utf-8")) |
| train_rows = read_jsonl(split / "train.jsonl") + read_jsonl(split / "auxiliary_train.jsonl") |
| validation_rows = read_jsonl(split / "validation.jsonl") |
| test_rows = read_jsonl(split / "test.jsonl") |
| if any(row["tier"] == "gold" for row in train_rows): |
| raise RuntimeError("Gold sample detected in training rows") |
| selected_device = ( |
| "cuda" if args.device == "auto" and torch.cuda.is_available() |
| else "cpu" if args.device == "auto" |
| else args.device |
| ) |
| device = torch.device(selected_device) |
| if device.type == "cuda" and not torch.cuda.is_available(): |
| raise RuntimeError("CUDA requested but unavailable") |
|
|
| output = Path(args.output) |
| output.mkdir(parents=True, exist_ok=True) |
| loader = DataLoader( |
| AccessibilityMaskDataset(train_rows, args.image_size, augment=True), |
| batch_size=args.batch_size, |
| shuffle=True, |
| num_workers=args.num_workers, |
| pin_memory=device.type == "cuda", |
| generator=torch.Generator().manual_seed(args.seed), |
| ) |
| model = TinyAmodalUNet().to(device) |
| optimizer = torch.optim.AdamW(model.parameters(), lr=args.learning_rate, weight_decay=1e-4) |
| scaler = torch.amp.GradScaler("cuda", enabled=device.type == "cuda") |
| thresholds = [round(value, 2) for value in np.arange(0.20, 0.81, 0.05)] |
| best_score = (-1.0, -1.0) |
| best_epoch = -1 |
| stale_epochs = 0 |
| history = [] |
| for epoch in range(1, args.epochs + 1): |
| model.train() |
| losses = [] |
| for batch in loader: |
| inputs = batch["input"].to(device, non_blocking=True) |
| target = batch["hidden"].to(device, non_blocking=True) |
| obstacle = batch["obstacle"].to(device, non_blocking=True) |
| optimizer.zero_grad(set_to_none=True) |
| with torch.autocast(device_type=device.type, dtype=torch.float16, enabled=device.type == "cuda"): |
| logits = model(inputs) |
| loss = training_loss(logits, target, obstacle) |
| scaler.scale(loss).backward() |
| scaler.step(optimizer) |
| scaler.update() |
| losses.append(float(loss.detach().cpu())) |
| model.eval() |
| threshold, validation, _ = evaluate_thresholds( |
| model, validation_rows, args.image_size, device, thresholds |
| ) |
| record = { |
| "epoch": epoch, |
| "train_loss": float(np.mean(losses)), |
| "validation_threshold": threshold, |
| "validation_hidden_iou_nonempty": validation["mean_hidden_iou_nonempty"], |
| "validation_amodal_iou": validation["mean_amodal_iou"], |
| } |
| history.append(record) |
| print(json.dumps(record, ensure_ascii=False), flush=True) |
| score = (validation["mean_hidden_iou_nonempty"], validation["mean_amodal_iou"]) |
| if score > best_score: |
| best_score = score |
| best_epoch = epoch |
| stale_epochs = 0 |
| torch.save( |
| { |
| "model_state": model.state_dict(), |
| "epoch": epoch, |
| "validation_threshold": threshold, |
| "validation_metrics": validation, |
| "split_fingerprint": split_summary["fingerprint"], |
| "architecture": "TinyAmodalUNet", |
| "input_channels": [ |
| "rgb", |
| "target_visible", |
| "occluding_obstacle", |
| *CANONICAL_CATEGORIES, |
| ], |
| "model_input_channels": 5 + len(CANONICAL_CATEGORIES), |
| "pseudo_depth_supervision": False, |
| }, |
| output / "best.pt", |
| ) |
| else: |
| stale_epochs += 1 |
| if stale_epochs >= args.patience: |
| break |
|
|
| checkpoint = torch.load(output / "best.pt", map_location=device, weights_only=False) |
| model.load_state_dict(checkpoint["model_state"]) |
| model.eval() |
| threshold, validation_report, validation_cache = evaluate_thresholds( |
| model, validation_rows, args.image_size, device, thresholds |
| ) |
| _, test_report, test_cache = evaluate_thresholds( |
| model, test_rows, args.image_size, device, [threshold] |
| ) |
| save_predictions(output / "predictions" / "validation", validation_rows, validation_cache, threshold) |
| save_predictions(output / "predictions" / "test", test_rows, test_cache, threshold) |
| build_contact_sheet(output / "validation_contact_sheet.jpg", validation_rows, validation_cache, threshold) |
| build_contact_sheet(output / "test_contact_sheet.jpg", test_rows, test_cache, threshold) |
| (output / "history.jsonl").write_text( |
| "".join(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n" for row in history), |
| encoding="utf-8", |
| ) |
| baseline = { |
| "validation_mean_amodal_iou_visible_only": float( |
| np.mean( |
| [ |
| iou(load_mask(Path(row["sample_dir"]) / "target_visible.png"), load_mask(Path(row["sample_dir"]) / "target_amodal.png")) |
| for row in validation_rows |
| ] |
| ) |
| ), |
| "validation_mean_hidden_iou_nonempty_visible_only": 0.0, |
| } |
| summary = { |
| "status": "complete", |
| "model": "small_2d_hidden_mask_adapter_not_original_amodal3d", |
| "best_epoch": best_epoch, |
| "epochs_run": len(history), |
| "threshold_selected_on_validation": threshold, |
| "split_fingerprint": split_summary["fingerprint"], |
| "training_real_silver_count": 0, |
| "training_strict_gt_count": len(read_jsonl(split / "train.jsonl")), |
| "training_annotation_tier": "human_reviewed_strict_gt", |
| "strict_gt_used_for_training": True, |
| "training_auxiliary_synthetic_silver_count": len(read_jsonl(split / "auxiliary_train.jsonl")), |
| "gold_used_for_training": False, |
| "pseudo_depth_supervision": False, |
| "baseline": baseline, |
| "validation": {key: value for key, value in validation_report.items() if key != "rows"}, |
| "test": {key: value for key, value in test_report.items() if key != "rows"}, |
| "validation_rows": validation_report["rows"], |
| "test_rows": test_report["rows"], |
| } |
| (output / "summary.json").write_text( |
| json.dumps(summary, ensure_ascii=False, indent=2, sort_keys=True) + "\n", |
| encoding="utf-8", |
| ) |
| print(json.dumps(summary, ensure_ascii=False, indent=2), flush=True) |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|