| |
| """Validate and materialize one human-reviewed visible accessibility mask. |
| |
| This deliberately supports *visible-mask-only* review. It does not upgrade a |
| reviewed visible mask into amodal ground truth: the hidden/obstacle layers are |
| still generated as review candidates by the normal pipeline. The tool is |
| used by the Slurm launcher before it bypasses SAM3 for a reviewed mask. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import hashlib |
| import json |
| import shutil |
| from datetime import datetime, timezone |
| from pathlib import Path |
| from typing import Any |
|
|
| import numpy as np |
| from PIL import Image, ImageOps |
|
|
|
|
| def file_sha256(path: Path) -> str: |
| digest = hashlib.sha256() |
| with path.open("rb") as handle: |
| for chunk in iter(lambda: handle.read(1024 * 1024), b""): |
| digest.update(chunk) |
| return digest.hexdigest() |
|
|
|
|
| def read_json(path: Path) -> dict[str, Any]: |
| return json.loads(path.read_text(encoding="utf-8")) |
|
|
|
|
| def write_json(path: Path, value: dict[str, Any]) -> None: |
| path.parent.mkdir(parents=True, exist_ok=True) |
| temporary = path.with_suffix(path.suffix + ".tmp") |
| temporary.write_text( |
| json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n", |
| encoding="utf-8", |
| ) |
| temporary.replace(path) |
|
|
|
|
| def display_rgb(path: Path) -> Image.Image: |
| return ImageOps.exif_transpose(Image.open(path)).convert("RGB") |
|
|
|
|
| def read_binary_mask(path: Path, size: tuple[int, int]) -> np.ndarray: |
| mask = ImageOps.exif_transpose(Image.open(path)).convert("L") |
| if mask.size != size: |
| raise ValueError( |
| f"Reviewed visible mask size {mask.size} does not match normalized source RGB {size}." |
| ) |
| return np.asarray(mask) > 127 |
|
|
|
|
| def validate_workspace( |
| workspace: Path, |
| source_image: Path, |
| sample_id: str, |
| category: str, |
| ) -> tuple[dict[str, Any], np.ndarray, tuple[int, int]]: |
| """Validate the reviewer identity, provenance, category, and exact raster grid.""" |
| metadata_path = workspace / "metadata.json" |
| target_path = workspace / "target_visible.png" |
| image_path = workspace / "image.png" |
| for path in (metadata_path, target_path, image_path): |
| if not path.is_file(): |
| raise ValueError(f"Reviewed visible-mask workspace is missing {path.name}: {workspace}") |
| metadata = read_json(metadata_path) |
| errors: list[str] = [] |
| if str(metadata.get("sample_id", "")) != sample_id: |
| errors.append("sample_id does not match the requested inference sample") |
| if str(metadata.get("category", "")) != category: |
| errors.append("category does not match the requested inference category") |
| if str(metadata.get("review_status", "")) != "approved": |
| errors.append("review_status must be approved") |
| if not bool(metadata.get("review_is_human", False)): |
| errors.append("review_is_human must be true") |
| if not bool(metadata.get("visible_confirmed", False)): |
| errors.append("visible_confirmed must be true") |
| if not str(metadata.get("annotator", "")).strip(): |
| errors.append("approved review requires a non-empty annotator") |
| if bool(metadata.get("target_amodal_is_ground_truth", False)): |
| errors.append("visible-only workspace must not claim target_amodal ground truth") |
| source_hash = file_sha256(source_image) |
| recorded_hash = str(metadata.get("source_image_sha256", "")) |
| if not recorded_hash or recorded_hash != source_hash: |
| errors.append("source image SHA-256 does not match the reviewed workspace") |
| source = display_rgb(source_image) |
| workspace_image = Image.open(image_path).convert("RGB") |
| if workspace_image.size != source.size: |
| errors.append( |
| f"workspace normalized RGB size {workspace_image.size} does not match source {source.size}" |
| ) |
| if errors: |
| raise ValueError("Invalid reviewed visible-mask workspace: " + "; ".join(errors)) |
| target = read_binary_mask(target_path, source.size) |
| if not target.any(): |
| raise ValueError("Reviewed visible target mask is empty") |
| return metadata, target, source.size |
|
|
|
|
| def materialize_workspace( |
| workspace: Path, |
| source_image: Path, |
| sample_id: str, |
| category: str, |
| output_dir: Path, |
| ) -> dict[str, Any]: |
| metadata, target, size = validate_workspace(workspace, source_image, sample_id, category) |
| output_dir.mkdir(parents=True, exist_ok=True) |
| target_path = output_dir / "target_visible.png" |
| obstacle_path = output_dir / "obstacle.png" |
| Image.fromarray(target.astype(np.uint8) * 255, mode="L").save(target_path) |
| Image.fromarray(np.zeros(target.shape, dtype=np.uint8), mode="L").save(obstacle_path) |
| result = { |
| "schema_version": 1, |
| "source_kind": "human_reviewed_visible_mask_only", |
| "sample_id": sample_id, |
| "category": category, |
| "review_status": "approved", |
| "review_is_human": True, |
| "visible_confirmed": True, |
| "annotator": str(metadata["annotator"]).strip(), |
| "reviewed_at_utc": metadata.get("reviewed_at_utc"), |
| "source_image_sha256": file_sha256(source_image), |
| "workspace_metadata_sha256": file_sha256(workspace / "metadata.json"), |
| "target_visible_sha256": file_sha256(target_path), |
| "target_visible_pixels": int(target.sum()), |
| "raster_size": {"width": size[0], "height": size[1]}, |
| "hidden_and_obstacle_policy": "No reviewed hidden/obstacle layer was supplied; an empty obstacle candidate is passed to the adapter and all derived layers remain review candidates.", |
| "automatic_mask_is_ground_truth": False, |
| "target_amodal_is_ground_truth": False, |
| "created_at_utc": datetime.now(timezone.utc).isoformat(), |
| } |
| write_json(output_dir / "metadata.json", result) |
| return result |
|
|
|
|
| def main() -> int: |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument("--workspace", required=True) |
| parser.add_argument("--image", required=True) |
| parser.add_argument("--sample-id", required=True) |
| parser.add_argument("--category", required=True, choices=("curb_cut", "ramp", "stairs", "tactile_paving", "walkway")) |
| parser.add_argument("--output-dir", required=True) |
| args = parser.parse_args() |
| result = materialize_workspace( |
| Path(args.workspace).expanduser().resolve(), |
| Path(args.image).expanduser().resolve(), |
| args.sample_id, |
| args.category, |
| Path(args.output_dir).expanduser().resolve(), |
| ) |
| print(json.dumps(result, ensure_ascii=False)) |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|