Spaces:
Running on Zero
Running on Zero
Render auto kinematics with selected upright orientation
Browse files
app.py
CHANGED
|
@@ -2903,9 +2903,11 @@ class InstructParticulateApp:
|
|
| 2903 |
"Rendering",
|
| 2904 |
)
|
| 2905 |
rendered_views = render_mesh_auto_kinematics_views(
|
| 2906 |
-
mesh_geometry.
|
| 2907 |
output_dir=renders_dir,
|
| 2908 |
azimuths_deg=DEFAULT_AUTO_KINEMATICS_AZIMUTHS,
|
|
|
|
|
|
|
| 2909 |
blender_bin=os.environ.get("BLENDER_BIN") or None,
|
| 2910 |
)
|
| 2911 |
gallery_items = _auto_render_gallery_items(rendered_views)
|
|
@@ -2939,7 +2941,7 @@ class InstructParticulateApp:
|
|
| 2939 |
lifted=lifted,
|
| 2940 |
center=np.asarray(mesh_geometry.center, dtype=np.float32),
|
| 2941 |
scale=float(mesh_geometry.scale),
|
| 2942 |
-
render_to_model_rotation=
|
| 2943 |
)
|
| 2944 |
save_auto_kinematics_artifacts(
|
| 2945 |
output_dir=auto_output_dir,
|
|
|
|
| 2903 |
"Rendering",
|
| 2904 |
)
|
| 2905 |
rendered_views = render_mesh_auto_kinematics_views(
|
| 2906 |
+
mesh_geometry.original_mesh,
|
| 2907 |
output_dir=renders_dir,
|
| 2908 |
azimuths_deg=DEFAULT_AUTO_KINEMATICS_AZIMUTHS,
|
| 2909 |
+
mesh_path=mesh_path,
|
| 2910 |
+
up_dir=canonical_up,
|
| 2911 |
blender_bin=os.environ.get("BLENDER_BIN") or None,
|
| 2912 |
)
|
| 2913 |
gallery_items = _auto_render_gallery_items(rendered_views)
|
|
|
|
| 2941 |
lifted=lifted,
|
| 2942 |
center=np.asarray(mesh_geometry.center, dtype=np.float32),
|
| 2943 |
scale=float(mesh_geometry.scale),
|
| 2944 |
+
render_to_model_rotation=None,
|
| 2945 |
)
|
| 2946 |
save_auto_kinematics_artifacts(
|
| 2947 |
output_dir=auto_output_dir,
|
instruct_particulate/utils/auto_kinematics_utils.py
CHANGED
|
@@ -213,6 +213,8 @@ def render_mesh_auto_kinematics_views(
|
|
| 213 |
*,
|
| 214 |
output_dir: Path,
|
| 215 |
azimuths_deg: Sequence[float],
|
|
|
|
|
|
|
| 216 |
pitch_deg: float = DEFAULT_RENDER_PITCH_DEG,
|
| 217 |
camera_distance: float = DEFAULT_RENDER_DISTANCE,
|
| 218 |
resolution: int = DEFAULT_RENDER_RESOLUTION,
|
|
@@ -227,8 +229,13 @@ def render_mesh_auto_kinematics_views(
|
|
| 227 |
if not blender_script_path.exists():
|
| 228 |
raise FileNotFoundError(f"Auto-kinematics Blender script does not exist: {blender_script_path}")
|
| 229 |
|
| 230 |
-
|
| 231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
command = [
|
| 233 |
str(blender_path),
|
| 234 |
"-b",
|
|
@@ -252,6 +259,8 @@ def render_mesh_auto_kinematics_views(
|
|
| 252 |
"--azimuths",
|
| 253 |
*[repr(float(azimuth_deg)) for azimuth_deg in azimuths],
|
| 254 |
]
|
|
|
|
|
|
|
| 255 |
print(
|
| 256 |
f"Rendering {len(azimuths)} auto-kinematics views with Blender at {blender_path}"
|
| 257 |
)
|
|
|
|
| 213 |
*,
|
| 214 |
output_dir: Path,
|
| 215 |
azimuths_deg: Sequence[float],
|
| 216 |
+
mesh_path: str | os.PathLike[str] | None = None,
|
| 217 |
+
up_dir: str | None = None,
|
| 218 |
pitch_deg: float = DEFAULT_RENDER_PITCH_DEG,
|
| 219 |
camera_distance: float = DEFAULT_RENDER_DISTANCE,
|
| 220 |
resolution: int = DEFAULT_RENDER_RESOLUTION,
|
|
|
|
| 229 |
if not blender_script_path.exists():
|
| 230 |
raise FileNotFoundError(f"Auto-kinematics Blender script does not exist: {blender_script_path}")
|
| 231 |
|
| 232 |
+
if mesh_path is None:
|
| 233 |
+
render_mesh_path = output_dir / "_auto_kinematics_render_mesh.glb"
|
| 234 |
+
mesh.export(render_mesh_path)
|
| 235 |
+
else:
|
| 236 |
+
render_mesh_path = Path(mesh_path).expanduser().resolve()
|
| 237 |
+
if not render_mesh_path.exists():
|
| 238 |
+
raise FileNotFoundError(f"Auto-kinematics render mesh path does not exist: {render_mesh_path}")
|
| 239 |
command = [
|
| 240 |
str(blender_path),
|
| 241 |
"-b",
|
|
|
|
| 259 |
"--azimuths",
|
| 260 |
*[repr(float(azimuth_deg)) for azimuth_deg in azimuths],
|
| 261 |
]
|
| 262 |
+
if up_dir is not None and str(up_dir).strip():
|
| 263 |
+
command.extend(["--up-dir", str(up_dir)])
|
| 264 |
print(
|
| 265 |
f"Rendering {len(azimuths)} auto-kinematics views with Blender at {blender_path}"
|
| 266 |
)
|
scripts/render_auto_kinematics_blender.py
CHANGED
|
@@ -46,17 +46,39 @@ else: # pragma: no cover - used only in non-Blender unit tests
|
|
| 46 |
IMPORT_FUNCTIONS = {}
|
| 47 |
|
| 48 |
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
"""Match the final saved image orientation for per-pixel render payloads."""
|
| 51 |
-
oriented = np.
|
|
|
|
|
|
|
| 52 |
return oriented.copy()
|
| 53 |
|
| 54 |
|
| 55 |
-
def orient_saved_render_image(image: Image.Image) -> Image.Image:
|
| 56 |
"""Apply the same output-space transform as the saved raster payloads."""
|
|
|
|
|
|
|
| 57 |
oriented = image.transpose(Image.FLIP_TOP_BOTTOM)
|
| 58 |
-
|
| 59 |
-
return oriented
|
| 60 |
|
| 61 |
|
| 62 |
def parse_args() -> argparse.Namespace:
|
|
@@ -70,6 +92,7 @@ def parse_args() -> argparse.Namespace:
|
|
| 70 |
parser.add_argument("--engine", type=str, default="CYCLES")
|
| 71 |
parser.add_argument("--samples", type=int, default=8)
|
| 72 |
parser.add_argument("--azimuths", type=float, nargs="+", required=True)
|
|
|
|
| 73 |
return parser.parse_args(argv)
|
| 74 |
|
| 75 |
|
|
@@ -213,6 +236,57 @@ def get_scene_meshes() -> list[bpy.types.Object]:
|
|
| 213 |
return [obj for obj in bpy.context.scene.objects.values() if isinstance(obj.data, bpy.types.Mesh)]
|
| 214 |
|
| 215 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
def scene_bbox() -> tuple[Vector, Vector]:
|
| 217 |
bbox_min = (math.inf,) * 3
|
| 218 |
bbox_max = (-math.inf,) * 3
|
|
@@ -421,8 +495,18 @@ def main() -> None:
|
|
| 421 |
resolution=int(args.resolution),
|
| 422 |
samples=int(args.samples),
|
| 423 |
)
|
| 424 |
-
|
| 425 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
init_lighting()
|
| 427 |
intrinsic = configure_camera(
|
| 428 |
camera,
|
|
@@ -458,17 +542,17 @@ def main() -> None:
|
|
| 458 |
intrinsic=intrinsic.astype(np.float32),
|
| 459 |
camera_to_world=camera_to_world.astype(np.float32),
|
| 460 |
world_to_camera=world_to_camera.astype(np.float32),
|
| 461 |
-
face_ids=orient_saved_raster_payload(face_ids).astype(np.int32),
|
| 462 |
-
hit_points=orient_saved_raster_payload(hit_points).astype(np.float32),
|
| 463 |
-
normals=orient_saved_raster_payload(normals).astype(np.float32),
|
| 464 |
-
depth=orient_saved_raster_payload(depth).astype(np.float32),
|
| 465 |
azimuth_deg=np.float32(azimuth_deg),
|
| 466 |
elevation_deg=np.float32(90.0 - float(args.pitch_deg)),
|
| 467 |
pitch_deg=np.float32(args.pitch_deg),
|
| 468 |
)
|
| 469 |
if Image is not None:
|
| 470 |
with Image.open(image_path) as image:
|
| 471 |
-
oriented = orient_saved_render_image(image)
|
| 472 |
oriented.save(image_path)
|
| 473 |
print(
|
| 474 |
f"Rendered auto-kinematics Blender view {image_id + 1}/{len(args.azimuths)} "
|
|
|
|
| 46 |
IMPORT_FUNCTIONS = {}
|
| 47 |
|
| 48 |
|
| 49 |
+
UP_DIR_ROTATIONS = {
|
| 50 |
+
"+X": ((0.0, 0.0, -1.0), (0.0, 1.0, 0.0), (1.0, 0.0, 0.0)),
|
| 51 |
+
"-X": ((0.0, 0.0, 1.0), (0.0, 1.0, 0.0), (-1.0, 0.0, 0.0)),
|
| 52 |
+
"+Y": ((1.0, 0.0, 0.0), (0.0, 0.0, -1.0), (0.0, 1.0, 0.0)),
|
| 53 |
+
"-Y": ((1.0, 0.0, 0.0), (0.0, 0.0, 1.0), (0.0, -1.0, 0.0)),
|
| 54 |
+
"+Z": ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
|
| 55 |
+
"-Z": ((1.0, 0.0, 0.0), (0.0, -1.0, 0.0), (0.0, 0.0, -1.0)),
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def canonicalize_up_dir(up_dir: str) -> str:
|
| 60 |
+
token = str(up_dir).strip().upper()
|
| 61 |
+
if token in {"X", "Y", "Z"}:
|
| 62 |
+
token = f"+{token}"
|
| 63 |
+
if token not in UP_DIR_ROTATIONS:
|
| 64 |
+
raise ValueError(f"Invalid up direction: {up_dir}")
|
| 65 |
+
return token
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def orient_saved_raster_payload(buffer: np.ndarray, *, flip: bool) -> np.ndarray:
|
| 69 |
"""Match the final saved image orientation for per-pixel render payloads."""
|
| 70 |
+
oriented = np.asarray(buffer)
|
| 71 |
+
if flip:
|
| 72 |
+
oriented = np.flip(np.flip(oriented, axis=0), axis=1)
|
| 73 |
return oriented.copy()
|
| 74 |
|
| 75 |
|
| 76 |
+
def orient_saved_render_image(image: Image.Image, *, flip: bool) -> Image.Image:
|
| 77 |
"""Apply the same output-space transform as the saved raster payloads."""
|
| 78 |
+
if not flip:
|
| 79 |
+
return image.copy()
|
| 80 |
oriented = image.transpose(Image.FLIP_TOP_BOTTOM)
|
| 81 |
+
return oriented.transpose(Image.FLIP_LEFT_RIGHT)
|
|
|
|
| 82 |
|
| 83 |
|
| 84 |
def parse_args() -> argparse.Namespace:
|
|
|
|
| 92 |
parser.add_argument("--engine", type=str, default="CYCLES")
|
| 93 |
parser.add_argument("--samples", type=int, default=8)
|
| 94 |
parser.add_argument("--azimuths", type=float, nargs="+", required=True)
|
| 95 |
+
parser.add_argument("--up-dir", type=str, default="")
|
| 96 |
return parser.parse_args(argv)
|
| 97 |
|
| 98 |
|
|
|
|
| 236 |
return [obj for obj in bpy.context.scene.objects.values() if isinstance(obj.data, bpy.types.Mesh)]
|
| 237 |
|
| 238 |
|
| 239 |
+
def create_render_root() -> bpy.types.Object:
|
| 240 |
+
root = bpy.data.objects.new("AutoKinematicsRenderRoot", None)
|
| 241 |
+
bpy.context.scene.collection.objects.link(root)
|
| 242 |
+
imported_roots = [
|
| 243 |
+
obj
|
| 244 |
+
for obj in bpy.context.scene.objects.values()
|
| 245 |
+
if obj.parent is None and obj.type not in {"CAMERA", "LIGHT"}
|
| 246 |
+
]
|
| 247 |
+
for obj in imported_roots:
|
| 248 |
+
if obj == root:
|
| 249 |
+
continue
|
| 250 |
+
obj.parent = root
|
| 251 |
+
obj.matrix_parent_inverse = root.matrix_world.inverted()
|
| 252 |
+
return root
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def rotation_matrix_for_up(up_dir: str) -> Matrix:
|
| 256 |
+
return Matrix(UP_DIR_ROTATIONS[canonicalize_up_dir(up_dir)]).to_4x4()
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def import_basis_to_blender(mesh_path: Path) -> Matrix:
|
| 260 |
+
suffix = mesh_path.suffix.lower().lstrip(".")
|
| 261 |
+
if suffix in {"glb", "gltf"}:
|
| 262 |
+
return rotation_matrix_for_up("+Y")
|
| 263 |
+
return Matrix.Identity(4)
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def orient_and_normalize(
|
| 267 |
+
root: bpy.types.Object,
|
| 268 |
+
*,
|
| 269 |
+
up_dir: str,
|
| 270 |
+
import_basis: Matrix,
|
| 271 |
+
) -> None:
|
| 272 |
+
# Keep auto-kinematic renders in the same selected-upright frame as the
|
| 273 |
+
# upright orientation picker: undo Blender's glTF import basis, then apply
|
| 274 |
+
# the user-selected source-up -> +Z rotation.
|
| 275 |
+
rotation = rotation_matrix_for_up(up_dir) @ import_basis.inverted()
|
| 276 |
+
root.matrix_world = rotation
|
| 277 |
+
bpy.context.view_layer.update()
|
| 278 |
+
|
| 279 |
+
bbox_min, bbox_max = scene_bbox()
|
| 280 |
+
center = (bbox_min + bbox_max) * 0.5
|
| 281 |
+
extent = bbox_max - bbox_min
|
| 282 |
+
max_extent = max(float(extent.x), float(extent.y), float(extent.z), 1e-6)
|
| 283 |
+
scale = 1.0 / max_extent
|
| 284 |
+
scale_matrix = Matrix.Diagonal((scale, scale, scale, 1.0))
|
| 285 |
+
center_matrix = Matrix.Translation(-center)
|
| 286 |
+
root.matrix_world = scale_matrix @ center_matrix @ rotation
|
| 287 |
+
bpy.context.view_layer.update()
|
| 288 |
+
|
| 289 |
+
|
| 290 |
def scene_bbox() -> tuple[Vector, Vector]:
|
| 291 |
bbox_min = (math.inf,) * 3
|
| 292 |
bbox_max = (-math.inf,) * 3
|
|
|
|
| 495 |
resolution=int(args.resolution),
|
| 496 |
samples=int(args.samples),
|
| 497 |
)
|
| 498 |
+
mesh_path = args.mesh_path.resolve()
|
| 499 |
+
load_object(mesh_path)
|
| 500 |
+
selected_up_dir = str(args.up_dir).strip()
|
| 501 |
+
if selected_up_dir:
|
| 502 |
+
root = create_render_root()
|
| 503 |
+
orient_and_normalize(
|
| 504 |
+
root,
|
| 505 |
+
up_dir=canonicalize_up_dir(selected_up_dir),
|
| 506 |
+
import_basis=import_basis_to_blender(mesh_path),
|
| 507 |
+
)
|
| 508 |
+
else:
|
| 509 |
+
normalize_scene()
|
| 510 |
init_lighting()
|
| 511 |
intrinsic = configure_camera(
|
| 512 |
camera,
|
|
|
|
| 542 |
intrinsic=intrinsic.astype(np.float32),
|
| 543 |
camera_to_world=camera_to_world.astype(np.float32),
|
| 544 |
world_to_camera=world_to_camera.astype(np.float32),
|
| 545 |
+
face_ids=orient_saved_raster_payload(face_ids, flip=not selected_up_dir).astype(np.int32),
|
| 546 |
+
hit_points=orient_saved_raster_payload(hit_points, flip=not selected_up_dir).astype(np.float32),
|
| 547 |
+
normals=orient_saved_raster_payload(normals, flip=not selected_up_dir).astype(np.float32),
|
| 548 |
+
depth=orient_saved_raster_payload(depth, flip=not selected_up_dir).astype(np.float32),
|
| 549 |
azimuth_deg=np.float32(azimuth_deg),
|
| 550 |
elevation_deg=np.float32(90.0 - float(args.pitch_deg)),
|
| 551 |
pitch_deg=np.float32(args.pitch_deg),
|
| 552 |
)
|
| 553 |
if Image is not None:
|
| 554 |
with Image.open(image_path) as image:
|
| 555 |
+
oriented = orient_saved_render_image(image, flip=not selected_up_dir)
|
| 556 |
oriented.save(image_path)
|
| 557 |
print(
|
| 558 |
f"Rendered auto-kinematics Blender view {image_id + 1}/{len(args.azimuths)} "
|