rayli commited on
Commit
99e1338
·
verified ·
1 Parent(s): 3538f0f

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.render_mesh,
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=mesh_geometry.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
- render_mesh_path = output_dir / "_auto_kinematics_render_mesh.glb"
231
- mesh.export(render_mesh_path)
 
 
 
 
 
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
- def orient_saved_raster_payload(buffer: np.ndarray) -> np.ndarray:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  """Match the final saved image orientation for per-pixel render payloads."""
51
- oriented = np.flip(np.flip(np.asarray(buffer), axis=0), axis=1)
 
 
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
- oriented = oriented.transpose(Image.FLIP_LEFT_RIGHT)
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
- load_object(args.mesh_path.resolve())
425
- normalize_scene()
 
 
 
 
 
 
 
 
 
 
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)} "