AccessPath / tools /train_accessibility_amodal_adapter.py
anonymous-accesspath's picture
Audited anonymous-review AccessPath release
2f382c4 verified
Raw
History Blame Contribute Delete
19.8 kB
#!/usr/bin/env python3
"""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:
# Ignore EXIF display orientation so RGB stays aligned with raw PNG masks.
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())