| |
| """AccessibilityAmodal one-command mask, 2D completion, and 3D inference. |
| |
| This is a lightweight login-node launcher. GPU work is submitted to Slurm via |
| ``slurm/run_accesspath_demo.sbatch``; this process can wait for the |
| job and report the persistent result directory. |
| |
| The third-party Accessibility3D model is accessed only through the project adapter |
| ``tools/accessibility_3d_completion.py``. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import os |
| import re |
| import shutil |
| import subprocess |
| import sys |
| import time |
| from datetime import datetime |
| from pathlib import Path |
|
|
| from accesspath3r.privacy import public_path, sanitize_text |
|
|
|
|
| PROJECT_ROOT = Path(__file__).resolve().parents[1] |
| SBATCH_SCRIPT = PROJECT_ROOT / "slurm" / "run_accesspath_demo.sbatch" |
| PRIMARY_3D_DIRNAME = "accessibility3d" |
| DEFAULT_PROMPT_CONFIG = ( |
| PROJECT_ROOT / "configs" / "accessibility_mask_prompts_e5_v2.json" |
| ) |
| STRUCTURAL_STAIRS_PROMPT_CONFIG = ( |
| PROJECT_ROOT / "configs" / "accessibility_stairs_structural_occlusion_prompts.json" |
| ) |
| OUTDOOR_STAIRS_NO_OCCLUSION_PROMPT_CONFIG = ( |
| PROJECT_ROOT / "configs" / "accessibility_stairs_outdoor_no_occlusion_prompts.json" |
| ) |
| CATEGORIES = ("curb_cut", "ramp", "stairs", "tactile_paving", "walkway") |
| GEOMETRY_ALLOWED_2D_STATUSES = { |
| "candidate_selected_for_review", |
| "skipped_empty_removal_mask", |
| } |
| TERMINAL_STATES = { |
| "BOOT_FAIL", |
| "CANCELLED", |
| "COMPLETED", |
| "DEADLINE", |
| "FAILED", |
| "NODE_FAIL", |
| "OUT_OF_MEMORY", |
| "PREEMPTED", |
| "REVOKED", |
| "TIMEOUT", |
| } |
|
|
|
|
| def safe_sample_id(value: str) -> str: |
| result = re.sub(r"[^A-Za-z0-9_-]+", "_", value).strip("_") |
| return result or "single_image" |
|
|
|
|
| def normalized_state(value: str) -> str: |
| return value.strip().split()[0].split("+")[0] if value.strip() else "" |
|
|
|
|
| def run_command(command: list[str]) -> subprocess.CompletedProcess[str]: |
| return subprocess.run( |
| command, |
| cwd=PROJECT_ROOT, |
| check=False, |
| capture_output=True, |
| text=True, |
| ) |
|
|
|
|
| def query_job_state(job_id: str) -> str | None: |
| queued = run_command(["squeue", "-h", "-j", job_id, "-o", "%T"]) |
| if queued.returncode == 0 and queued.stdout.strip(): |
| return normalized_state(queued.stdout.splitlines()[0]) |
|
|
| accounting = run_command( |
| [ |
| "sacct", |
| "-n", |
| "-P", |
| "-j", |
| job_id, |
| "--format=JobIDRaw,State,ExitCode", |
| ] |
| ) |
| if accounting.returncode != 0: |
| return None |
| for line in accounting.stdout.splitlines(): |
| fields = line.split("|") |
| if len(fields) >= 2 and fields[0] == job_id: |
| return normalized_state(fields[1]) |
| return None |
|
|
|
|
| def format_clock(seconds: float) -> str: |
| seconds = max(0, int(round(seconds))) |
| hours, remainder = divmod(seconds, 3600) |
| minutes, seconds = divmod(remainder, 60) |
| if hours: |
| return f"{hours:d}:{minutes:02d}:{seconds:02d}" |
| return f"{minutes:02d}:{seconds:02d}" |
|
|
|
|
| def parse_timestamp(value: object) -> float | None: |
| if not isinstance(value, str): |
| return None |
| try: |
| return datetime.fromisoformat(value).timestamp() |
| except ValueError: |
| return None |
|
|
|
|
| def read_progress(output_dir: Path) -> dict | None: |
| try: |
| value = json.loads((output_dir / "progress.json").read_text(encoding="utf-8")) |
| except (FileNotFoundError, json.JSONDecodeError, OSError): |
| return None |
| return value if isinstance(value, dict) else None |
|
|
|
|
| def infer_progress_from_artifacts( |
| output_dir: Path, |
| sample_id: str, |
| include_generative_3d: bool, |
| ) -> dict | None: |
| """Provide useful progress for jobs launched before progress.json existed.""" |
| presentation_manifest = ( |
| output_dir |
| / "04_3d_completion" |
| / "geometry" |
| / "presentation_models" |
| / "manifest.json" |
| ) |
| legacy_variants_manifest = ( |
| output_dir |
| / "04_3d_completion" |
| / "geometry" |
| / "3d_variants" |
| / "manifest.json" |
| ) |
| if not presentation_manifest.is_file() and legacy_variants_manifest.is_file(): |
| presentation_manifest = legacy_variants_manifest |
| learned_manifest = output_dir / PRIMARY_3D_DIRNAME / "manifest.json" |
| current_compatibility_manifest = ( |
| output_dir / "04_3d_completion" / "visual_candidate" / "manifest.json" |
| ) |
| if not learned_manifest.is_file() and current_compatibility_manifest.is_file(): |
| learned_manifest = current_compatibility_manifest |
| final_marker = learned_manifest if include_generative_3d else presentation_manifest |
| if final_marker.is_file(): |
| return { |
| "status": "completed", |
| "percent": 100.0, |
| "stage_label": "All outputs completed", |
| } |
| milestones = ( |
| ( |
| learned_manifest, |
| 96.0, |
| "Accessibility3D CUDA Gaussian rotation and dense triangle mesh", |
| ), |
| ( |
| presentation_manifest, |
| 74.0, |
| "Diagnostic category-constrained geometry", |
| ), |
| ( |
| output_dir / "04_3d_completion" / "geometry" / "completed_mesh_turntable_slow.gif", |
| 44.0, |
| "Continuous-surface and solid 3D views", |
| ), |
| ( |
| output_dir / "04_3d_completion" / "geometry" / "geometry_manifest.json", |
| 43.0, |
| "Basic 3D turntable preview", |
| ), |
| ( |
| output_dir / "03_2d_completion" / "completed_rgb_selected.png", |
| 34.0, |
| "Depth and 3D geometry reconstruction", |
| ), |
| ( |
| output_dir / "02_masks" / "metadata.json", |
| 20.0, |
| "GPU generative 2D completion and candidate selection", |
| ), |
| ( |
| output_dir / "01_sam3" / "summary.json", |
| 16.0, |
| "Visible, hidden, amodal, and obstacle masks", |
| ), |
| ) |
| for marker, percent, label in milestones: |
| if marker.is_file(): |
| return {"status": "running", "percent": percent, "stage_label": label} |
| return None |
|
|
|
|
| def interpolated_percent(progress: dict, now: float) -> float: |
| try: |
| percent = float(progress.get("percent", 0.0)) |
| except (TypeError, ValueError): |
| percent = 0.0 |
| if progress.get("status") != "running": |
| return max(0.0, min(100.0, percent)) |
| try: |
| end_percent = float(progress.get("stage_end_percent", percent)) |
| expected = float(progress.get("stage_expected_seconds", 0.0)) |
| except (TypeError, ValueError): |
| return max(0.0, min(99.0, percent)) |
| stage_started = parse_timestamp(progress.get("stage_started_at")) |
| if stage_started is None or expected <= 0 or end_percent <= percent: |
| return max(0.0, min(99.0, percent)) |
| fraction = max(0.0, min(0.98, (now - stage_started) / expected)) |
| interpolated = percent + (end_percent - percent) * fraction |
| return max(0.0, min(99.0, interpolated)) |
|
|
|
|
| def estimated_remaining_seconds( |
| progress: dict | None, |
| now: float, |
| elapsed: float, |
| base_total: float, |
| ) -> float: |
| """Keep ETA conservative when a stage or an earlier stage runs long.""" |
| adjusted_total = max(1.0, base_total) |
| if not progress or progress.get("status") != "running": |
| return max(0.0, adjusted_total - elapsed) |
| overall_started = parse_timestamp(progress.get("started_at")) |
| stage_started = parse_timestamp(progress.get("stage_started_at")) |
| try: |
| start_percent = float(progress.get("percent", 0.0)) |
| stage_expected = float(progress.get("stage_expected_seconds", 0.0)) |
| except (TypeError, ValueError): |
| return max(0.0, adjusted_total - elapsed) |
| if overall_started is not None and stage_started is not None: |
| planned_stage_start = max(0.0, start_percent) / 100.0 * base_total |
| actual_stage_start = max(0.0, stage_started - overall_started) |
| adjusted_total += max(0.0, actual_stage_start - planned_stage_start) |
| adjusted_total += max(0.0, now - stage_started - stage_expected) |
| return max(0.0, adjusted_total - elapsed) |
|
|
|
|
| class ProgressDisplay: |
| def __init__(self, job_id: str) -> None: |
| self.job_id = job_id |
| self.is_tty = sys.stdout.isatty() |
| self.last_key: tuple[str, str] | None = None |
| self.last_width = 0 |
|
|
| def update( |
| self, |
| percent: float, |
| label: str, |
| elapsed: float, |
| eta: float | None, |
| state: str, |
| final: bool = False, |
| ) -> None: |
| width = 28 |
| filled = min(width, max(0, int(round(width * percent / 100.0)))) |
| bar = "#" * filled + "-" * (width - filled) |
| if state == "PENDING": |
| timing = f"queue {format_clock(elapsed)} | GPU ETA ~{format_clock(eta or 0)}" |
| elif state == "FAILED": |
| timing = f"elapsed {format_clock(elapsed)} | failed" |
| elif eta is None: |
| timing = f"elapsed {format_clock(elapsed)} | ETA calculating" |
| elif eta <= 0 and not final: |
| timing = f"elapsed {format_clock(elapsed)} | finishing" |
| else: |
| timing = f"elapsed {format_clock(elapsed)} | ETA ~{format_clock(eta)}" |
| line = ( |
| f"[Slurm {self.job_id}] [{bar}] {percent:5.1f}% | " |
| f"{label} | {timing}" |
| ) |
| key = (state, label) |
| if self.is_tty: |
| padding = " " * max(0, self.last_width - len(line)) |
| print(f"\r{line}{padding}", end="\n" if final else "", flush=True) |
| self.last_width = len(line) |
| elif key != self.last_key or final: |
| print(line, flush=True) |
| self.last_key = key |
|
|
|
|
| def wait_for_job( |
| job_id: str, |
| poll_seconds: float, |
| output_dir: Path, |
| sample_id: str, |
| include_generative_3d: bool, |
| estimated_seconds: float, |
| ) -> str: |
| missing_polls = 0 |
| submitted_at = time.monotonic() |
| running_at: float | None = None |
| display = ProgressDisplay(job_id) |
| while True: |
| state = query_job_state(job_id) |
| now_wall = time.time() |
| now_monotonic = time.monotonic() |
| progress = read_progress(output_dir) or infer_progress_from_artifacts( |
| output_dir, |
| sample_id, |
| include_generative_3d, |
| ) |
|
|
| if state: |
| missing_polls = 0 |
| if state == "RUNNING" and running_at is None: |
| running_at = now_monotonic |
| else: |
| |
| missing_polls += 1 |
| if missing_polls >= 12 and not progress: |
| raise RuntimeError( |
| "Unable to read the job state from squeue or sacct. " |
| f"Check manually with: squeue -j {job_id}" |
| ) |
|
|
| progress_status = progress.get("status") if progress else None |
| if progress_status == "completed": |
| runtime_started = parse_timestamp(progress.get("started_at")) |
| elapsed = now_wall - runtime_started if runtime_started else 0.0 |
| display.update(100.0, "All outputs completed", elapsed, 0.0, "COMPLETED", final=True) |
| return "COMPLETED" |
| if progress_status == "failed": |
| runtime_started = parse_timestamp(progress.get("started_at")) |
| elapsed = now_wall - runtime_started if runtime_started else 0.0 |
| display.update( |
| interpolated_percent(progress, now_wall), |
| str(progress.get("message") or progress.get("stage_label") or "GPU stage failed"), |
| elapsed, |
| None, |
| "FAILED", |
| final=True, |
| ) |
| return "FAILED" |
|
|
| if state == "PENDING": |
| display.update( |
| 0.0, |
| "Waiting for a GPU", |
| now_monotonic - submitted_at, |
| estimated_seconds, |
| state, |
| ) |
| else: |
| if running_at is None: |
| running_at = now_monotonic |
| runtime_started = parse_timestamp(progress.get("started_at")) if progress else None |
| elapsed = ( |
| max(0.0, now_wall - runtime_started) |
| if runtime_started is not None |
| else now_monotonic - running_at |
| ) |
| total_estimate = estimated_seconds |
| if progress: |
| try: |
| total_estimate = float(progress.get("total_estimated_seconds", estimated_seconds)) |
| except (TypeError, ValueError): |
| pass |
| percent = interpolated_percent(progress, now_wall) if progress else 0.0 |
| label = ( |
| str(progress.get("stage_label") or "Initializing GPU job") |
| if progress |
| else "Initializing GPU job" |
| ) |
| display.update( |
| percent, |
| label, |
| elapsed, |
| estimated_remaining_seconds(progress, now_wall, elapsed, total_estimate), |
| state or "UNKNOWN", |
| final=bool(state in TERMINAL_STATES), |
| ) |
|
|
| if state in TERMINAL_STATES: |
| return state |
| time.sleep(poll_seconds) |
|
|
|
|
| def estimate_runtime_seconds(args: argparse.Namespace) -> int: |
| |
| |
| |
| |
| seconds = (5 if args.reviewed_visible_workspace else 150) + 13 + (24 if not args.no_depth else 6) + 3 + 420 |
| if not args.no_2d: |
| seconds += 40 |
| if args.fast_2d_baseline: |
| seconds += 12 |
| if not args.no_generative_3d: |
| seconds += 75 |
| if args.export_glb: |
| seconds += 540 |
| return seconds |
|
|
|
|
| def build_parser() -> argparse.ArgumentParser: |
| parser = argparse.ArgumentParser( |
| description=( |
| "Submit one accessibility image to SAM3 mask inference, amodal/hidden " |
| "completion, 2D completion, Depth Anything geometry reconstruction, " |
| "and Accessibility3D CUDA Gaussian rendering with a dense triangle mesh." |
| ) |
| ) |
| parser.add_argument("image_pos", nargs="?", help="Input RGB image (positional form).") |
| parser.add_argument("--image", dest="image_opt", help="Input RGB image.") |
| parser.add_argument("--category", required=True, choices=CATEGORIES) |
| parser.add_argument("--sample-id", default=None) |
| parser.add_argument( |
| "--output-dir", |
| default=None, |
| help=( |
| "Persistent output directory. " |
| "Default: output/inference/<sample>_<time>." |
| ), |
| ) |
| parser.add_argument( |
| "--prompt-config", |
| default=None, |
| help=( |
| "Optional expert override for the mask prompt JSON. By default, a unified " |
| "target/occluder prompt bank is evaluated against the input image, so the " |
| "caller does not need to choose a scene-specific JSON." |
| ), |
| ) |
| parser.add_argument( |
| "--reviewed-visible-workspace", |
| default=None, |
| help=( |
| "A one-image workspace saved by serve_accessibility_visible_mask_review.py. " |
| "Requires explicit human approval; replaces the SAM3 visible-target proposal only." |
| ), |
| ) |
| parser.add_argument( |
| "--structural-occluder", |
| action="store_true", |
| help=( |
| "For stairs occluded by a pillar, newel post, banister, or railing. " |
| "Selects the dedicated structural-occlusion prompt config." |
| ), |
| ) |
| parser.add_argument( |
| "--no-target-occluder", |
| action="store_true", |
| help=( |
| "For a visible outdoor stair flight with no target-surface occluder. " |
| "Uses prompts that exclude permanent handrails and side walls from the obstacle mask." |
| ), |
| ) |
| parser.add_argument( |
| "--sd-2d", |
| action="store_true", |
| help="Legacy compatibility flag; GPU generative 2D completion is enabled by default.", |
| ) |
| parser.add_argument( |
| "--fast-2d-baseline", |
| action="store_true", |
| help="Also produce the old OpenCV 2D baseline for comparison (not a primary result).", |
| ) |
| parser.add_argument("--no-2d", action="store_true", help="Skip GPU generative 2D completion.") |
| parser.add_argument("--no-depth", action="store_true", help="Use debug perspective geometry instead of Depth Anything.") |
| parser.add_argument( |
| "--no-generative-3d", |
| action="store_true", |
| help="Skip the primary Accessibility3D CUDA rotation and dense learned mesh.", |
| ) |
| parser.add_argument( |
| "--export-glb", |
| action="store_true", |
| help="Also export the Accessibility3D dense triangle result as mesh.glb.", |
| ) |
| parser.add_argument("--allow-large-2d", action="store_true", help="Allow 2D completion masks larger than the normal safety gate.") |
| parser.add_argument("--no-wait", action="store_true", help="Submit and return immediately instead of waiting for results.") |
| parser.add_argument("--poll-seconds", type=float, default=5.0, help=argparse.SUPPRESS) |
| parser.add_argument("--dry-run", action="store_true", help="Validate and print the planned submission without submitting.") |
| return parser |
|
|
|
|
| def resolve_args(args: argparse.Namespace, parser: argparse.ArgumentParser) -> dict[str, str]: |
| if bool(args.image_pos) == bool(args.image_opt): |
| parser.error("provide exactly one image, either positional or with --image") |
| image = Path(args.image_opt or args.image_pos).expanduser().resolve() |
| if not image.is_file(): |
| parser.error( |
| "input image does not exist: " |
| f"{public_path(image, project_root=PROJECT_ROOT)}" |
| ) |
| if args.poll_seconds < 2: |
| parser.error("--poll-seconds must be at least 2") |
| if args.structural_occluder and args.category != "stairs": |
| parser.error("--structural-occluder currently applies only to --category stairs") |
| if args.no_target_occluder and args.category != "stairs": |
| parser.error("--no-target-occluder currently applies only to --category stairs") |
| if args.structural_occluder and args.no_target_occluder: |
| parser.error("--structural-occluder and --no-target-occluder are mutually exclusive") |
| if (args.structural_occluder or args.no_target_occluder) and args.prompt_config: |
| parser.error("choose one of --structural-occluder, --no-target-occluder, or --prompt-config") |
| reviewed_visible_workspace = None |
| if args.reviewed_visible_workspace: |
| reviewed_visible_workspace = Path(args.reviewed_visible_workspace).expanduser().resolve() |
| if not reviewed_visible_workspace.is_dir(): |
| parser.error( |
| "reviewed-visible workspace does not exist: " |
| f"{public_path(reviewed_visible_workspace, project_root=PROJECT_ROOT)}" |
| ) |
|
|
| if args.structural_occluder: |
| prompt_config = STRUCTURAL_STAIRS_PROMPT_CONFIG |
| elif args.no_target_occluder: |
| prompt_config = OUTDOOR_STAIRS_NO_OCCLUSION_PROMPT_CONFIG |
| elif args.prompt_config: |
| prompt_config = Path(args.prompt_config).expanduser().resolve() |
| else: |
| prompt_config = DEFAULT_PROMPT_CONFIG |
| if not prompt_config.is_file(): |
| parser.error( |
| "prompt config does not exist: " |
| f"{public_path(prompt_config, project_root=PROJECT_ROOT)}" |
| ) |
| if not SBATCH_SCRIPT.is_file(): |
| parser.error( |
| "Slurm script does not exist: " |
| f"{public_path(SBATCH_SCRIPT, project_root=PROJECT_ROOT)}" |
| ) |
| if shutil.which("sbatch") is None: |
| parser.error("sbatch is unavailable; run this command on the configured Slurm server") |
|
|
| sample_id = safe_sample_id(args.sample_id or image.stem) |
| if args.output_dir: |
| output_dir = Path(args.output_dir).expanduser().resolve() |
| else: |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| output_dir = PROJECT_ROOT / "output" / "inference" / f"{sample_id}_{timestamp}" |
|
|
| return { |
| "IMAGE": str(image), |
| "CATEGORY": args.category, |
| "SAMPLE_ID": sample_id, |
| "OUTPUT_DIR": str(output_dir), |
| "PROMPT_CONFIG": str(prompt_config), |
| "RUN_GPU_2D": "0" if args.no_2d else "1", |
| "RUN_FAST_2D": "1" if args.fast_2d_baseline else "0", |
| "ALLOW_LARGE_2D": "1" if args.allow_large_2d else "0", |
| "RUN_DEPTH": "0" if args.no_depth else "1", |
| "RUN_ACCESSIBILITYAMODAL_VISUAL_3D": "0" if args.no_generative_3d else "1", |
| "EXPORT_ACCESSIBILITYAMODAL_VISUAL_GLB": "1" if args.export_glb else "0", |
| "NO_TARGET_OCCLUDER": "1" if args.no_target_occluder else "0", |
| "REVIEWED_VISIBLE_WORKSPACE": str(reviewed_visible_workspace) if reviewed_visible_workspace else "", |
| } |
|
|
|
|
| def print_result_paths( |
| output_dir: Path, |
| sample_id: str, |
| include_gpu_2d: bool, |
| include_generative_3d: bool, |
| include_glb: bool, |
| reviewed_visible: bool = False, |
| ) -> None: |
| def shown(path: Path) -> str: |
| return public_path( |
| path, |
| project_root=PROJECT_ROOT, |
| output_root=output_dir, |
| ) |
|
|
| print("\nResults:") |
| print(f" output root: {shown(output_dir)}") |
| if include_gpu_2d: |
| print( |
| f" quick review: " |
| f"{shown(output_dir / '00_quick_review' / 'overview.jpg')}" |
| ) |
| if reviewed_visible: |
| print(f" reviewed visible: {shown(output_dir / '01_reviewed_visible' / 'metadata.json')}") |
| else: |
| print(f" SAM3 overlay: {shown(output_dir / '01_sam3' / 'samples' / sample_id / 'overlay.png')}") |
| print(f" mask overlay: {shown(output_dir / '02_masks' / 'mask_overlay.png')}") |
| print(f" visible mask: {shown(output_dir / '02_masks' / 'target_visible.png')}") |
| print(f" hidden mask: {shown(output_dir / '02_masks' / 'hidden.png')}") |
| print(f" amodal mask: {shown(output_dir / '02_masks' / 'target_amodal.png')}") |
| print(f" obstacle mask: {shown(output_dir / '02_masks' / 'obstacle.png')}") |
| if include_gpu_2d: |
| print(f" GPU 2D selected: {shown(output_dir / '03_2d_completion' / 'completed_rgb_selected.png')}") |
| print(f" GPU 2D candidates: {shown(output_dir / '03_2d_completion' / 'candidate_comparison.jpg')}") |
| print(f" depth diagnostic: {shown(output_dir / '04_3d_completion' / 'geometry' / 'completed_depth_vis.png')}") |
| print(f" point-cloud debug: {shown(output_dir / '04_3d_completion' / 'geometry' / 'completed_point_cloud.ply')}") |
| print(f" geometry debug: {shown(output_dir / '04_3d_completion' / 'geometry' / 'completed_mesh.ply')}") |
| print(f" geometry fallback: {shown(output_dir / '04_3d_completion' / 'turntable' / 'turntable.gif')}") |
| print( |
| " presentation 3D: " |
| f"{shown(output_dir / '04_3d_completion' / 'geometry' / 'presentation_models')}" |
| ) |
| if include_generative_3d: |
| visual_dir = output_dir / PRIMARY_3D_DIRNAME |
| print(f" quality checks: {shown(output_dir / '05_quality_checks' / 'verification.json')}") |
| print(f" primary 3D GIF: {shown(visual_dir / 'sample_gaussian.gif')}") |
| print(f" mesh normal diag: {shown(visual_dir / 'sample_mesh.gif')}") |
| print(f" primary 3D views: {shown(visual_dir / 'multiview_contact_sheet.jpg')}") |
| print(f" dense face mesh: {shown(visual_dir / 'mesh.ply')}") |
| if include_glb: |
| print(f" textured 3D GLB: {shown(visual_dir / 'mesh.glb')}") |
|
|
|
|
| def main(argv: list[str] | None = None) -> int: |
| parser = build_parser() |
| args = parser.parse_args(argv) |
| job_env = resolve_args(args, parser) |
| output_dir = Path(job_env["OUTPUT_DIR"]) |
| sample_id = job_env["SAMPLE_ID"] |
|
|
| print("AccessibilityAmodal one-command inference") |
| print( |
| " image: " |
| f"{public_path(job_env['IMAGE'], project_root=PROJECT_ROOT)}" |
| ) |
| print(f" category: {job_env['CATEGORY']}") |
| if job_env["REVIEWED_VISIBLE_WORKSPACE"]: |
| print(" visible mask: human-reviewed workspace (SAM3 proposal bypassed)") |
| print(" prompt config: not used after reviewed-mask validation") |
| else: |
| print( |
| " prompt config: " |
| f"{public_path(job_env['PROMPT_CONFIG'], project_root=PROJECT_ROOT)}" |
| ) |
| print( |
| " output: " |
| f"{public_path(output_dir, project_root=PROJECT_ROOT, output_root=output_dir)}" |
| ) |
| print(" GPU execution: Slurm, one GPU (5090 partition)") |
| print( |
| " 3D outputs: diagnostic depth/point cloud" |
| + ( |
| " + primary Accessibility3D CUDA Gaussian rotation/dense mesh" |
| if not args.no_generative_3d |
| else " + category-geometry fallback" |
| ) |
| ) |
| estimated_seconds = estimate_runtime_seconds(args) |
| print( |
| f" estimated time: about {format_clock(estimated_seconds)} after GPU starts " |
| "(Slurm queue excluded)" |
| ) |
|
|
| command = ["sbatch", "--parsable", "--export=ALL", str(SBATCH_SCRIPT)] |
| if args.dry_run: |
| print("Dry run; no job submitted.") |
| shown_command = [*command[:-1], public_path(command[-1], project_root=PROJECT_ROOT)] |
| print(" " + " ".join(shown_command)) |
| return 0 |
|
|
| environment = os.environ.copy() |
| environment.update(job_env) |
| submitted = subprocess.run( |
| command, |
| cwd=PROJECT_ROOT, |
| env=environment, |
| check=False, |
| capture_output=True, |
| text=True, |
| ) |
| if submitted.returncode != 0: |
| detail = submitted.stderr.strip() or submitted.stdout.strip() |
| raise RuntimeError( |
| "Slurm submission failed: " |
| + sanitize_text( |
| detail, |
| project_root=PROJECT_ROOT, |
| output_root=output_dir, |
| sensitive_paths=(job_env["IMAGE"], job_env["PROMPT_CONFIG"]), |
| ) |
| ) |
| job_id = submitted.stdout.strip().split(";")[0] |
| if not job_id.isdigit(): |
| raise RuntimeError(f"Could not parse Slurm job ID from: {submitted.stdout!r}") |
|
|
| print(f"Submitted Slurm job {job_id}") |
| print(f" stdout: Logs/slurm-{job_id}.out") |
| print(f" stderr: Logs/slurm-{job_id}.err") |
| if args.no_wait: |
| print(f"Monitor with: squeue -j {job_id}") |
| print_result_paths( |
| output_dir, |
| sample_id, |
| not args.no_2d, |
| not args.no_generative_3d, |
| args.export_glb, |
| bool(job_env["REVIEWED_VISIBLE_WORKSPACE"]), |
| ) |
| return 0 |
|
|
| try: |
| state = wait_for_job( |
| job_id, |
| args.poll_seconds, |
| output_dir, |
| sample_id, |
| not args.no_generative_3d, |
| estimated_seconds, |
| ) |
| except KeyboardInterrupt: |
| print( |
| f"\nStopped waiting; Slurm job {job_id} is still managed separately. " |
| f"Cancel it only if needed with: scancel {job_id}", |
| file=sys.stderr, |
| ) |
| return 130 |
|
|
| if state != "COMPLETED": |
| raise RuntimeError( |
| f"Slurm job {job_id} ended with state {state}. " |
| f"Inspect Logs/slurm-{job_id}.err" |
| ) |
| expected = output_dir / "02_masks" / "mask_overlay.png" |
| if not expected.is_file(): |
| raise RuntimeError( |
| f"Slurm job {job_id} completed but the expected mask output is missing: " |
| f"{public_path(expected, project_root=PROJECT_ROOT, output_root=output_dir)}" |
| ) |
| preflight_path = output_dir / "05_quality_checks" / "preflight.json" |
| if preflight_path.is_file(): |
| try: |
| preflight = json.loads(preflight_path.read_text(encoding="utf-8")) |
| except (json.JSONDecodeError, OSError): |
| preflight = {} |
| if preflight.get("decision") != "accept": |
| print( |
| "\n3D generation was intentionally withheld because the mask/structure " |
| "preflight requires re-prompting or human review." |
| ) |
| print( |
| " verification: " |
| f"{public_path(preflight_path, project_root=PROJECT_ROOT, output_root=output_dir)}" |
| ) |
| print("Automatic masks are review candidates, not ground truth.") |
| return 0 |
| completion_manifest_path = output_dir / "03_2d_completion" / "manifest.json" |
| if not args.no_2d: |
| if not completion_manifest_path.is_file(): |
| raise RuntimeError( |
| "Slurm job completed but the 2D completion manifest is missing: " |
| f"{public_path(completion_manifest_path, project_root=PROJECT_ROOT, output_root=output_dir)}" |
| ) |
| try: |
| completion_manifest = json.loads( |
| completion_manifest_path.read_text(encoding="utf-8") |
| ) |
| except (json.JSONDecodeError, OSError) as exc: |
| raise RuntimeError("The 2D completion manifest is unreadable") from exc |
| completion_status = completion_manifest.get("status") |
| if completion_status not in GEOMETRY_ALLOWED_2D_STATUSES: |
| print( |
| "\nThe 2D result needs review and was not used to condition 3D: " |
| f"{completion_status!r}. Accessibility3D used the original RGB and strict " |
| "three-value mask instead." |
| ) |
| comparison = output_dir / "03_2d_completion" / "candidate_comparison.jpg" |
| if comparison.is_file(): |
| print( |
| " candidates: " |
| f"{public_path(comparison, project_root=PROJECT_ROOT, output_root=output_dir)}" |
| ) |
|
|
| if not args.no_generative_3d: |
| verification_path = output_dir / "05_quality_checks" / "verification.json" |
| if not verification_path.is_file(): |
| raise RuntimeError( |
| "Slurm job completed but the generated-3D verification report is missing: " |
| f"{public_path(verification_path, project_root=PROJECT_ROOT, output_root=output_dir)}" |
| ) |
| try: |
| verification = json.loads(verification_path.read_text(encoding="utf-8")) |
| except (json.JSONDecodeError, OSError) as exc: |
| raise RuntimeError("Generated-3D verification report is unreadable") from exc |
| if verification.get("decision") != "accept": |
| print( |
| "\nThe Accessibility3D result remains available for human review, but its " |
| "structured verification did not accept automatic use." |
| ) |
| print( |
| " verification: " |
| f"{public_path(verification_path, project_root=PROJECT_ROOT, output_root=output_dir)}" |
| ) |
|
|
| print_result_paths( |
| output_dir, |
| sample_id, |
| not args.no_2d, |
| not args.no_generative_3d, |
| args.export_glb, |
| bool(job_env["REVIEWED_VISIBLE_WORKSPACE"]), |
| ) |
| print("\nAutomatic masks are review candidates, not ground truth.") |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|