diff --git a/docs/source/api_reference/public_api.rst b/docs/source/api_reference/public_api.rst index b103d8f9d..3950fcde5 100644 --- a/docs/source/api_reference/public_api.rst +++ b/docs/source/api_reference/public_api.rst @@ -56,6 +56,100 @@ embodichain.data_pipeline.depth_video DEPTH_MILLIMETER_UNIT DEPTH_QMAX +embodichain.gen_sim.scene_engine.core.scene_edit_plan +------------------------------------------------------ + +.. currentmodule:: embodichain.gen_sim.scene_engine.core.scene_edit_plan + +.. autosummary:: + + SceneEditOperation + SceneEditPlan + +embodichain.gen_sim.scene_engine.core.scene_graph +-------------------------------------------------- + +.. currentmodule:: embodichain.gen_sim.scene_engine.core.scene_graph + +.. autosummary:: + + GENERATED_SCENE_GRAPH_SCHEMA + GeneratedSceneGraph + GeneratedSceneNode + GeneratedSceneRelation + SceneGraph + SceneGraphNode + SceneGraphRelation + OrientationState + PlanarRelationType + SceneConstraintType + SupportRelationType + TABLE_OBJECT_ID + TABLE_REGIONS + TableRegion + +embodichain.gen_sim.scene_engine.core.scene_object +--------------------------------------------------- + +.. currentmodule:: embodichain.gen_sim.scene_engine.core.scene_object + +.. autosummary:: + + ObjectPhysics + SceneObject + +embodichain.gen_sim.scene_engine.errors +----------------------------------------- + +.. currentmodule:: embodichain.gen_sim.scene_engine.errors + +.. autosummary:: + + SceneServiceError + +embodichain.gen_sim.scene_engine.pipeline +----------------------------------------- + +.. currentmodule:: embodichain.gen_sim.scene_engine.pipeline + +.. autosummary:: + + SCENE_BLUEPRINT_SCHEMA + SCENE_EDIT_BLUEPRINT_SCHEMA + SceneBlueprintPackage + SceneEditBlueprintPackage + SceneMaterialization + analyze_edit + analyze_image + materialize_blueprint + materialize_edit + +embodichain.gen_sim.scene_engine.pipeline.api +--------------------------------------------- + +.. currentmodule:: embodichain.gen_sim.scene_engine.pipeline.api + +.. autosummary:: + + SCENE_BLUEPRINT_SCHEMA + SCENE_EDIT_BLUEPRINT_SCHEMA + SceneBlueprintPackage + SceneEditBlueprintPackage + SceneMaterialization + analyze_edit + analyze_image + materialize_blueprint + materialize_edit + +embodichain.gen_sim.scene_engine.pipeline.editing.scene_edit_asset_preparation +----------------------------------------------------------------------------- + +.. currentmodule:: embodichain.gen_sim.scene_engine.pipeline.editing.scene_edit_asset_preparation + +.. autosummary:: + + prepare_scene_edit_assets + embodichain.gen_sim.simready_pipeline.cli.start ----------------------------------------------- diff --git a/embodichain/gen_sim/scene_engine/clients/geometry_generation.py b/embodichain/gen_sim/scene_engine/clients/geometry_generation.py index c84fe4c26..8f4d13a13 100644 --- a/embodichain/gen_sim/scene_engine/clients/geometry_generation.py +++ b/embodichain/gen_sim/scene_engine/clients/geometry_generation.py @@ -25,6 +25,8 @@ import requests +from embodichain.gen_sim.scene_engine.errors import SceneServiceError + from embodichain.gen_sim.scene_engine.configs.environment import ( read_scene_engine_env_values, ) @@ -77,7 +79,7 @@ def check_health(self) -> None: last_error = exc assert last_error is not None - raise RuntimeError( + raise SceneServiceError( "Geometry Generation Server health check failed after " f"{self._max_attempts} attempts." ) from last_error @@ -91,6 +93,7 @@ def generate_objects( image_path: str | Path, object_masks: list[tuple[str, Path]], output_root: str | Path, + seed: int | None = None, ) -> tuple[dict[str, Any], list[dict[str, Any]]]: """Generate objects through the geometry server's mask-list endpoint. @@ -125,6 +128,7 @@ def generate_objects( response_data, response_objects = self._request_objects( image_path=resolved_image_path, object_masks=resolved_object_masks, + seed=seed, ) resolved_output_root = Path(output_root).expanduser().resolve() @@ -158,6 +162,7 @@ def _request_objects( *, image_path: Path, object_masks: list[tuple[str, Path]], + seed: int | None, ) -> tuple[dict[str, Any], list[dict[str, Any]]]: last_error: Exception | None = None for _ in range(self._max_attempts): @@ -171,6 +176,7 @@ def _request_objects( ] response = self._session.post( self._url(self._generate_objects_path), + data=(None if seed is None else {"seed": str(int(seed))}), files=[ ( "image", @@ -201,6 +207,11 @@ def _request_objects( "Geometry Generation Server response is not valid JSON." ) from exc response_data = self._wait_for_task_if_needed(response_data) + if seed is not None and _response_seed(response_data) != int(seed): + raise RuntimeError( + "Geometry Generation Server did not acknowledge the " + f"requested seed {int(seed)}." + ) response_objects = _parse_objects_response( response_data, object_ids=[object_id for object_id, _ in object_masks], @@ -210,7 +221,7 @@ def _request_objects( last_error = exc assert last_error is not None - raise RuntimeError( + raise SceneServiceError( "Geometry Generation Server request failed after " f"{self._max_attempts} attempts." ) from last_error @@ -294,7 +305,7 @@ def _download_glb(self, mesh_path: str, output_path: Path) -> None: last_error = exc assert last_error is not None - raise RuntimeError( + raise SceneServiceError( "Geometry Generation Server GLB download failed after " f"{self._max_attempts} attempts: {mesh_path}" ) from last_error @@ -374,6 +385,25 @@ def _parse_objects_response( return parsed_objects +def _response_seed(value: object) -> int | None: + """Return a seed acknowledged by a geometry response envelope.""" + if not isinstance(value, dict): + return None + candidates = [value.get("seed")] + for key in ("result", "metadata"): + nested = value.get(key) + if isinstance(nested, dict): + candidates.append(nested.get("seed")) + for candidate in candidates: + if candidate is None or isinstance(candidate, bool): + continue + try: + return int(candidate) + except (TypeError, ValueError): + continue + return None + + def _parse_numeric_list( value: object, *, diff --git a/embodichain/gen_sim/scene_engine/clients/image_generation.py b/embodichain/gen_sim/scene_engine/clients/image_generation.py index 4286e26f8..4f59fff11 100644 --- a/embodichain/gen_sim/scene_engine/clients/image_generation.py +++ b/embodichain/gen_sim/scene_engine/clients/image_generation.py @@ -21,6 +21,8 @@ import requests +from embodichain.gen_sim.scene_engine.errors import SceneServiceError + from embodichain.gen_sim.scene_engine.configs.environment import ( read_scene_engine_env_values, ) @@ -73,7 +75,7 @@ def check_health(self) -> None: last_error = exc assert last_error is not None - raise RuntimeError( + raise SceneServiceError( "Image Generation Server health check failed after " f"{self._max_attempts} attempts." ) from last_error @@ -86,6 +88,7 @@ def generate_image_by_prompt( *, prompt: str, output_path: str | Path, + seed: int | None = None, ) -> Path: """Generate one PNG image from ``prompt`` and save it to ``output_path``.""" prompt = prompt.strip() @@ -100,10 +103,27 @@ def generate_image_by_prompt( try: response = self._session.post( self._url(self._generate_image_by_prompt_path), - json={"prompt": prompt}, + json={ + "prompt": prompt, + **({} if seed is None else {"seed": int(seed)}), + }, timeout=self._timeout_s, ) response.raise_for_status() + if seed is not None: + acknowledged = response.headers.get( + "x-generation-seed", + response.headers.get("x-seed"), + ) + try: + acknowledged_seed = int(acknowledged) + except (TypeError, ValueError): + acknowledged_seed = None + if acknowledged_seed != int(seed): + raise RuntimeError( + "Image Generation Server did not acknowledge the " + f"requested seed {int(seed)}." + ) content_type = response.headers.get("content-type", "").split(";")[0] if content_type != "image/png": raise RuntimeError( @@ -115,7 +135,7 @@ def generate_image_by_prompt( last_error = exc assert last_error is not None - raise RuntimeError( + raise SceneServiceError( "Image Generation Server request failed after " f"{self._max_attempts} attempts." ) from last_error diff --git a/embodichain/gen_sim/scene_engine/core/scene_edit_plan.py b/embodichain/gen_sim/scene_engine/core/scene_edit_plan.py index 902f6eda7..8a903ff1c 100644 --- a/embodichain/gen_sim/scene_engine/core/scene_edit_plan.py +++ b/embodichain/gen_sim/scene_engine/core/scene_edit_plan.py @@ -23,11 +23,13 @@ from embodichain.gen_sim.scene_engine.core.scene_graph import ( OrientationState, SceneConstraintType, - SceneGraph, + GeneratedSceneGraph, TableRegion, TABLE_OBJECT_ID, ) +__all__ = ["SceneEditOperation", "SceneEditPlan"] + SceneEditOperationType = Literal["add", "move", "delete"] @@ -65,7 +67,7 @@ class SceneEditPlan: """Validated operations against one immutable pre-edit scene state.""" scene: Scene - scene_graph: SceneGraph + scene_graph: GeneratedSceneGraph operations: list[SceneEditOperation] = field(default_factory=list) def __post_init__(self) -> None: @@ -92,7 +94,7 @@ def validate(self) -> None: # - every target is from the pre-edit scene; new and deleted objects are invalid targets. # - an existing object has at most one move or delete operation in one plan. # - delete carries no placement or new-object metadata and must delete every descendant. - # - these checks validate intent only; they do not mutate Scene or SceneGraph. + # - these checks validate intent only; they do not mutate Scene or GeneratedSceneGraph. # Scene object IDs must remain a one-to-one lookup key for edit operations. scene_object_ids = {scene_object.id for scene_object in self.scene.objects} if len(scene_object_ids) != len(self.scene.objects): diff --git a/embodichain/gen_sim/scene_engine/core/scene_graph.py b/embodichain/gen_sim/scene_engine/core/scene_graph.py index b43c7353a..74ea8e4f1 100644 --- a/embodichain/gen_sim/scene_engine/core/scene_graph.py +++ b/embodichain/gen_sim/scene_engine/core/scene_graph.py @@ -19,6 +19,24 @@ from dataclasses import dataclass, field from typing import Literal +__all__ = [ + "GENERATED_SCENE_GRAPH_SCHEMA", + "GeneratedSceneGraph", + "GeneratedSceneNode", + "GeneratedSceneRelation", + "OrientationState", + "PlanarRelationType", + "SceneConstraintType", + "SceneGraph", + "SceneGraphNode", + "SceneGraphRelation", + "SupportRelationType", + "TABLE_OBJECT_ID", + "TABLE_REGIONS", + "TableRegion", +] + +GENERATED_SCENE_GRAPH_SCHEMA = "generated_scene_graph/v1" TABLE_OBJECT_ID = "table" # Static type constraint for the nine regions of the tabletop 3x3 grid. @@ -57,9 +75,15 @@ OrientationState = Literal["standing", "lying"] +def _validate_stable_id(value: str, *, field_name: str) -> None: + """Reject identifiers whose spelling can change during serialization.""" + if not isinstance(value, str) or not value or value != value.strip(): + raise ValueError(f"{field_name} must be a non-empty, trimmed string.") + + @dataclass -class SceneGraphNode: - """One object node in the edit-time scene hierarchy. +class GeneratedSceneNode: + """One object node in the authoring-only scene hierarchy. ``orientation_state`` is an image-derived placement semantic, rather than an edge to the node itself or an exact three-dimensional transform. @@ -74,8 +98,9 @@ class SceneGraphNode: def __post_init__(self) -> None: """Validate local node fields before graph-level checks.""" - if not self.object_id: - raise ValueError("object_id must be non-empty.") + _validate_stable_id(self.object_id, field_name="object_id") + if self.parent_id is not None: + _validate_stable_id(self.parent_id, field_name="parent_id") if self.table_region not in {None, *TABLE_REGIONS}: raise ValueError("table_region is invalid.") if self.orientation_state not in {None, "standing", "lying"}: @@ -106,8 +131,8 @@ def to_dict(self) -> dict[str, object]: @dataclass -class SceneGraphRelation: - """One edit-time spatial relation between two non-table objects.""" +class GeneratedSceneRelation: + """One authoring-time spatial relation between two non-table objects.""" source_id: str relation: PlanarRelationType @@ -115,8 +140,8 @@ class SceneGraphRelation: def __post_init__(self) -> None: """Validate local relation fields before graph-level checks.""" - if not self.source_id or not self.target_id: - raise ValueError("relation endpoints must be non-empty.") + _validate_stable_id(self.source_id, field_name="source_id") + _validate_stable_id(self.target_id, field_name="target_id") if self.source_id == self.target_id: raise ValueError("relation endpoints must be different.") @@ -130,11 +155,17 @@ def to_dict(self) -> dict[str, object]: @dataclass -class SceneGraph: - """Layered support graph plus planar relations for scene editing.""" +class GeneratedSceneGraph: + """Authoring graph for generation and editing, never live scene state. - nodes: list[SceneGraphNode] = field(default_factory=list) - relations: list[SceneGraphRelation] = field(default_factory=list) + The graph contains provider-free identities and image-derived spatial + semantics. It deliberately has no simulator handles, pose readers, or + registration behavior; a later integration layer converts it to the + canonical runtime scene contracts. + """ + + nodes: list[GeneratedSceneNode] = field(default_factory=list) + relations: list[GeneratedSceneRelation] = field(default_factory=list) validate_on_refresh: bool = True # Validate after each automatic refresh. def __post_init__(self) -> None: @@ -148,9 +179,9 @@ def refresh(self) -> None: if self.validate_on_refresh: self.validate() - def node_by_id(self) -> dict[str, SceneGraphNode]: + def node_by_id(self) -> dict[str, GeneratedSceneNode]: """Return nodes keyed by object id, raising on duplicate ids.""" - nodes_by_id: dict[str, SceneGraphNode] = {} + nodes_by_id: dict[str, GeneratedSceneNode] = {} for node in self.nodes: if node.object_id in nodes_by_id: raise ValueError(f"Duplicate scene graph node: {node.object_id}") @@ -181,7 +212,7 @@ def remove_nodes(self, object_ids: set[str]) -> None: # Refresh. self.refresh() - def add_node(self, node: SceneGraphNode) -> None: + def add_node(self, node: GeneratedSceneNode) -> None: """Add one node and validate the resulting graph.""" if node.object_id in self.node_by_id(): raise ValueError(f"Duplicate scene graph node: {node.object_id}") @@ -232,7 +263,7 @@ def apply_updates( # New nodes default to the table; later updates replace that parent when needed. self.nodes.extend( - SceneGraphNode( + GeneratedSceneNode( object_id=object_id, parent_id=TABLE_OBJECT_ID, parent_relation="on", @@ -257,7 +288,7 @@ def apply_updates( self._clear_incident_planar_relations(source_id) for source_id, relation, target_id in planar_relation_updates: self.relations.append( - SceneGraphRelation( + GeneratedSceneRelation( source_id=source_id, relation=relation, target_id=target_id, @@ -432,15 +463,17 @@ def validate(self) -> None: self._validate_planar_relation_conflicts() def to_dict(self) -> dict[str, object]: - """Serialize the normalized graph state.""" + """Serialize the normalized, versioned authoring artifact.""" self.refresh() return { + "schema_version": GENERATED_SCENE_GRAPH_SCHEMA, + "artifact_kind": "scene_authoring", "nodes": [node.to_dict() for node in self.nodes], "relations": [relation.to_dict() for relation in self.relations], } - def _children_by_parent(self) -> dict[str, list[SceneGraphNode]]: - children_by_parent: dict[str, list[SceneGraphNode]] = {} + def _children_by_parent(self) -> dict[str, list[GeneratedSceneNode]]: + children_by_parent: dict[str, list[GeneratedSceneNode]] = {} for node in self.nodes: if node.parent_id is not None: children_by_parent.setdefault(node.parent_id, []).append(node) @@ -448,7 +481,7 @@ def _children_by_parent(self) -> dict[str, list[SceneGraphNode]]: def _deduplicate_relations(self) -> None: """Remove duplicate planar relations while preserving the first occurrence.""" - deduplicated: list[SceneGraphRelation] = [] + deduplicated: list[GeneratedSceneRelation] = [] seen: set[tuple[str, PlanarRelationType, str]] = set() for relation in self.relations: key = (relation.source_id, relation.relation, relation.target_id) @@ -462,7 +495,7 @@ def _deduplicate_relations(self) -> None: def _materialize_inverse_planar_relations(self) -> None: """Add the inverse of every planar relation to the graph.""" inverse_relations = [ - SceneGraphRelation( + GeneratedSceneRelation( source_id=relation.target_id, relation=self._inverse_planar_relation(relation.relation), target_id=relation.source_id, @@ -547,3 +580,11 @@ def _inverse_planar_relation( if relation == "in_front_of": return "behind" return "in_front_of" + + +# Preserve the pre-stack authoring names for callers already using the Scene +# Engine edit API. The explicit ``Generated*`` names remain canonical for the +# task-first pipeline and distinguish this graph from live simulator state. +SceneGraph = GeneratedSceneGraph +SceneGraphNode = GeneratedSceneNode +SceneGraphRelation = GeneratedSceneRelation diff --git a/embodichain/gen_sim/scene_engine/core/scene_object.py b/embodichain/gen_sim/scene_engine/core/scene_object.py index 2b868e3c3..ad30f9f22 100644 --- a/embodichain/gen_sim/scene_engine/core/scene_object.py +++ b/embodichain/gen_sim/scene_engine/core/scene_object.py @@ -20,6 +20,8 @@ from dataclasses import dataclass from typing import Literal +__all__ = ["ObjectPhysics", "SceneObject"] + @dataclass class ObjectPhysics: @@ -28,6 +30,7 @@ class ObjectPhysics: body_type: Literal["dynamic", "kinematic"] # Runtime behaviour in simulation. attrs: dict[str, float | int] # Rigid-body material and contact attributes. max_convex_hull_num: int # Collision-decomposition hull budget. + provenance: str = "unspecified" # Authoring source of the selected profile. def __post_init__(self) -> None: """Validate physics settings before a later stage consumes them.""" @@ -37,6 +40,12 @@ def __post_init__(self) -> None: raise ValueError("max_convex_hull_num must be positive.") if not self.attrs: raise ValueError("attrs must contain at least one physics attribute.") + if ( + not isinstance(self.provenance, str) + or not self.provenance + or self.provenance != self.provenance.strip() + ): + raise ValueError("provenance must be a non-empty, trimmed string.") if not all( isinstance(name, str) and isinstance(value, (float, int)) for name, value in self.attrs.items() @@ -49,6 +58,7 @@ def to_dict(self) -> dict[str, object]: "body_type": self.body_type, "attrs": self.attrs, "max_convex_hull_num": self.max_convex_hull_num, + "provenance": self.provenance, } @@ -72,6 +82,11 @@ class SceneObject: support_optimization_rect_xy: list[list[float]] | None = None # Safe XY rectangle. physics: ObjectPhysics | None = None # Assigned when SimReady processing succeeds. + def __post_init__(self) -> None: + """Keep provider-free object identity stable across authoring exports.""" + if not isinstance(self.id, str) or not self.id or self.id != self.id.strip(): + raise ValueError("Scene object id must be a non-empty, trimmed string.") + def to_dict(self) -> dict[str, object]: """Serialize this object and its currently available pipeline artifacts.""" return { diff --git a/embodichain/gen_sim/scene_engine/errors.py b/embodichain/gen_sim/scene_engine/errors.py new file mode 100644 index 000000000..dd23d2aa9 --- /dev/null +++ b/embodichain/gen_sim/scene_engine/errors.py @@ -0,0 +1,23 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +from __future__ import annotations + +__all__ = ["SceneServiceError"] + + +class SceneServiceError(RuntimeError): + """A transient or remote Scene Engine service failure.""" diff --git a/embodichain/gen_sim/scene_engine/pipeline/__init__.py b/embodichain/gen_sim/scene_engine/pipeline/__init__.py index 015c41510..ecf448d22 100644 --- a/embodichain/gen_sim/scene_engine/pipeline/__init__.py +++ b/embodichain/gen_sim/scene_engine/pipeline/__init__.py @@ -16,4 +16,26 @@ from __future__ import annotations -__all__: list[str] = [] +from .api import ( + SCENE_BLUEPRINT_SCHEMA, + SCENE_EDIT_BLUEPRINT_SCHEMA, + SceneBlueprintPackage, + SceneEditBlueprintPackage, + SceneMaterialization, + analyze_edit, + analyze_image, + materialize_blueprint, + materialize_edit, +) + +__all__ = [ + "SCENE_BLUEPRINT_SCHEMA", + "SCENE_EDIT_BLUEPRINT_SCHEMA", + "SceneBlueprintPackage", + "SceneEditBlueprintPackage", + "SceneMaterialization", + "analyze_edit", + "analyze_image", + "materialize_blueprint", + "materialize_edit", +] diff --git a/embodichain/gen_sim/scene_engine/pipeline/api.py b/embodichain/gen_sim/scene_engine/pipeline/api.py new file mode 100644 index 000000000..63cc276cd --- /dev/null +++ b/embodichain/gen_sim/scene_engine/pipeline/api.py @@ -0,0 +1,351 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Auditable stage boundaries for Scene Engine generation and editing.""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass +import hashlib +import json +from pathlib import Path +from typing import Any, Final + +from embodichain.gen_sim.scene_engine.clients.geometry_generation import ( + GeometryGenerationClient, +) +from embodichain.gen_sim.scene_engine.clients.image_generation import ( + ImageGenerationClient, +) +from embodichain.gen_sim.scene_engine.clients.image_segmentation import ( + ImageSegmentationClient, +) +from embodichain.gen_sim.scene_engine.core.scene import Scene +from embodichain.gen_sim.scene_engine.core.scene_edit_plan import SceneEditPlan +from embodichain.gen_sim.scene_engine.core.scene_graph import GeneratedSceneGraph +from embodichain.gen_sim.scene_engine.llms.openai_compatible_client import ( + OpenAICompatibleVLM, +) +from embodichain.gen_sim.scene_engine.pipeline.editing.scene_edit_asset_preparation import ( + prepare_scene_edit_assets, +) +from embodichain.gen_sim.scene_engine.pipeline.editing.scene_edit_layout_generation import ( + edit_layout, +) +from embodichain.gen_sim.scene_engine.pipeline.editing.scene_edit_understanding import ( + understand_scene_edit, +) +from embodichain.gen_sim.scene_engine.pipeline.generation.scene_generation import ( + generate_scene_and_refine, +) +from embodichain.gen_sim.scene_engine.pipeline.generation.scene_understanding import ( + understand_scene, +) +from embodichain.gen_sim.scene_engine.pipeline.utils.scene_exporter import SceneExporter +from embodichain.gen_sim.scene_engine.pipeline.utils.scene_importer import ( + SceneExportImporter, +) +from embodichain.utils.logger import log_info + +__all__ = [ + "SCENE_BLUEPRINT_SCHEMA", + "SCENE_EDIT_BLUEPRINT_SCHEMA", + "SceneBlueprintPackage", + "SceneEditBlueprintPackage", + "SceneMaterialization", + "analyze_edit", + "analyze_image", + "materialize_blueprint", + "materialize_edit", +] + +SCENE_BLUEPRINT_SCHEMA: Final = "embodichain.scene-blueprint/v1" +SCENE_EDIT_BLUEPRINT_SCHEMA: Final = "embodichain.scene-edit-blueprint/v1" + + +@dataclass(frozen=True) +class SceneBlueprintPackage: + """In-process scene semantics plus their persisted audit document.""" + + blueprint_id: str + image_path: Path + output_root: Path + manifest_path: Path + scene: Scene + scene_graph: GeneratedSceneGraph + + +@dataclass(frozen=True) +class SceneEditBlueprintPackage: + """Validated edit intent before added assets and layout are materialized.""" + + blueprint_id: str + edit_prompt: str + output_root: Path + manifest_path: Path + scene_edit_plan: SceneEditPlan + updated_scene_graph: GeneratedSceneGraph + + +@dataclass(frozen=True) +class SceneMaterialization: + """One exported materialized scene revision.""" + + scene: Scene + scene_graph: GeneratedSceneGraph + output_root: Path + scene_config_path: Path + + +def analyze_image( + image_path: str | Path, + output_root: str | Path, + *, + vlm_client: OpenAICompatibleVLM | None = None, + image_segmentation_client: ImageSegmentationClient | None = None, +) -> SceneBlueprintPackage: + """Understand an image and persist the pre-generation semantic blueprint.""" + resolved_image = Path(image_path).expanduser().resolve() + resolved_output = Path(output_root).expanduser().resolve() + resolved_output.mkdir(parents=True, exist_ok=True) + effective_vlm = vlm_client or OpenAICompatibleVLM.from_dotenv() + segmentation = image_segmentation_client or ImageSegmentationClient.from_dotenv() + owns_segmentation = image_segmentation_client is None + log_info("Starting Scene Understanding") + try: + segmentation.check_health() + scene, scene_graph = understand_scene( + scene=Scene(), + image_path=resolved_image, + output_root=resolved_output, + vlm_client=effective_vlm, + image_segmentation_client=segmentation, + ) + finally: + if owns_segmentation: + segmentation.close() + log_info("Completed Scene Understanding") + + payload = { + "schema_version": SCENE_BLUEPRINT_SCHEMA, + "image_path": resolved_image.as_posix(), + "scene": scene.to_dict(), + "scene_graph": scene_graph.to_dict(), + "artifacts": _artifact_records(resolved_output / "scene_understanding"), + } + blueprint_id = _canonical_hash(payload) + document = {**payload, "blueprint_id": blueprint_id} + manifest_path = resolved_output / "scene_blueprint.json" + _write_json(manifest_path, document) + return SceneBlueprintPackage( + blueprint_id=blueprint_id, + image_path=resolved_image, + output_root=resolved_output, + manifest_path=manifest_path, + scene=scene, + scene_graph=scene_graph, + ) + + +def materialize_blueprint( + blueprint: SceneBlueprintPackage, + *, + vlm_client: OpenAICompatibleVLM | None = None, + geometry_generation_client: GeometryGenerationClient | None = None, + seed: int | None = None, +) -> SceneMaterialization: + """Generate assets and layout for one image-derived blueprint.""" + scene = deepcopy(blueprint.scene) + scene_graph = deepcopy(blueprint.scene_graph) + effective_vlm = vlm_client or OpenAICompatibleVLM.from_dotenv() + geometry = geometry_generation_client or GeometryGenerationClient.from_dotenv() + owns_geometry = geometry_generation_client is None + log_info("Starting Objects + Coarse Layout Generation") + try: + geometry.check_health() + scene = generate_scene_and_refine( + image_path=blueprint.image_path, + output_root=blueprint.output_root, + scene=scene, + scene_graph=scene_graph, + geometry_generation_client=geometry, + vlm_client=effective_vlm, + seed=seed, + ) + finally: + if owns_geometry: + geometry.close() + log_info("Completed Objects + Coarse Layout Generation") + return _export_materialization( + scene=scene, + scene_graph=scene_graph, + output_root=blueprint.output_root, + ) + + +def analyze_edit( + *, + output_root: str | Path, + edit_prompt: str, + vlm_client: OpenAICompatibleVLM | None = None, +) -> SceneEditBlueprintPackage: + """Interpret and persist one edit against an already generated scene.""" + resolved_output = Path(output_root).expanduser().resolve() + normalized_prompt = str(edit_prompt).strip() + if not normalized_prompt: + raise ValueError("Edit prompt must not be empty.") + scene, scene_graph = SceneExportImporter( + output_root=resolved_output + ).import_scene_and_graph() + effective_vlm = vlm_client or OpenAICompatibleVLM.from_dotenv() + log_info("Starting Edit Understanding") + scene_edit_plan, updated_scene_graph = understand_scene_edit( + scene=scene, + scene_graph=scene_graph, + edit_prompt=normalized_prompt, + vlm_client=effective_vlm, + ) + log_info("Completed Edit Understanding") + payload = { + "schema_version": SCENE_EDIT_BLUEPRINT_SCHEMA, + "edit_prompt": normalized_prompt, + "scene_edit_plan": scene_edit_plan.to_dict(), + "updated_scene_graph": updated_scene_graph.to_dict(), + } + blueprint_id = _canonical_hash(payload) + manifest_path = resolved_output / "scene_edit" / "scene_edit_blueprint.json" + _write_json(manifest_path, {**payload, "blueprint_id": blueprint_id}) + return SceneEditBlueprintPackage( + blueprint_id=blueprint_id, + edit_prompt=normalized_prompt, + output_root=resolved_output, + manifest_path=manifest_path, + scene_edit_plan=scene_edit_plan, + updated_scene_graph=updated_scene_graph, + ) + + +def materialize_edit( + blueprint: SceneEditBlueprintPackage, + *, + vlm_client: OpenAICompatibleVLM | None = None, + image_generation_client: ImageGenerationClient | None = None, + geometry_generation_client: GeometryGenerationClient | None = None, + image_segmentation_client: ImageSegmentationClient | None = None, + seed: int | None = None, +) -> SceneMaterialization: + """Generate added assets, apply layout edits, and export the new revision.""" + scene_edit_plan = deepcopy(blueprint.scene_edit_plan) + updated_scene_graph = deepcopy(blueprint.updated_scene_graph) + effective_vlm = vlm_client or OpenAICompatibleVLM.from_dotenv() + image_generation = image_generation_client or ImageGenerationClient.from_dotenv() + geometry = geometry_generation_client or GeometryGenerationClient.from_dotenv() + segmentation = image_segmentation_client or ImageSegmentationClient.from_dotenv() + owned_clients = ( + (image_generation, image_generation_client is None), + (geometry, geometry_generation_client is None), + (segmentation, image_segmentation_client is None), + ) + log_info("Starting Objects Preparation") + try: + for client, _ in owned_clients: + client.check_health() + added_assets = prepare_scene_edit_assets( + scene_edit_plan=scene_edit_plan, + output_root=blueprint.output_root, + image_generation_client=image_generation, + geometry_generation_client=geometry, + image_segmentation_client=segmentation, + vlm_client=effective_vlm, + seed=seed, + ) + finally: + for client, owned in owned_clients: + if owned: + client.close() + log_info("Completed Objects Preparation") + log_info("Starting Layout Generation") + scene = edit_layout( + scene=scene_edit_plan.scene, + scene_edit_plan=scene_edit_plan, + updated_scene_graph=updated_scene_graph, + added_assets=added_assets, + output_root=blueprint.output_root, + ) + log_info("Completed Layout Generation") + return _export_materialization( + scene=scene, + scene_graph=updated_scene_graph, + output_root=blueprint.output_root, + ) + + +def _export_materialization( + *, + scene: Scene, + scene_graph: GeneratedSceneGraph, + output_root: Path, +) -> SceneMaterialization: + log_info("Starting Scene Export") + scene_config_path = SceneExporter( + scene=scene, + scene_graph=scene_graph, + output_root=output_root, + ).export() + log_info("Completed Scene Export") + return SceneMaterialization( + scene=scene, + scene_graph=scene_graph, + output_root=output_root, + scene_config_path=scene_config_path, + ) + + +def _artifact_records(root: Path) -> list[dict[str, Any]]: + if not root.is_dir(): + return [] + records = [] + for path in sorted(item for item in root.rglob("*") if item.is_file()): + records.append( + { + "path": path.resolve().as_posix(), + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "size": path.stat().st_size, + } + ) + return records + + +def _canonical_hash(value: Any) -> str: + payload = json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def _write_json(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text( + json.dumps(value, indent=2, ensure_ascii=False, allow_nan=False) + "\n", + encoding="utf-8", + ) + temporary.replace(path) diff --git a/embodichain/gen_sim/scene_engine/pipeline/edit.py b/embodichain/gen_sim/scene_engine/pipeline/edit.py index 5b71af556..ec993c8dc 100644 --- a/embodichain/gen_sim/scene_engine/pipeline/edit.py +++ b/embodichain/gen_sim/scene_engine/pipeline/edit.py @@ -18,34 +18,13 @@ from pathlib import Path -from embodichain.gen_sim.scene_engine.clients.geometry_generation import ( - GeometryGenerationClient, -) -from embodichain.gen_sim.scene_engine.clients.image_generation import ( - ImageGenerationClient, -) -from embodichain.gen_sim.scene_engine.clients.image_segmentation import ( - ImageSegmentationClient, -) from embodichain.gen_sim.scene_engine.llms.openai_compatible_client import ( OpenAICompatibleVLM, ) -from embodichain.gen_sim.scene_engine.pipeline.utils.scene_importer import ( - SceneExportImporter, -) -from embodichain.gen_sim.scene_engine.pipeline.utils.scene_exporter import ( - SceneExporter, -) -from embodichain.gen_sim.scene_engine.pipeline.editing.scene_edit_understanding import ( - understand_scene_edit, +from embodichain.gen_sim.scene_engine.pipeline.api import ( + analyze_edit, + materialize_edit, ) -from embodichain.gen_sim.scene_engine.pipeline.editing.scene_edit_asset_preparation import ( - prepare_scene_edit_assets, -) -from embodichain.gen_sim.scene_engine.pipeline.editing.scene_edit_layout_generation import ( - edit_layout, -) -from embodichain.utils.logger import log_info def edit_scene( @@ -55,71 +34,11 @@ def edit_scene( ) -> None: """Apply one text edit instruction to an existing Scene Engine output.""" resolved_output_root = Path(output_root).expanduser().resolve() - resolved_output_root.mkdir(parents=True, exist_ok=True) - - # Initialize the VLM client that will interpret the edit instruction. vlm_client = OpenAICompatibleVLM.from_dotenv() - scene_importer = SceneExportImporter(output_root=output_root) - # Validate scene_export, write scene.json, and return Scene; failures raise before editing. - scene, scene_graph = scene_importer.import_scene_and_graph() - - # 1. Edit Understanding - # Will return an already checked scene edit plan - # and a validated updated scene graph. - log_info("Starting Edit Understanding") - scene_edit_plan, updated_scene_graph = understand_scene_edit( - scene=scene, - scene_graph=scene_graph, + blueprint = analyze_edit( + output_root=resolved_output_root, edit_prompt=edit_prompt, vlm_client=vlm_client, ) - log_info("Completed Edit Understanding") - - # 2. Prepare Objects - log_info("Starting Objects Preparation") - # Initialize all the clients and then check. - image_generation_client = ImageGenerationClient.from_dotenv() - geometry_generation_client = GeometryGenerationClient.from_dotenv() - image_segmentation_client = ImageSegmentationClient.from_dotenv() - try: - image_generation_client.check_health() - geometry_generation_client.check_health() - image_segmentation_client.check_health() - # Return a list of added SceneObjects assets. - # Now do not support editing the table. - added_assets = prepare_scene_edit_assets( - scene_edit_plan=scene_edit_plan, - output_root=resolved_output_root, - image_generation_client=image_generation_client, - geometry_generation_client=geometry_generation_client, - image_segmentation_client=image_segmentation_client, - vlm_client=vlm_client, - ) - finally: - image_generation_client.close() - geometry_generation_client.close() - image_segmentation_client.close() - log_info("Completed Objects Preparation") - - # 3. Layout Generation - log_info("Starting Layout Generation") - post_edit_scene = edit_layout( - scene=scene, - scene_edit_plan=scene_edit_plan, - updated_scene_graph=updated_scene_graph, - added_assets=added_assets, - output_root=resolved_output_root, - ) - log_info("Completed Layout Generation") - - # 4. Scene Export - log_info("Starting Scene Export") - scene_exporter = SceneExporter( - scene=post_edit_scene, - scene_graph=updated_scene_graph, - output_root=resolved_output_root, - ) - scene_exporter.export() - log_info("Completed Scene Export") - + materialize_edit(blueprint, vlm_client=vlm_client) return None diff --git a/embodichain/gen_sim/scene_engine/pipeline/editing/scene_edit_asset_preparation.py b/embodichain/gen_sim/scene_engine/pipeline/editing/scene_edit_asset_preparation.py index aa19e97e1..c1e3516d2 100644 --- a/embodichain/gen_sim/scene_engine/pipeline/editing/scene_edit_asset_preparation.py +++ b/embodichain/gen_sim/scene_engine/pipeline/editing/scene_edit_asset_preparation.py @@ -50,6 +50,8 @@ SimReadyProcessorConfig, ) +__all__ = ["prepare_scene_edit_assets"] + @dataclass(frozen=True) class _AddedAssetInfo: @@ -69,6 +71,7 @@ def prepare_scene_edit_assets( geometry_generation_client: GeometryGenerationClient, image_segmentation_client: ImageSegmentationClient, vlm_client: OpenAICompatibleVLM | None = None, + seed: int | None = None, ) -> list[SceneObject]: """Prepare and return SimReady assets required by add operations.""" # Prepare descriptions for all newly added objects. @@ -89,6 +92,7 @@ def prepare_scene_edit_assets( added_asset_descriptions=added_asset_descriptions, stage_output_root=stage_output_root, image_generation_client=image_generation_client, + seed=seed, ) generated_asset_masks = _segment_generated_added_asset_images( added_asset_descriptions=added_asset_descriptions, @@ -102,6 +106,7 @@ def prepare_scene_edit_assets( generated_asset_masks=generated_asset_masks, stage_output_root=stage_output_root, geometry_generation_client=geometry_generation_client, + seed=seed, ) # Build a list of added SceneObjects. added_assets = _build_added_scene_objects( @@ -220,6 +225,7 @@ def _generate_added_asset_images( added_asset_descriptions: list[_AddedAssetInfo], stage_output_root: Path, image_generation_client: ImageGenerationClient, + seed: int | None, ) -> list[tuple[str, Path]]: """Generate one stable PNG for each new object description.""" # Prepare a list. @@ -228,12 +234,17 @@ def _generate_added_asset_images( image_output_root = stage_output_root / "generated_images" image_output_root.mkdir(parents=True, exist_ok=True) - for asset_info in added_asset_descriptions: + for index, asset_info in enumerate(added_asset_descriptions): object_id = asset_info.object_id # Stable object IDs preserve the image-to-asset mapping across later stages. + generation_kwargs = { + "prompt": asset_info.description, + "output_path": image_output_root / f"{object_id}.png", + } + if seed is not None: + generation_kwargs["seed"] = int(seed) + index image_path = image_generation_client.generate_image_by_prompt( - prompt=asset_info.description, - output_path=image_output_root / f"{object_id}.png", + **generation_kwargs ) generated_asset_images.append((object_id, image_path)) return generated_asset_images @@ -297,6 +308,7 @@ def _generate_added_assets_coarse_geometry( generated_asset_masks: list[tuple[str, Path]], stage_output_root: Path, geometry_generation_client: GeometryGenerationClient, + seed: int | None, ) -> list[tuple[str, Path]]: """Generate one coarse GLB for each generated image and binary mask.""" masks_by_id = dict(generated_asset_masks) @@ -310,13 +322,16 @@ def _generate_added_assets_coarse_geometry( geometry_output_root = stage_output_root / "coarse_geometry" geometry_output_root.mkdir(parents=True, exist_ok=True) generated_asset_glbs: list[tuple[str, Path]] = [] - for object_id, image_path in generated_asset_images: + for index, (object_id, image_path) in enumerate(generated_asset_images): # Each generated object has its own color image, so it needs an individual request. - geometry_generation_client.generate_objects( - image_path=image_path, - object_masks=[(object_id, masks_by_id[object_id])], - output_root=geometry_output_root, - ) + generation_kwargs = { + "image_path": image_path, + "object_masks": [(object_id, masks_by_id[object_id])], + "output_root": geometry_output_root, + } + if seed is not None: + generation_kwargs["seed"] = int(seed) + index + geometry_generation_client.generate_objects(**generation_kwargs) glb_path = geometry_output_root / f"{object_id}.glb" if not glb_path.is_file(): raise FileNotFoundError( diff --git a/embodichain/gen_sim/scene_engine/pipeline/editing/scene_edit_layout_generation.py b/embodichain/gen_sim/scene_engine/pipeline/editing/scene_edit_layout_generation.py index 9c6b06410..61f97dbee 100644 --- a/embodichain/gen_sim/scene_engine/pipeline/editing/scene_edit_layout_generation.py +++ b/embodichain/gen_sim/scene_engine/pipeline/editing/scene_edit_layout_generation.py @@ -22,7 +22,7 @@ from embodichain.gen_sim.scene_engine.core.scene import Scene from embodichain.gen_sim.scene_engine.core.scene_edit_plan import SceneEditPlan -from embodichain.gen_sim.scene_engine.core.scene_graph import SceneGraph +from embodichain.gen_sim.scene_engine.core.scene_graph import GeneratedSceneGraph from embodichain.gen_sim.scene_engine.core.scene_object import SceneObject from embodichain.gen_sim.scene_engine.pipeline.utils.scene_layout_constructor import ( SceneLayoutConstructor, @@ -33,7 +33,7 @@ def edit_layout( *, scene: Scene, scene_edit_plan: SceneEditPlan, - updated_scene_graph: SceneGraph, + updated_scene_graph: GeneratedSceneGraph, added_assets: list[SceneObject], output_root: str | Path, ) -> Scene: diff --git a/embodichain/gen_sim/scene_engine/pipeline/editing/scene_edit_understanding.py b/embodichain/gen_sim/scene_engine/pipeline/editing/scene_edit_understanding.py index 3205a7a48..8ca64dbbc 100644 --- a/embodichain/gen_sim/scene_engine/pipeline/editing/scene_edit_understanding.py +++ b/embodichain/gen_sim/scene_engine/pipeline/editing/scene_edit_understanding.py @@ -28,9 +28,9 @@ from embodichain.gen_sim.scene_engine.core.scene_graph import ( OrientationState, PlanarRelationType, - SceneGraph, - SceneGraphNode, - SceneGraphRelation, + GeneratedSceneGraph, + GeneratedSceneNode, + GeneratedSceneRelation, TABLE_REGIONS, TableRegion, ) @@ -173,11 +173,11 @@ def understand_scene_edit( *, scene: Scene, - scene_graph: SceneGraph, + scene_graph: GeneratedSceneGraph, edit_prompt: str, vlm_client: OpenAICompatibleVLM, json_max_attempts: int = 3, -) -> tuple[SceneEditPlan, SceneGraph]: +) -> tuple[SceneEditPlan, GeneratedSceneGraph]: """Understand one text edit instruction for an existing scene.""" edit_prompt = edit_prompt.strip() if not edit_prompt: @@ -212,14 +212,14 @@ def understand_scene_edit( def _build_updated_scene_graph( *, - scene_graph: SceneGraph, + scene_graph: GeneratedSceneGraph, scene_edit_plan: SceneEditPlan, -) -> SceneGraph: +) -> GeneratedSceneGraph: """Build and validate the target graph implied by one edit plan.""" # Copy every mutable graph value so the pre-edit graph remains unchanged. - updated_scene_graph = SceneGraph( + updated_scene_graph = GeneratedSceneGraph( nodes=[ - SceneGraphNode( + GeneratedSceneNode( object_id=node.object_id, parent_id=node.parent_id, parent_relation=node.parent_relation, @@ -229,7 +229,7 @@ def _build_updated_scene_graph( for node in scene_graph.nodes ], relations=[ - SceneGraphRelation( + GeneratedSceneRelation( source_id=relation.source_id, relation=relation.relation, target_id=relation.target_id, @@ -247,7 +247,7 @@ def _build_updated_scene_graph( def _apply_scene_edit_plan_to_scene_graph( *, - scene_graph: SceneGraph, + scene_graph: GeneratedSceneGraph, scene_edit_plan: SceneEditPlan, ) -> None: """Apply the target graph updates implied by add and move operations.""" @@ -296,7 +296,7 @@ def _apply_scene_edit_plan_to_scene_graph( def _simplify_scene_info( *, scene: Scene, - scene_graph: SceneGraph, + scene_graph: GeneratedSceneGraph, ) -> dict[str, object]: """Return the object metadata needed for edit instruction resolution.""" table_regions_by_id = { diff --git a/embodichain/gen_sim/scene_engine/pipeline/generate.py b/embodichain/gen_sim/scene_engine/pipeline/generate.py index 144551c29..603591684 100644 --- a/embodichain/gen_sim/scene_engine/pipeline/generate.py +++ b/embodichain/gen_sim/scene_engine/pipeline/generate.py @@ -22,22 +22,10 @@ from embodichain.gen_sim.scene_engine.llms.openai_compatible_client import ( OpenAICompatibleVLM, ) -from embodichain.gen_sim.scene_engine.clients.geometry_generation import ( - GeometryGenerationClient, +from embodichain.gen_sim.scene_engine.pipeline.api import ( + analyze_image, + materialize_blueprint, ) -from embodichain.gen_sim.scene_engine.clients.image_segmentation import ( - ImageSegmentationClient, -) - -from embodichain.gen_sim.scene_engine.pipeline.generation.scene_understanding import ( - understand_scene, -) -from embodichain.utils.logger import log_info - -from embodichain.gen_sim.scene_engine.pipeline.generation.scene_generation import ( - generate_scene_and_refine, -) -from embodichain.gen_sim.scene_engine.pipeline.utils.scene_exporter import SceneExporter def generate_scene_from_image( @@ -46,55 +34,10 @@ def generate_scene_from_image( ) -> Scene: """Generate the initial core scene state from an input image.""" resolved_output_root = Path(output_root).expanduser().resolve() - resolved_output_root.mkdir(parents=True, exist_ok=True) - - # Initialize the VLM client and the Scene data structure. vlm_client = OpenAICompatibleVLM.from_dotenv() - scene = Scene() - - # 1. Scene Understanding - log_info("Starting Scene Understanding") - # Load .env settings and fail if the Image Segmentation Server is unavailable. - image_segmentation_client = ImageSegmentationClient.from_dotenv() - try: - image_segmentation_client.check_health() - scene, scene_graph = understand_scene( - scene=scene, - image_path=image_path, - output_root=resolved_output_root, - vlm_client=vlm_client, - image_segmentation_client=image_segmentation_client, - ) - finally: - image_segmentation_client.close() # Close the session after scene understanding. - log_info("Completed Scene Understanding") - - # 2. Objects + Coarse Layout Generation - log_info("Starting Objects + Coarse Layout Generation") - # Load .env settings and fail if the Geometry Generation Server is unavailable. - geometry_generation_client = GeometryGenerationClient.from_dotenv() - try: - geometry_generation_client.check_health() # Error raising will happen internally. - scene = generate_scene_and_refine( - image_path=image_path, - output_root=resolved_output_root, - scene=scene, - scene_graph=scene_graph, - geometry_generation_client=geometry_generation_client, - vlm_client=vlm_client, - ) - finally: - geometry_generation_client.close() # Kill the session to avoid resource leaks. - log_info("Completed Objects + Coarse Layout Generation") - - # 3. Scene Export - log_info("Starting Scene Export") - scene_exporter = SceneExporter( - scene=scene, - scene_graph=scene_graph, - output_root=resolved_output_root, + blueprint = analyze_image( + image_path, + resolved_output_root, + vlm_client=vlm_client, ) - scene_exporter.export() - log_info("Completed Scene Export") - - return scene + return materialize_blueprint(blueprint, vlm_client=vlm_client).scene diff --git a/embodichain/gen_sim/scene_engine/pipeline/generation/scene_generation.py b/embodichain/gen_sim/scene_engine/pipeline/generation/scene_generation.py index 7010e5f14..3ed713d0c 100644 --- a/embodichain/gen_sim/scene_engine/pipeline/generation/scene_generation.py +++ b/embodichain/gen_sim/scene_engine/pipeline/generation/scene_generation.py @@ -30,7 +30,7 @@ GeometryGenerationClient, ) from embodichain.gen_sim.scene_engine.core.scene import Scene -from embodichain.gen_sim.scene_engine.core.scene_graph import SceneGraph +from embodichain.gen_sim.scene_engine.core.scene_graph import GeneratedSceneGraph from embodichain.gen_sim.scene_engine.core.scene_object import SceneObject from embodichain.gen_sim.scene_engine.llms.openai_compatible_client import ( OpenAICompatibleVLM, @@ -67,10 +67,11 @@ def generate_scene_and_refine( image_path: str | Path, output_root: str | Path, scene: Scene, - scene_graph: SceneGraph, + scene_graph: GeneratedSceneGraph, *, geometry_generation_client: GeometryGenerationClient, vlm_client: OpenAICompatibleVLM, + seed: int | None = None, ) -> Scene: resolved_image_path = _validate_image_path(image_path) @@ -102,6 +103,7 @@ def generate_scene_and_refine( coarse_geometry_output_root=coarse_geometry_output_root, scene=scene, # Use the masks which are kept in the scene data structure. geometry_generation_client=geometry_generation_client, + seed=seed, ) # Simready all the assets(includes table). @@ -163,6 +165,7 @@ def _generate_coarse_results_from_masks( scene: Scene, *, geometry_generation_client: GeometryGenerationClient, + seed: int | None = None, ) -> None: # Parse whether the scene has each assets' binary masks. @@ -189,10 +192,15 @@ def _generate_coarse_results_from_masks( ) # id + mask, for avoiding the download glbs order confusion. # Sent the request, wait, then save the intermediate results. + generation_kwargs = { + "image_path": image_path, + "object_masks": object_masks, + "output_root": coarse_geometry_output_root, + } + if seed is not None: + generation_kwargs["seed"] = seed response_data, response_objects = geometry_generation_client.generate_objects( - image_path=image_path, - object_masks=object_masks, - output_root=coarse_geometry_output_root, # Keep the coarse geometries + **generation_kwargs ) # Write the response JSON which contains all the layout info the server gave us. # Keep original response for getting the sam3d coarse layout matrix. @@ -302,7 +310,7 @@ def _copy_y_up_layout_to_scene_object( def _layout_refinement( *, scene: Scene, - scene_graph: SceneGraph, + scene_graph: GeneratedSceneGraph, simready_geometry_output_root: str | Path, debug_output_root: str | Path, ) -> tuple[dict[str, object], list[dict[str, object]]]: @@ -494,7 +502,7 @@ def _layout_refinement( def _scene_graph_based_calibration( *, - scene_graph: SceneGraph, + scene_graph: GeneratedSceneGraph, assets_layout: list[dict[str, object]], ) -> list[dict[str, object]]: """Minimally align graph-marked standing assets with the z-up table frame.""" diff --git a/embodichain/gen_sim/scene_engine/pipeline/generation/scene_understanding.py b/embodichain/gen_sim/scene_engine/pipeline/generation/scene_understanding.py index d93ada019..d3a297d98 100644 --- a/embodichain/gen_sim/scene_engine/pipeline/generation/scene_understanding.py +++ b/embodichain/gen_sim/scene_engine/pipeline/generation/scene_understanding.py @@ -30,8 +30,8 @@ ) from embodichain.gen_sim.scene_engine.core.scene import Scene from embodichain.gen_sim.scene_engine.core.scene_graph import ( - SceneGraph, - SceneGraphNode, + GeneratedSceneGraph, + GeneratedSceneNode, TABLE_OBJECT_ID, ) from embodichain.gen_sim.scene_engine.core.scene_object import SceneObject @@ -172,7 +172,7 @@ def understand_scene( vlm_client: OpenAICompatibleVLM, image_segmentation_client: ImageSegmentationClient, json_max_attempts: int = 3, -) -> tuple[Scene, SceneGraph]: +) -> tuple[Scene, GeneratedSceneGraph]: resolved_image_path = _validate_image_path(image_path) # The output in this stage will keep a JSON which contains @@ -226,7 +226,7 @@ def _initialize_scene_graph_from_segmented_scene( asset_mask_id_overlay_path: str | Path, vlm_client: OpenAICompatibleVLM, json_max_attempts: int = 3, -) -> SceneGraph: +) -> GeneratedSceneGraph: """Build the initial graph assuming every segmented asset rests on the table.""" # Get simplified scene info for VLM. scene_info = _simplify_scene_info_for_graph_initialization(scene=scene) @@ -241,11 +241,11 @@ def _initialize_scene_graph_from_segmented_scene( vlm_client=vlm_client, json_max_attempts=json_max_attempts, ) - return SceneGraph( + return GeneratedSceneGraph( nodes=[ - SceneGraphNode(object_id=TABLE_OBJECT_ID, parent_id=None), + GeneratedSceneNode(object_id=TABLE_OBJECT_ID, parent_id=None), *[ - SceneGraphNode( + GeneratedSceneNode( object_id=asset.id, parent_id=TABLE_OBJECT_ID, parent_relation="on", # semi-hard-code. diff --git a/embodichain/gen_sim/scene_engine/pipeline/utils/parent_surface_layout_optimizer.py b/embodichain/gen_sim/scene_engine/pipeline/utils/parent_surface_layout_optimizer.py index de8514dee..12e3e773b 100644 --- a/embodichain/gen_sim/scene_engine/pipeline/utils/parent_surface_layout_optimizer.py +++ b/embodichain/gen_sim/scene_engine/pipeline/utils/parent_surface_layout_optimizer.py @@ -23,7 +23,7 @@ import numpy as np from scipy.optimize import minimize -from embodichain.gen_sim.scene_engine.core.scene_graph import SceneGraphRelation +from embodichain.gen_sim.scene_engine.core.scene_graph import GeneratedSceneRelation from embodichain.gen_sim.scene_engine.core.scene_object import SceneObject from embodichain.gen_sim.scene_engine.pipeline.utils.gravity_settler import ( GravitySettleBody, @@ -53,7 +53,7 @@ class ParentSurfaceLayoutProblem: fixed_child_xy_by_id: dict[str, list[float] | None] parent_aabb_xy: list[list[float]] parent_top_z: float - child_relations: list[SceneGraphRelation] + child_relations: list[GeneratedSceneRelation] @classmethod def from_layout_problem( diff --git a/embodichain/gen_sim/scene_engine/pipeline/utils/scene_exporter.py b/embodichain/gen_sim/scene_engine/pipeline/utils/scene_exporter.py index 7451296b4..fa2e4f6b5 100644 --- a/embodichain/gen_sim/scene_engine/pipeline/utils/scene_exporter.py +++ b/embodichain/gen_sim/scene_engine/pipeline/utils/scene_exporter.py @@ -17,16 +17,16 @@ from __future__ import annotations +import hashlib import json from pathlib import Path import shutil -import time import numpy as np from scipy.spatial.transform import Rotation from embodichain.gen_sim.scene_engine.core.scene import Scene -from embodichain.gen_sim.scene_engine.core.scene_graph import SceneGraph +from embodichain.gen_sim.scene_engine.core.scene_graph import GeneratedSceneGraph from embodichain.gen_sim.scene_engine.core.scene_object import SceneObject from embodichain.utils.logger import log_info @@ -41,22 +41,29 @@ class SceneExporter: - """Write one generated scene and its SimReady meshes as a scene export.""" + """Write one generated scene and its SimReady meshes as a scene export. + + By default, the complete z-up scene is rotated 180 degrees around the + table center before serialization. The input ``Scene`` is not mutated. + """ def __init__( self, *, scene: Scene, - scene_graph: SceneGraph, + scene_graph: GeneratedSceneGraph, output_root: str | Path, + rotate_z_up_180: bool = True, ) -> None: self.scene = scene self.scene_graph = scene_graph self.output_root = Path(output_root).expanduser().resolve() + self.rotate_z_up_180 = rotate_z_up_180 # Keep the legacy frame on request. self.export_root = self.output_root / "scene_export" self.scene_config_path: Path | None = None self.scene_graph_path: Path | None = None self.scene_json_path: Path | None = None + self.authoring_evidence_path: Path | None = None def export(self) -> Path: """Write a scene-only config and copy SimReady GLBs into ``mesh_assets``. @@ -64,7 +71,9 @@ def export(self) -> Path: Scene layouts are y-up. The simulator automatically converts each y-up GLB to z-up, so this exporter copies each GLB unchanged and converts only its world position and rotation for ``init_pos`` and ``init_rot``. - ``body_scale`` remains the original y-up scale associated with the GLB. + The default applies one additional 180-degree global z-up rotation about + the table center to every object and XY layout metadata. ``body_scale`` + remains the original y-up scale associated with the GLB. This is not a complete ``EmbodiedEnv``/``run-env`` configuration because a generated scene does not determine a robot, its placement, or control. """ @@ -81,6 +90,8 @@ def export(self) -> Path: if set(self.scene_graph.node_by_id()) != set(object_ids): raise ValueError("Scene graph nodes must match exported scene object ids.") + z_up_rotation, z_up_pivot_xy = self._z_up_export_transform() + exported_entries = { scene_object.id: self._copy_scene_object_to_assets( scene_object=scene_object, @@ -88,25 +99,36 @@ def export(self) -> Path: ) for scene_object in scene_objects } + background_entries = [ + self._scene_object_config( + scene_object=self.scene.table, + asset_relative_path=exported_entries[self.scene.table.id], + z_up_rotation=z_up_rotation, + z_up_pivot_xy=z_up_pivot_xy, + ) + ] + rigid_object_entries = [ + self._scene_object_config( + scene_object=asset, + asset_relative_path=exported_entries[asset.id], + z_up_rotation=z_up_rotation, + z_up_pivot_xy=z_up_pivot_xy, + ) + for asset in self.scene.assets + ] + authoring_evidence = self._authoring_evidence( + exported_entries=exported_entries, + z_up_rotation=z_up_rotation, + z_up_pivot_xy=z_up_pivot_xy, + ) scene_config = { "format": "embodichain.scene-export/v1", # This identifies the exported scene data only. It is deliberately not # a Gymnasium environment ID because scene exports do not register or # instantiate an EmbodiedEnv. - "scene_id": f"scene-engine-{int(time.time() * 1000)}", - "background": [ - self._scene_object_config( - scene_object=self.scene.table, - asset_relative_path=exported_entries[self.scene.table.id], - ) - ], - "rigid_object": [ - self._scene_object_config( - scene_object=asset, - asset_relative_path=exported_entries[asset.id], - ) - for asset in self.scene.assets - ], + "scene_id": self._stable_scene_id(authoring_evidence), + "background": background_entries, + "rigid_object": rigid_object_entries, } self.scene_config_path = self.export_root / "scene_config.json" self.scene_config_path.write_text( @@ -120,9 +142,31 @@ def export(self) -> Path: encoding="utf-8", ) log_info(f"Exported scene graph: {self.scene_graph_path}") + self.authoring_evidence_path = ( + self.export_root / "scene_authoring_evidence.json" + ) + self.authoring_evidence_path.write_text( + json.dumps(authoring_evidence, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + log_info(f"Exported scene authoring evidence: {self.authoring_evidence_path}") self.scene_json_path = self.export_root / "scene.json" self.scene_json_path.write_text( - json.dumps(self.scene.to_dict(), indent=2, ensure_ascii=False) + "\n", + json.dumps( + { + "objects": [ + self._scene_object_y_up_dict( + scene_object=scene_object, + z_up_rotation=z_up_rotation, + z_up_pivot_xy=z_up_pivot_xy, + ) + for scene_object in scene_objects + ] + }, + indent=2, + ensure_ascii=False, + ) + + "\n", encoding="utf-8", ) log_info(f"Exported scene JSON: {self.scene_json_path}") @@ -182,25 +226,42 @@ def _remove_stale_mesh_assets( else: asset_root.unlink() + def _z_up_export_transform(self) -> tuple[np.ndarray, np.ndarray]: + """Return the optional global z-up rotation and its table-center pivot.""" + table = self.scene.table + if table is None: + raise ValueError("Cannot transform a scene export without a table.") + table_pos_z_up, _ = self._final_z_up_pose( + scene_object=table, + z_up_rotation=np.eye(3), + z_up_pivot_xy=np.zeros(2), + ) + z_up_rotation = ( + Rotation.from_euler("z", 180.0, degrees=True).as_matrix() + if self.rotate_z_up_180 + else np.eye(3) + ) + return z_up_rotation, table_pos_z_up[:2] + @staticmethod def _scene_object_config( *, scene_object: SceneObject, asset_relative_path: str, + z_up_rotation: np.ndarray, + z_up_pivot_xy: np.ndarray, ) -> dict[str, object]: """Build one z-up scene-only object config from a final y-up object.""" - pos_y_up = SceneExporter._scene_vector(scene_object, "pos") - rot_y_up = SceneExporter._scene_vector(scene_object, "rot") scale_y_up = SceneExporter._scene_vector(scene_object, "scale") if scene_object.physics is None: raise ValueError( f"Scene object {scene_object.id!r} has no SimReady physics settings." ) - pos_z_up = _Y_UP_TO_Z_UP_ROTATION @ np.asarray(pos_y_up, dtype=float) - rotation_y_up = Rotation.from_euler("xyz", rot_y_up, degrees=True).as_matrix() - rotation_z_up = ( - _Y_UP_TO_Z_UP_ROTATION @ rotation_y_up @ _Y_UP_TO_Z_UP_ROTATION.T + pos_z_up, rotation_z_up = SceneExporter._final_z_up_pose( + scene_object=scene_object, + z_up_rotation=z_up_rotation, + z_up_pivot_xy=z_up_pivot_xy, ) rot_z_up = Rotation.from_matrix(rotation_z_up).as_euler( # RigidObjectCfg.init_rot is interpreted with uppercase XYZ. @@ -220,18 +281,238 @@ def _scene_object_config( }, "attrs": scene_object.physics.attrs, "body_type": scene_object.physics.body_type, + "physics_provenance": scene_object.physics.provenance, "init_pos": pos_z_up.tolist(), "init_rot": rot_z_up.tolist(), # Do not permute this scale: it belongs to the original y-up GLB, # which SimulationManager itself converts to z-up. "body_scale": scale_y_up, - "center_xy": scene_object.center_xy, + "center_xy": SceneExporter._transformed_optional_xy( + scene_object=scene_object, + field_name="center_xy", + z_up_rotation=z_up_rotation, + z_up_pivot_xy=z_up_pivot_xy, + ), "support_surface_z": scene_object.support_surface_z, - "support_contour_xy": scene_object.support_contour_xy, - "support_optimization_rect_xy": scene_object.support_optimization_rect_xy, + "support_contour_xy": SceneExporter._transformed_optional_xy_points( + scene_object=scene_object, + field_name="support_contour_xy", + z_up_rotation=z_up_rotation, + z_up_pivot_xy=z_up_pivot_xy, + ), + "support_optimization_rect_xy": ( + SceneExporter._transformed_optional_xy_points( + scene_object=scene_object, + field_name="support_optimization_rect_xy", + z_up_rotation=z_up_rotation, + z_up_pivot_xy=z_up_pivot_xy, + ) + ), "max_convex_hull_num": scene_object.physics.max_convex_hull_num, } + def _authoring_evidence( + self, + *, + exported_entries: dict[str, str], + z_up_rotation: np.ndarray, + z_up_pivot_xy: np.ndarray, + ) -> dict[str, object]: + """Build deterministic audit evidence without creating runtime identity.""" + nodes_by_id = self.scene_graph.node_by_id() + objects: list[dict[str, object]] = [] + for scene_object in sorted(self.scene.objects, key=lambda item: item.id): + node = nodes_by_id[scene_object.id] + physics = scene_object.physics + if physics is None: + raise ValueError( + f"Scene object {scene_object.id!r} has no SimReady physics settings." + ) + exported_object = self._scene_object_y_up_dict( + scene_object=scene_object, + z_up_rotation=z_up_rotation, + z_up_pivot_xy=z_up_pivot_xy, + ) + relative_glb_path = exported_entries[scene_object.id] + glb_path = self.export_root / relative_glb_path + planar_relations = sorted( + ( + relation.to_dict() + for relation in self.scene_graph.relations + if scene_object.id in {relation.source_id, relation.target_id} + ), + key=lambda value: ( + str(value["source_id"]), + str(value["relation"]), + str(value["target_id"]), + ), + ) + objects.append( + { + "canonical_id": scene_object.id, + "kind": scene_object.kind, + "ancestry": { + "parent_id": node.parent_id, + "parent_relation": node.parent_relation, + }, + "affordance_evidence": { + "source": "generated_scene_graph", + "orientation_state": node.orientation_state, + "table_region": node.table_region, + "planar_relations": planar_relations, + }, + "geometry_metadata": { + "format": "glb", + "frame": "y_up", + "asset_path": relative_glb_path, + "sha256": self._file_sha256(glb_path), + "position": exported_object["pos"], + "rotation_xyz_degrees": exported_object["rot"], + "scale": exported_object["scale"], + "support_surface_z": exported_object["support_surface_z"], + "support_contour_xy": exported_object["support_contour_xy"], + "support_optimization_rect_xy": exported_object[ + "support_optimization_rect_xy" + ], + }, + "physics_provenance": physics.to_dict(), + } + ) + return { + "schema_version": "scene_authoring_evidence/v1", + "artifact_kind": "audit_only", + "objects": objects, + } + + def _stable_scene_id(self, authoring_evidence: dict[str, object]) -> str: + """Derive a stable export id from provider-free authoring artifacts.""" + payload = { + "generated_scene_graph": self.scene_graph.to_dict(), + "authoring_evidence": authoring_evidence, + } + encoded = json.dumps( + payload, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return f"scene-engine-{hashlib.sha256(encoded).hexdigest()[:16]}" + + @staticmethod + def _file_sha256(path: Path) -> str: + """Return the content digest for one exported geometry artifact.""" + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + @staticmethod + def _final_z_up_pose( + *, + scene_object: SceneObject, + z_up_rotation: np.ndarray, + z_up_pivot_xy: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray]: + """Convert one y-up pose and apply the export's global z-up rotation.""" + pos_y_up = SceneExporter._scene_vector(scene_object, "pos") + rot_y_up = SceneExporter._scene_vector(scene_object, "rot") + pos_z_up = _Y_UP_TO_Z_UP_ROTATION @ np.asarray(pos_y_up, dtype=float) + pos_z_up[:2] = z_up_pivot_xy + z_up_rotation[:2, :2] @ ( + pos_z_up[:2] - z_up_pivot_xy + ) + rotation_y_up = Rotation.from_euler("xyz", rot_y_up, degrees=True).as_matrix() + rotation_z_up = z_up_rotation @ ( + _Y_UP_TO_Z_UP_ROTATION @ rotation_y_up @ _Y_UP_TO_Z_UP_ROTATION.T + ) + return pos_z_up, rotation_z_up + + @staticmethod + def _scene_object_y_up_dict( + *, + scene_object: SceneObject, + z_up_rotation: np.ndarray, + z_up_pivot_xy: np.ndarray, + ) -> dict[str, object]: + """Serialize the globally rotated export without mutating the input scene.""" + pos_z_up, rotation_z_up = SceneExporter._final_z_up_pose( + scene_object=scene_object, + z_up_rotation=z_up_rotation, + z_up_pivot_xy=z_up_pivot_xy, + ) + result = scene_object.to_dict() + result["pos"] = (_Y_UP_TO_Z_UP_ROTATION.T @ pos_z_up).tolist() + result["rot"] = ( + Rotation.from_matrix( + _Y_UP_TO_Z_UP_ROTATION.T @ rotation_z_up @ _Y_UP_TO_Z_UP_ROTATION + ) + .as_euler("xyz", degrees=True) + .tolist() + ) + result["center_xy"] = SceneExporter._transformed_optional_xy( + scene_object=scene_object, + field_name="center_xy", + z_up_rotation=z_up_rotation, + z_up_pivot_xy=z_up_pivot_xy, + ) + result["support_contour_xy"] = SceneExporter._transformed_optional_xy_points( + scene_object=scene_object, + field_name="support_contour_xy", + z_up_rotation=z_up_rotation, + z_up_pivot_xy=z_up_pivot_xy, + ) + result["support_optimization_rect_xy"] = ( + SceneExporter._transformed_optional_xy_points( + scene_object=scene_object, + field_name="support_optimization_rect_xy", + z_up_rotation=z_up_rotation, + z_up_pivot_xy=z_up_pivot_xy, + ) + ) + return result + + @staticmethod + def _transformed_optional_xy( + *, + scene_object: SceneObject, + field_name: str, + z_up_rotation: np.ndarray, + z_up_pivot_xy: np.ndarray, + ) -> list[float] | None: + """Rotate one optional z-up XY metadata point around the table center.""" + value = getattr(scene_object, field_name) + if value is None: + return None + point = np.asarray(value, dtype=float) + if point.shape != (2,) or not np.all(np.isfinite(point)): + raise ValueError( + f"Scene object {scene_object.id!r} has invalid {field_name!r} metadata." + ) + return ( + z_up_pivot_xy + z_up_rotation[:2, :2] @ (point - z_up_pivot_xy) + ).tolist() + + @staticmethod + def _transformed_optional_xy_points( + *, + scene_object: SceneObject, + field_name: str, + z_up_rotation: np.ndarray, + z_up_pivot_xy: np.ndarray, + ) -> list[list[float]] | None: + """Rotate optional z-up XY support geometry around the table center.""" + value = getattr(scene_object, field_name) + if value is None: + return None + points = np.asarray(value, dtype=float) + if points.ndim != 2 or points.shape[1] != 2 or not np.all(np.isfinite(points)): + raise ValueError( + f"Scene object {scene_object.id!r} has invalid {field_name!r} metadata." + ) + return ( + z_up_pivot_xy + (z_up_rotation[:2, :2] @ (points - z_up_pivot_xy).T).T + ).tolist() + @staticmethod def _scene_vector(scene_object: SceneObject, field_name: str) -> list[float]: """Read one finite final y-up layout vector from a scene object.""" diff --git a/embodichain/gen_sim/scene_engine/pipeline/utils/scene_importer.py b/embodichain/gen_sim/scene_engine/pipeline/utils/scene_importer.py index e730b88cb..6d7ca6382 100644 --- a/embodichain/gen_sim/scene_engine/pipeline/utils/scene_importer.py +++ b/embodichain/gen_sim/scene_engine/pipeline/utils/scene_importer.py @@ -25,9 +25,10 @@ from embodichain.gen_sim.scene_engine.core.scene import Scene from embodichain.gen_sim.scene_engine.core.scene_graph import ( - SceneGraph, - SceneGraphNode, - SceneGraphRelation, + GENERATED_SCENE_GRAPH_SCHEMA, + GeneratedSceneGraph, + GeneratedSceneNode, + GeneratedSceneRelation, TABLE_REGIONS, ) from embodichain.gen_sim.scene_engine.core.scene_object import ( @@ -68,7 +69,7 @@ def import_scene(self) -> Scene: self._write_scene_json(scene) return scene - def import_scene_and_graph(self) -> tuple[Scene, SceneGraph]: + def import_scene_and_graph(self) -> tuple[Scene, GeneratedSceneGraph]: """Import a scene and graph after validating the complete edit input.""" scene = self._load_scene() scene_graph = self._load_scene_graph() @@ -112,7 +113,7 @@ def _load_scene(self) -> Scene: return self._scene_from_config(scene_config) - def _load_scene_graph(self) -> SceneGraph: + def _load_scene_graph(self) -> GeneratedSceneGraph: """Read and validate the exported scene graph.""" if not self.scene_graph_path.is_file(): raise FileNotFoundError(f"Scene graph not found: {self.scene_graph_path}") @@ -166,10 +167,19 @@ def _scene_from_config(self, scene_config: dict[str, Any]) -> Scene: ) @staticmethod - def _scene_graph_from_data(value: object) -> SceneGraph: - """Build a validated ``SceneGraph`` from exported graph JSON.""" - if not isinstance(value, dict) or set(value) != {"nodes", "relations"}: - raise ValueError("Scene graph must contain exactly nodes and relations.") + def _scene_graph_from_data(value: object) -> GeneratedSceneGraph: + """Build a validated ``GeneratedSceneGraph`` from exported graph JSON.""" + if not isinstance(value, dict) or set(value) != { + "schema_version", + "artifact_kind", + "nodes", + "relations", + }: + raise ValueError("Generated scene graph must use its versioned schema.") + if value["schema_version"] != GENERATED_SCENE_GRAPH_SCHEMA: + raise ValueError("Generated scene graph schema_version is unsupported.") + if value["artifact_kind"] != "scene_authoring": + raise ValueError("Generated scene graph must be an authoring artifact.") nodes_value = value["nodes"] relations_value = value["relations"] if not isinstance(nodes_value, list) or not isinstance(relations_value, list): @@ -183,10 +193,10 @@ def _scene_graph_from_data(value: object) -> SceneGraph: SceneExportImporter._scene_graph_relation_from_data(relation) for relation in relations_value ] - return SceneGraph(nodes=nodes, relations=relations) + return GeneratedSceneGraph(nodes=nodes, relations=relations) @staticmethod - def _scene_graph_node_from_data(value: object) -> SceneGraphNode: + def _scene_graph_node_from_data(value: object) -> GeneratedSceneNode: if not isinstance(value, dict) or set(value) != { "object_id", "parent_id", @@ -210,7 +220,7 @@ def _scene_graph_node_from_data(value: object) -> SceneGraphNode: raise ValueError("Scene graph table_region is invalid.") if orientation_state not in {None, "standing", "lying"}: raise ValueError("Scene graph orientation_state is invalid.") - return SceneGraphNode( + return GeneratedSceneNode( object_id=object_id, parent_id=parent_id, parent_relation=parent_relation, @@ -219,7 +229,7 @@ def _scene_graph_node_from_data(value: object) -> SceneGraphNode: ) @staticmethod - def _scene_graph_relation_from_data(value: object) -> SceneGraphRelation: + def _scene_graph_relation_from_data(value: object) -> GeneratedSceneRelation: if not isinstance(value, dict) or set(value) != { "source_id", "relation", @@ -235,7 +245,7 @@ def _scene_graph_relation_from_data(value: object) -> SceneGraphRelation: raise ValueError("Scene graph relation ids must be strings.") if relation not in {"left_of", "right_of", "in_front_of", "behind"}: raise ValueError("Scene graph relation is invalid.") - return SceneGraphRelation( + return GeneratedSceneRelation( source_id=source_id, relation=relation, target_id=target_id, @@ -314,6 +324,11 @@ def _scene_object_from_export_entry( body_type=str(entry.get("body_type", "dynamic")), # type: ignore[arg-type] attrs=self._physics_attrs(entry.get("attrs", {"mass": 1.0})), max_convex_hull_num=max(1, int(entry.get("max_convex_hull_num", 32))), + provenance=self._semantic_text( + entry.get("physics_provenance"), + field_name=f"{uid}.physics_provenance", + default="scene_export/legacy-import", + ), ), ) diff --git a/embodichain/gen_sim/scene_engine/pipeline/utils/scene_layout_constructor.py b/embodichain/gen_sim/scene_engine/pipeline/utils/scene_layout_constructor.py index b22a84c63..47e963677 100644 --- a/embodichain/gen_sim/scene_engine/pipeline/utils/scene_layout_constructor.py +++ b/embodichain/gen_sim/scene_engine/pipeline/utils/scene_layout_constructor.py @@ -23,7 +23,7 @@ from embodichain.gen_sim.scene_engine.core.scene import Scene from embodichain.gen_sim.scene_engine.core.scene_graph import ( TABLE_OBJECT_ID, - SceneGraph, + GeneratedSceneGraph, ) from embodichain.gen_sim.scene_engine.core.scene_object import SceneObject from embodichain.gen_sim.scene_engine.pipeline.utils.parent_surface_layout_optimizer import ( @@ -61,7 +61,7 @@ class SceneLayoutProblem: """Prepared graph-constrained inputs for one scene-layout construction.""" post_edit_scene: Scene - goal_scene_graph: SceneGraph + goal_scene_graph: GeneratedSceneGraph layout_variable_ids: set[str] initial_xy_by_id: dict[str, list[float] | None] groups: list[SceneLayoutGroup] @@ -78,7 +78,7 @@ def __init__( self, *, formal_scene: Scene, - goal_scene_graph: SceneGraph, + goal_scene_graph: GeneratedSceneGraph, layout_variable_ids: set[str], generated_scene_objects: list[SceneObject], output_root: str | Path, diff --git a/embodichain/gen_sim/scene_engine/pipeline/utils/simready_processor.py b/embodichain/gen_sim/scene_engine/pipeline/utils/simready_processor.py index 864e39a59..ede83a292 100644 --- a/embodichain/gen_sim/scene_engine/pipeline/utils/simready_processor.py +++ b/embodichain/gen_sim/scene_engine/pipeline/utils/simready_processor.py @@ -67,7 +67,7 @@ @dataclass(frozen=True) class SimReadyProcessorConfig: - """SceneGraph-conditioned policy for SimReady mesh canonicalization.""" + """GeneratedSceneGraph-conditioned policy for SimReady mesh canonicalization.""" use_vlm_scale: bool = False # Use the VLM-selected asset scale. use_vlm_rotation: bool = False # Use the VLM-selected asset rotation. @@ -290,12 +290,14 @@ def _fixed_physics_for_kind(kind: str) -> ObjectPhysics: body_type="kinematic", attrs=dict(_TABLE_PHYSICS_ATTRS), max_convex_hull_num=_FIXED_MAX_CONVEX_HULL_NUM, + provenance="scene_engine/simready-fixed-profile/v1", ) if kind == "asset": return ObjectPhysics( body_type="dynamic", attrs=dict(_ASSET_PHYSICS_ATTRS), max_convex_hull_num=_FIXED_MAX_CONVEX_HULL_NUM, + provenance="scene_engine/simready-fixed-profile/v1", ) raise ValueError(f"Unsupported SceneObject kind {kind!r} for physics.") diff --git a/embodichain/gen_sim/scene_engine/pipeline/utils/table_surface_layout_optimizer.py b/embodichain/gen_sim/scene_engine/pipeline/utils/table_surface_layout_optimizer.py index be6f0062d..066c09970 100644 --- a/embodichain/gen_sim/scene_engine/pipeline/utils/table_surface_layout_optimizer.py +++ b/embodichain/gen_sim/scene_engine/pipeline/utils/table_surface_layout_optimizer.py @@ -23,7 +23,7 @@ import numpy as np from scipy.optimize import minimize -from embodichain.gen_sim.scene_engine.core.scene_graph import SceneGraphRelation +from embodichain.gen_sim.scene_engine.core.scene_graph import GeneratedSceneRelation from embodichain.gen_sim.scene_engine.core.scene_object import SceneObject from embodichain.gen_sim.scene_engine.pipeline.utils.scene_layout_utils import ( load_scene_object_z_up_mesh, @@ -47,7 +47,7 @@ class TableSurfaceLayoutProblem: fixed_root_xy_by_id: dict[str, list[float] | None] root_table_regions_by_id: dict[str, str | None] table_optimization_rect_xy: list[list[float]] - root_relations: list[SceneGraphRelation] + root_relations: list[GeneratedSceneRelation] @classmethod def from_layout_problem( @@ -196,7 +196,7 @@ def _build_constraints( root_half_extents_xy: dict[str, np.ndarray], config: TableSurfaceLayoutOptimizerConfig, ) -> tuple[list[tuple[np.ndarray, float]], list[tuple[np.ndarray, float]]]: - """Build variable table-region, planar-relation, and fixed-root constraints.""" + """Build hard table-region, planar-relation, and fixed-root constraints.""" # Objects which need to be optimized. root_index = {root_id: index for index, root_id in enumerate(problem.root_ids)} table_bounds = _bounds_from_points(problem.table_optimization_rect_xy) diff --git a/tests/gen_sim/scene_engine/test_pipeline_api.py b/tests/gen_sim/scene_engine/test_pipeline_api.py new file mode 100644 index 000000000..251d9abf8 --- /dev/null +++ b/tests/gen_sim/scene_engine/test_pipeline_api.py @@ -0,0 +1,246 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +from __future__ import annotations + +from copy import deepcopy +import json +from pathlib import Path + +from embodichain.gen_sim.scene_engine.core.scene import Scene +from embodichain.gen_sim.scene_engine.core.scene_edit_plan import SceneEditPlan +from embodichain.gen_sim.scene_engine.core.scene_graph import ( + GeneratedSceneGraph, + GeneratedSceneNode, +) +from embodichain.gen_sim.scene_engine.core.scene_object import SceneObject +from embodichain.gen_sim.scene_engine.pipeline import api + + +class _HealthyClient: + def __init__(self) -> None: + self.health_checks = 0 + + def check_health(self) -> None: + self.health_checks += 1 + + +def _materialization( + *, + scene: Scene, + scene_graph: GeneratedSceneGraph, + output_root: Path, +) -> api.SceneMaterialization: + return api.SceneMaterialization( + scene=scene, + scene_graph=scene_graph, + output_root=output_root, + scene_config_path=output_root / "scene_export" / "scene_config.json", + ) + + +def _table_scene() -> tuple[Scene, GeneratedSceneGraph]: + scene = Scene( + objects=[ + SceneObject( + id="table", + kind="table", + category="table", + name="table", + description="A work table.", + ) + ] + ) + graph = GeneratedSceneGraph( + nodes=[GeneratedSceneNode(object_id="table", parent_id=None)] + ) + return scene, graph + + +def test_analyze_image_persists_blueprint_and_artifact_hashes( + tmp_path: Path, + monkeypatch, +) -> None: + image_path = tmp_path / "input.png" + image_path.write_bytes(b"image") + scene, graph = _table_scene() + + def fake_understand_scene(**kwargs): + stage_root = Path(kwargs["output_root"]) / "scene_understanding" + stage_root.mkdir(parents=True) + (stage_root / "table-mask.png").write_bytes(b"mask") + return scene, graph + + monkeypatch.setattr(api, "understand_scene", fake_understand_scene) + segmentation = _HealthyClient() + package = api.analyze_image( + image_path, + tmp_path / "output", + vlm_client=object(), + image_segmentation_client=segmentation, + ) + document = json.loads(package.manifest_path.read_text(encoding="utf-8")) + + assert segmentation.health_checks == 1 + assert document["blueprint_id"] == package.blueprint_id + assert document["scene_graph"] == graph.to_dict() + assert document["artifacts"][0]["path"].endswith("table-mask.png") + assert len(document["artifacts"][0]["sha256"]) == 64 + + +def test_analyze_edit_persists_post_edit_blueprint( + tmp_path: Path, + monkeypatch, +) -> None: + scene, graph = _table_scene() + plan = SceneEditPlan(scene=scene, scene_graph=graph, operations=[]) + + class FakeImporter: + def __init__(self, *, output_root: Path) -> None: + self.output_root = output_root + + def import_scene_and_graph(self): + return scene, graph + + monkeypatch.setattr(api, "SceneExportImporter", FakeImporter) + monkeypatch.setattr( + api, + "understand_scene_edit", + lambda **_: (plan, graph), + ) + package = api.analyze_edit( + output_root=tmp_path, + edit_prompt="Keep the scene unchanged.", + vlm_client=object(), + ) + document = json.loads(package.manifest_path.read_text(encoding="utf-8")) + + assert document["blueprint_id"] == package.blueprint_id + assert document["scene_edit_plan"] == plan.to_dict() + assert document["updated_scene_graph"] == graph.to_dict() + + +def test_materialize_blueprint_does_not_mutate_audited_snapshot( + tmp_path: Path, + monkeypatch, +) -> None: + scene, graph = _table_scene() + manifest_path = tmp_path / "scene_blueprint.json" + manifest_path.write_text("audited blueprint\n", encoding="utf-8") + package = api.SceneBlueprintPackage( + blueprint_id="blueprint", + image_path=tmp_path / "input.png", + output_root=tmp_path, + manifest_path=manifest_path, + scene=scene, + scene_graph=graph, + ) + original_scene = deepcopy(scene.to_dict()) + original_graph = deepcopy(graph.to_dict()) + + def fake_generate_scene_and_refine(**kwargs): + assert kwargs["seed"] == 31 + assert kwargs["scene"] is not package.scene + assert kwargs["scene_graph"] is not package.scene_graph + kwargs["scene"].objects[0].name = "materialized table" + return kwargs["scene"] + + monkeypatch.setattr( + api, + "generate_scene_and_refine", + fake_generate_scene_and_refine, + ) + monkeypatch.setattr( + api, + "_export_materialization", + lambda *, scene, scene_graph, output_root: _materialization( + scene=scene, + scene_graph=scene_graph, + output_root=output_root, + ), + ) + + result = api.materialize_blueprint( + package, + vlm_client=object(), + geometry_generation_client=_HealthyClient(), + seed=31, + ) + + assert result.scene.objects[0].name == "materialized table" + assert package.scene.to_dict() == original_scene + assert package.scene_graph.to_dict() == original_graph + assert manifest_path.read_text(encoding="utf-8") == "audited blueprint\n" + + +def test_materialize_edit_does_not_mutate_audited_snapshot( + tmp_path: Path, + monkeypatch, +) -> None: + scene, graph = _table_scene() + plan = SceneEditPlan(scene=scene, scene_graph=graph, operations=[]) + manifest_path = tmp_path / "scene_edit_blueprint.json" + manifest_path.write_text("audited edit blueprint\n", encoding="utf-8") + package = api.SceneEditBlueprintPackage( + blueprint_id="edit-blueprint", + edit_prompt="Keep the scene unchanged.", + output_root=tmp_path, + manifest_path=manifest_path, + scene_edit_plan=plan, + updated_scene_graph=graph, + ) + original_plan = deepcopy(plan.to_dict()) + original_graph = deepcopy(graph.to_dict()) + + def fake_prepare_scene_edit_assets(**kwargs): + assert kwargs["seed"] == 32 + return [] + + monkeypatch.setattr( + api, "prepare_scene_edit_assets", fake_prepare_scene_edit_assets + ) + + def fake_edit_layout(**kwargs): + assert kwargs["scene_edit_plan"] is not package.scene_edit_plan + assert kwargs["updated_scene_graph"] is not package.updated_scene_graph + kwargs["scene"].objects[0].name = "edited table" + return kwargs["scene"] + + monkeypatch.setattr(api, "edit_layout", fake_edit_layout) + monkeypatch.setattr( + api, + "_export_materialization", + lambda *, scene, scene_graph, output_root: _materialization( + scene=scene, + scene_graph=scene_graph, + output_root=output_root, + ), + ) + clients = [_HealthyClient(), _HealthyClient(), _HealthyClient()] + + result = api.materialize_edit( + package, + vlm_client=object(), + image_generation_client=clients[0], + geometry_generation_client=clients[1], + image_segmentation_client=clients[2], + seed=32, + ) + + assert result.scene.objects[0].name == "edited table" + assert package.scene_edit_plan.to_dict() == original_plan + assert package.updated_scene_graph.to_dict() == original_graph + assert manifest_path.read_text(encoding="utf-8") == "audited edit blueprint\n" diff --git a/tests/gen_sim/scene_engine/test_scene_core_and_export.py b/tests/gen_sim/scene_engine/test_scene_core_and_export.py index 95658cf16..6c29f683a 100644 --- a/tests/gen_sim/scene_engine/test_scene_core_and_export.py +++ b/tests/gen_sim/scene_engine/test_scene_core_and_export.py @@ -25,8 +25,8 @@ from embodichain.gen_sim.scene_engine.core.scene import Scene from embodichain.gen_sim.scene_engine.core.scene_graph import ( - SceneGraph, - SceneGraphNode, + GeneratedSceneGraph, + GeneratedSceneNode, ) from embodichain.gen_sim.scene_engine.core.scene_object import ( ObjectPhysics, @@ -64,17 +64,18 @@ def _physics(body_type: str) -> ObjectPhysics: body_type=body_type, # type: ignore[arg-type] attrs={"mass": 1.0, "static_friction": 0.8}, max_convex_hull_num=16, + provenance="test/fixed-profile/v1", ) -def _scene_graph(scene: Scene) -> SceneGraph: +def _scene_graph(scene: Scene) -> GeneratedSceneGraph: if scene.table is None: raise ValueError("Test scene must contain a table.") - return SceneGraph( + return GeneratedSceneGraph( nodes=[ - SceneGraphNode(object_id="table", parent_id=None), + GeneratedSceneNode(object_id="table", parent_id=None), *[ - SceneGraphNode( + GeneratedSceneNode( object_id=asset.id, parent_id="table", parent_relation="on", @@ -128,7 +129,9 @@ def test_object_physics_rejects_invalid_values( ) -def test_scene_export_copies_meshes_and_converts_y_up_pose(tmp_path: Path) -> None: +def test_scene_export_rotates_the_complete_z_up_scene_by_default( + tmp_path: Path, +) -> None: table_glb = tmp_path / "table.glb" asset_glb = tmp_path / "cup.glb" table_glb.write_bytes(b"glTF-table") @@ -139,6 +142,14 @@ def test_scene_export_copies_meshes_and_converts_y_up_pose(tmp_path: Path) -> No glb_path=table_glb, physics=_physics("kinematic"), ) + table.pos = [0.0, 0.0, 0.0] + table.support_contour_xy = [[-1.0, -0.5], [1.0, -0.5], [1.0, 0.5]] + table.support_optimization_rect_xy = [ + [-0.8, -0.3], + [0.8, -0.3], + [0.8, 0.3], + [-0.8, 0.3], + ] asset = _scene_object( object_id="cup", kind="asset", @@ -154,7 +165,14 @@ def test_scene_export_copies_meshes_and_converts_y_up_pose(tmp_path: Path) -> No output_root=tmp_path / "output", ).export() exported = json.loads(export_path.read_text(encoding="utf-8")) + second_export_path = SceneExporter( + scene=scene, + scene_graph=_scene_graph(scene), + output_root=tmp_path / "second-output", + ).export() + second_export = json.loads(second_export_path.read_text(encoding="utf-8")) + assert exported["scene_id"] == second_export["scene_id"] assert ( export_path.parent / "mesh_assets/table/table.glb" ).read_bytes() == b"glTF-table" @@ -164,11 +182,29 @@ def test_scene_export_copies_meshes_and_converts_y_up_pose(tmp_path: Path) -> No assert entry["category"] == "asset" assert entry["name"] == "cup" assert entry["body_type"] == "dynamic" - assert entry["init_pos"] == [1.0, -3.0, 2.0] + assert np.allclose(entry["init_pos"], [-1.0, 3.0, 2.0]) assert entry["body_scale"] == [1.0, 2.0, 3.0] - assert entry["center_xy"] == [0.25, -0.5] - assert np.allclose(entry["init_rot"], [0.0, 0.0, 0.0]) + assert np.allclose(entry["center_xy"], [-0.25, 0.5]) + assert np.allclose( + entry["init_rot"], + [0.0, 0.0, 180.0], + ) + exported_table = exported["background"][0] + assert np.allclose( + exported_table["support_contour_xy"], + [[1.0, 0.5], [-1.0, 0.5], [-1.0, -0.5]], + ) + assert np.allclose( + exported_table["support_optimization_rect_xy"], + [[0.8, 0.3], [-0.8, 0.3], [-0.8, -0.3], [0.8, -0.3]], + ) + exported_scene_json = json.loads((export_path.parent / "scene.json").read_text()) + exported_asset_json = exported_scene_json["objects"][1] + assert np.allclose(exported_asset_json["pos"], [-1.0, 2.0, -3.0]) + assert np.allclose(exported_asset_json["center_xy"], [-0.25, 0.5]) assert json.loads((export_path.parent / "scene_graph.json").read_text()) == { + "schema_version": "generated_scene_graph/v1", + "artifact_kind": "scene_authoring", "nodes": [ { "object_id": "table", @@ -187,6 +223,24 @@ def test_scene_export_copies_meshes_and_converts_y_up_pose(tmp_path: Path) -> No ], "relations": [], } + authoring_evidence = json.loads( + (export_path.parent / "scene_authoring_evidence.json").read_text() + ) + assert authoring_evidence["artifact_kind"] == "audit_only" + assert authoring_evidence["schema_version"] == "scene_authoring_evidence/v1" + cup_evidence = next( + item for item in authoring_evidence["objects"] if item["canonical_id"] == "cup" + ) + assert cup_evidence["ancestry"] == { + "parent_id": "table", + "parent_relation": "on", + } + assert cup_evidence["affordance_evidence"]["source"] == ("generated_scene_graph") + assert cup_evidence["geometry_metadata"]["asset_path"] == ( + "mesh_assets/cup/cup.glb" + ) + assert len(cup_evidence["geometry_metadata"]["sha256"]) == 64 + assert cup_evidence["physics_provenance"]["provenance"] == ("test/fixed-profile/v1") imported_scene, imported_graph = SceneExportImporter( output_root=tmp_path / "output" @@ -194,12 +248,54 @@ def test_scene_export_copies_meshes_and_converts_y_up_pose(tmp_path: Path) -> No assert [asset.id for asset in imported_scene.assets] == ["cup"] assert imported_scene.assets[0].category == "asset" assert imported_scene.assets[0].name == "cup" + assert imported_scene.assets[0].physics is not None + assert imported_scene.assets[0].physics.provenance == "test/fixed-profile/v1" + assert np.allclose(imported_scene.assets[0].pos, [-1.0, 2.0, -3.0]) + assert np.allclose(imported_scene.assets[0].center_xy, [-0.25, 0.5]) + assert np.allclose( + imported_scene.table.support_contour_xy, + [[1.0, 0.5], [-1.0, 0.5], [-1.0, -0.5]], + ) assert imported_graph.to_dict() == _scene_graph(scene).to_dict() +def test_scene_export_can_disable_the_default_z_up_rotation(tmp_path: Path) -> None: + table_glb = tmp_path / "table.glb" + asset_glb = tmp_path / "cup.glb" + table_glb.write_bytes(b"glTF-table") + asset_glb.write_bytes(b"glTF-cup") + table = _scene_object( + object_id="table", + kind="table", + glb_path=table_glb, + physics=_physics("kinematic"), + ) + table.pos = [0.0, 0.0, 0.0] + asset = _scene_object( + object_id="cup", + kind="asset", + glb_path=asset_glb, + physics=_physics("dynamic"), + ) + + scene = Scene(objects=[table, asset]) + export_path = SceneExporter( + scene=scene, + scene_graph=_scene_graph(scene), + output_root=tmp_path / "output", + rotate_z_up_180=False, + ).export() + exported = json.loads(export_path.read_text(encoding="utf-8")) + + assert exported["rigid_object"][0]["init_pos"] == [1.0, -3.0, 2.0] + assert np.allclose(exported["rigid_object"][0]["init_rot"], [0.0, 0.0, 0.0]) + + def test_scene_graph_importer_restores_node_orientation_state() -> None: imported_graph = SceneExportImporter._scene_graph_from_data( { + "schema_version": "generated_scene_graph/v1", + "artifact_kind": "scene_authoring", "nodes": [ { "object_id": "table", @@ -296,8 +392,8 @@ def test_scene_export_requires_final_physics(tmp_path: Path) -> None: with pytest.raises(ValueError, match="no SimReady physics"): SceneExporter( scene=Scene(objects=[table]), - scene_graph=SceneGraph( - nodes=[SceneGraphNode(object_id="table", parent_id=None)] + scene_graph=GeneratedSceneGraph( + nodes=[GeneratedSceneNode(object_id="table", parent_id=None)] ), output_root=tmp_path, ).export() diff --git a/tests/gen_sim/scene_engine/test_scene_graph.py b/tests/gen_sim/scene_engine/test_scene_graph.py index c6cbd3243..62c97e254 100644 --- a/tests/gen_sim/scene_engine/test_scene_graph.py +++ b/tests/gen_sim/scene_engine/test_scene_graph.py @@ -19,37 +19,43 @@ import pytest from embodichain.gen_sim.scene_engine.core.scene_graph import ( - SceneGraph, - SceneGraphNode, - SceneGraphRelation, + GeneratedSceneGraph, + GeneratedSceneNode, + GeneratedSceneRelation, ) +@pytest.mark.parametrize("object_id", ["", " cup", "cup "]) +def test_scene_graph_rejects_unstable_object_ids(object_id: str) -> None: + with pytest.raises(ValueError, match="trimmed string"): + GeneratedSceneNode(object_id=object_id, parent_id="table") + + def test_scene_graph_accepts_layered_on_relations() -> None: - graph = SceneGraph( + graph = GeneratedSceneGraph( nodes=[ - SceneGraphNode(object_id="table", parent_id=None), - SceneGraphNode( + GeneratedSceneNode(object_id="table", parent_id=None), + GeneratedSceneNode( object_id="plate", parent_id="table", parent_relation="on", table_region="center", orientation_state="standing", ), - SceneGraphNode( + GeneratedSceneNode( object_id="cup", parent_id="table", parent_relation="on", table_region="right_center", ), - SceneGraphNode( + GeneratedSceneNode( object_id="spoon", parent_id="plate", parent_relation="on", ), ], relations=[ - SceneGraphRelation( + GeneratedSceneRelation( source_id="plate", relation="left_of", target_id="cup", @@ -63,22 +69,22 @@ def test_scene_graph_accepts_layered_on_relations() -> None: def test_scene_graph_rejects_planar_relations_without_common_parent() -> None: with pytest.raises(ValueError, match="share one parent"): - SceneGraph( + GeneratedSceneGraph( nodes=[ - SceneGraphNode(object_id="table", parent_id=None), - SceneGraphNode( + GeneratedSceneNode(object_id="table", parent_id=None), + GeneratedSceneNode( object_id="plate", parent_id="table", parent_relation="on", ), - SceneGraphNode( + GeneratedSceneNode( object_id="spoon", parent_id="plate", parent_relation="on", ), ], relations=[ - SceneGraphRelation( + GeneratedSceneRelation( source_id="plate", relation="right_of", target_id="spoon", @@ -89,27 +95,27 @@ def test_scene_graph_rejects_planar_relations_without_common_parent() -> None: def test_scene_graph_rejects_conflicting_planar_relations() -> None: with pytest.raises(ValueError, match="Conflicting planar relations"): - SceneGraph( + GeneratedSceneGraph( nodes=[ - SceneGraphNode(object_id="table", parent_id=None), - SceneGraphNode( + GeneratedSceneNode(object_id="table", parent_id=None), + GeneratedSceneNode( object_id="plate", parent_id="table", parent_relation="on", ), - SceneGraphNode( + GeneratedSceneNode( object_id="cup", parent_id="table", parent_relation="on", ), ], relations=[ - SceneGraphRelation( + GeneratedSceneRelation( source_id="plate", relation="left_of", target_id="cup", ), - SceneGraphRelation( + GeneratedSceneRelation( source_id="cup", relation="left_of", target_id="plate", @@ -120,10 +126,10 @@ def test_scene_graph_rejects_conflicting_planar_relations() -> None: def test_scene_graph_requires_explicit_parent_relation() -> None: with pytest.raises(ValueError, match="parent relation"): - SceneGraph( + GeneratedSceneGraph( nodes=[ - SceneGraphNode(object_id="table", parent_id=None), - SceneGraphNode( + GeneratedSceneNode(object_id="table", parent_id=None), + GeneratedSceneNode( object_id="plate", parent_id="table", ), @@ -132,10 +138,10 @@ def test_scene_graph_requires_explicit_parent_relation() -> None: def test_scene_graph_can_skip_validation_during_refresh() -> None: - graph = SceneGraph( + graph = GeneratedSceneGraph( nodes=[ - SceneGraphNode(object_id="table", parent_id=None), - SceneGraphNode( + GeneratedSceneNode(object_id="table", parent_id=None), + GeneratedSceneNode( object_id="plate", parent_id="table", ), @@ -149,7 +155,7 @@ def test_scene_graph_can_skip_validation_during_refresh() -> None: def test_scene_graph_rejects_unsupported_parent_relation() -> None: with pytest.raises(ValueError, match="must be on their parent"): - SceneGraphNode( + GeneratedSceneNode( object_id="orange", parent_id="box", parent_relation="inside", @@ -157,15 +163,15 @@ def test_scene_graph_rejects_unsupported_parent_relation() -> None: def test_scene_graph_derives_layers_from_parent_links() -> None: - graph = SceneGraph( + graph = GeneratedSceneGraph( nodes=[ - SceneGraphNode(object_id="table", parent_id=None), - SceneGraphNode( + GeneratedSceneNode(object_id="table", parent_id=None), + GeneratedSceneNode( object_id="plate", parent_id="table", parent_relation="on", ), - SceneGraphNode( + GeneratedSceneNode( object_id="spoon", parent_id="plate", parent_relation="on", @@ -181,7 +187,7 @@ def test_scene_graph_derives_layers_from_parent_links() -> None: def test_scene_graph_layer_by_id_requires_table_root() -> None: - graph = SceneGraph(nodes=[], validate_on_refresh=False) + graph = GeneratedSceneGraph(nodes=[], validate_on_refresh=False) with pytest.raises(ValueError, match="table node"): graph.layer_by_id() @@ -189,15 +195,15 @@ def test_scene_graph_layer_by_id_requires_table_root() -> None: def test_scene_graph_rejects_table_region_for_non_table_parent() -> None: with pytest.raises(ValueError, match="only valid for objects on the table"): - SceneGraph( + GeneratedSceneGraph( nodes=[ - SceneGraphNode(object_id="table", parent_id=None), - SceneGraphNode( + GeneratedSceneNode(object_id="table", parent_id=None), + GeneratedSceneNode( object_id="plate", parent_id="table", parent_relation="on", ), - SceneGraphNode( + GeneratedSceneNode( object_id="spoon", parent_id="plate", parent_relation="on", @@ -208,27 +214,27 @@ def test_scene_graph_rejects_table_region_for_non_table_parent() -> None: def test_scene_graph_derives_support_and_inverse_planar_constraints() -> None: - graph = SceneGraph( + graph = GeneratedSceneGraph( nodes=[ - SceneGraphNode(object_id="table", parent_id=None), - SceneGraphNode( + GeneratedSceneNode(object_id="table", parent_id=None), + GeneratedSceneNode( object_id="plate", parent_id="table", parent_relation="on", ), - SceneGraphNode( + GeneratedSceneNode( object_id="cup", parent_id="table", parent_relation="on", ), ], relations=[ - SceneGraphRelation( + GeneratedSceneRelation( source_id="plate", relation="left_of", target_id="cup", ), - SceneGraphRelation( + GeneratedSceneRelation( source_id="plate", relation="left_of", target_id="cup", @@ -247,22 +253,22 @@ def test_scene_graph_derives_support_and_inverse_planar_constraints() -> None: def test_scene_graph_materializes_inverse_planar_relations() -> None: - graph = SceneGraph( + graph = GeneratedSceneGraph( nodes=[ - SceneGraphNode(object_id="table", parent_id=None), - SceneGraphNode( + GeneratedSceneNode(object_id="table", parent_id=None), + GeneratedSceneNode( object_id="plate", parent_id="table", parent_relation="on", ), - SceneGraphNode( + GeneratedSceneNode( object_id="cup", parent_id="table", parent_relation="on", ), ], relations=[ - SceneGraphRelation( + GeneratedSceneRelation( source_id="plate", relation="left_of", target_id="cup", @@ -278,20 +284,20 @@ def test_scene_graph_materializes_inverse_planar_relations() -> None: def test_scene_graph_batch_planar_updates_preserve_chained_constraints() -> None: """Keep both requested relations when one update targets another source.""" - graph = SceneGraph( + graph = GeneratedSceneGraph( nodes=[ - SceneGraphNode(object_id="table", parent_id=None), - SceneGraphNode( + GeneratedSceneNode(object_id="table", parent_id=None), + GeneratedSceneNode( object_id="plate", parent_id="table", parent_relation="on", ), - SceneGraphNode( + GeneratedSceneNode( object_id="cup", parent_id="table", parent_relation="on", ), - SceneGraphNode( + GeneratedSceneNode( object_id="spoon", parent_id="table", parent_relation="on", @@ -322,10 +328,10 @@ def test_scene_graph_batch_planar_updates_preserve_chained_constraints() -> None def test_scene_graph_to_dict_serializes_graph_state() -> None: - graph = SceneGraph( + graph = GeneratedSceneGraph( nodes=[ - SceneGraphNode(object_id="table", parent_id=None), - SceneGraphNode( + GeneratedSceneNode(object_id="table", parent_id=None), + GeneratedSceneNode( object_id="plate", parent_id="table", parent_relation="on", @@ -338,6 +344,8 @@ def test_scene_graph_to_dict_serializes_graph_state() -> None: graph_dict = graph.to_dict() assert graph_dict == { + "schema_version": "generated_scene_graph/v1", + "artifact_kind": "scene_authoring", "nodes": [ { "object_id": "table", diff --git a/tests/gen_sim/scene_engine/test_scene_understanding.py b/tests/gen_sim/scene_engine/test_scene_understanding.py index 382e4933d..637835ece 100644 --- a/tests/gen_sim/scene_engine/test_scene_understanding.py +++ b/tests/gen_sim/scene_engine/test_scene_understanding.py @@ -157,7 +157,7 @@ def complete(self, **_: object) -> str: return json.dumps( { "orientation_states": [ - {"object_id": "cup_001", "orientation_state": None}, + {"object_id": "cup_001", "orientation_state": "lying"}, ] } ) @@ -190,6 +190,8 @@ def complete(self, **_: object) -> str: ) assert scene_graph.to_dict() == { + "schema_version": "generated_scene_graph/v1", + "artifact_kind": "scene_authoring", "nodes": [ { "object_id": "table", @@ -203,14 +205,14 @@ def complete(self, **_: object) -> str: "parent_id": "table", "parent_relation": "on", "table_region": None, - "orientation_state": None, + "orientation_state": "lying", }, ], "relations": [], } -def test_scene_graph_initialization_uses_image_orientation_states( +def test_scene_graph_initialization_uses_container_orientation_states( tmp_path: Path, ) -> None: class VLM: @@ -359,7 +361,7 @@ def test_scene_graph_initialization_requires_asset_mask_id_overlay( ) -def test_scene_graph_initialization_info_lists_asset_ids() -> None: +def test_scene_graph_initialization_info_lists_existing_object_ids() -> None: scene = Scene( objects=[ SceneObject(