Answers #39. A photoreal
Kubric (Blender) implementation of
RendererBackend, a same-Protocol open/closed swap of PyrenderBackend. As with
every renderer here, pixels are not the contract: this backend never feeds the
manifest and is never run in CI.
The design split is deliberate:
multicam_sim.dsl.kubric_spec— pure and Blender-free. It does all the coordinate math and returns a typedKubricSceneSpec(pydantic). This is what the tests exercise, on a plain CPU box, with no Kubric or Blender.multicam_sim.dsl.kubric_backend— the thin adapter that feeds that spec tokb.*and renders.kubricis imported lazily, so importing the module (or constructingKubricBackend) never requires Blender.
Our convention is OpenCV pinhole, right-down-forward (RDF), world Z-up (see
DESIGN.md). Kubric/Blender's world is also right-handed and Z-up, so world
points copy across unchanged. Only the camera basis differs.
| our OpenCV RDF camera | Blender/Kubric camera | |
|---|---|---|
| looks along | +Z (forward) | -Z |
| +X | right | right |
| +Y | down | up |
Our R maps world→camera with rows [right, down, forward], and the centre is
C = -Rᵀ @ t. The camera-to-world rotation Blender wants therefore has columns
[right, -down, -forward]:
R_c2w = Rᵀ @ diag(1, -1, -1)
position = C = camera.centre()
quaternion = quat(R_c2w) # Kubric order (w, x, y, z)
diag(1, -1, -1) has determinant +1 here (because [right, down, forward]
is right-handed: right × down = forward), so R_c2w is a proper rotation and
converts cleanly to a unit quaternion. This is the identical OpenCV→OpenGL pose
flip the already-tested pyrender backend uses
(render.py: pose[:3,:3] = cam.rotation().T @ flip, pose[:3,3] = cam.centre()),
so the two independent backends agree by construction — that shared flip is our
correctness anchor.
Kubric's quaternion order is (w, x, y, z) (verified from
kubric/core/objects.py: "a (W, X, Y, Z) quaternion for describing the
rotation"), not scipy's (x, y, z, w). kubric_spec emits (w, x, y, z).
kb.PerspectiveCamera is parameterised by focal_length and sensor_width
(mm). From kubric/core/cameras.py:
sensor_height = sensor_width * res_y / res_x
f_x[px] = focal_length / sensor_width * width
f_y[px] = focal_length / sensor_height * height == f_x[px]
p_x, p_y = width/2, height/2 # principal point pinned at centre
Two consequences, guarded in camera_to_kubric_spec (it raises ValueError
otherwise):
fx == fy(square pixels).f_yreduces tof_x, so Kubric cannot represent non-square pixels.Intrinsics.from_focal/from_fov(withoutfov_y_deg) always satisfy this; a custom rig withfx != fyis rejected.- Centred principal point (
cx == W/2,cy == H/2).shift_x/shift_yexist but Kubric's own source says they are "currently not supported", so an off-centre principal point is rejected rather than rendered wrong.
The forward map is then just:
focal_length = fx * sensor_width / width
sensor_width is a free constant — it cancels in the f_x round-trip, so any
positive value gives identical pixels. We keep Kubric's default 36.0 mm.
tests/test_kubric_backend.py runs on a plain box and asserts the translation,
never the renderer:
- Projection round-trip within
1e-6. A known off-axis, off-centre world point is projected two ways and compared: (a) our analyticP = K[R|t]viaCamera.project, and (b) through Kubric's own parameterisation — rebuildfx = focal_length/sensor_width·width, turn the(w, x, y, z)quaternion back intoR_c2w, decomposepoint − positioninto (right, up, back), and apply the Blender sign flipsu = cx − fx·a/c,v = cy + fy·b/c. Going through mm + quaternion (not a verbatimK) verifies the two pieces checkable on a CPU: the mm formula inverts (fx = focal_length/sensor_width·width, from Kubric's source) and thediag(1,-1,-1)flip is sign-consistent with the projection. (Unverified without Blender: that Kubric consumes the(w, x, y, z)quaternion as camera→world and projects with these signs — see the boundary section below.) - Object mapping. Each entity named point present at the frame becomes one sphere at its GT xyz with a stable per-entity colour; a 1-point object and a multi-joint (pose-shaped) entity lower identically (one sphere per point).
- Guards.
fx != fyand off-centre principal points raise. - Isolation. Importing the backend / building a spec does not import
kubric.
Kubric cannot pip install cleanly: it needs Blender's bundled Python and
native libraries. The pip install multicam-sim[kubric] extra only pulls the
Python package; it will not render on its own. Use the maintained image:
docker run --rm -v "$PWD:/work" -w /work kubricdockerhub/kubruntu \
python3 - <<'PY'
from multicam_sim.dsl import CameraRig, Path, SceneBuilder
from multicam_sim.dsl.kubric_backend import KubricBackend
scene = (
SceneBuilder(fps=30.0, num_frames=11)
.cameras(CameraRig.ring(n=3, radius=4.0, height=1.5,
look_at=(0.0, 0.0, 0.5),
focal=800.0, width=640, height_px=480))
.entity("obj", Path.linear((0.0, -0.6, 0.5), (0.0, 0.6, 0.5)))
.build()
)
rgb = KubricBackend(point_radius=0.1).render(scene, camera_id=0, frame=5)
print("rendered", rgb.shape, rgb.dtype) # (480, 640, 3) uint8
PY(Install multicam-sim into the image first, e.g. mount the repo and
pip install -e ..)
Kubric renders far more than RGB — its passes include object coordinates,
segmentation, and depth ("z" in camera space). Because the camera we build
projects identically to P = K[R|t] (that is exactly what the 1e-6
round-trip test proves), the Kubric ground truth lines up with the manifest with
no fudge factor:
- Positions / segmentation. Project a manifest point's
xyz_gtwithCamera.projectand read the Kubric segmentation/object-coordinate pass at that pixel — same object, sameuv. A mismatch is a real bug in the scene, not a convention drift. - Depth ↔
visible. The Kubric depth pass is camera-spacez, the same quantityCamera.projectreturns asw. Where a nearer surface sits in front of a manifest point, the rendered depth is smaller than the analytic range — independently reproducing the analyticvisible=False, from pixels, exactly asdocs/renderer-eval.mddemonstrates for the pyrender depth backend.
This stays additive: per DESIGN.md "Three per-camera fields, kept distinct",
in_view/visible/occ_frac remain analytic and GL-free. scene_to_kubric_spec
does not consult them — it renders every point present at the frame — so the
Kubric image is an independent second opinion, never an input to the mask.
Everything in kubric_spec (the coordinate math, guards, object mapping) is
verified locally. The one thing that cannot be tested without the docker
image is that kb.PerspectiveCamera / kb.Sphere / the Blender renderer
consume these spec fields as expected and emit a frame — i.e. an actual pixel
render. The spec's field names and the intrinsic/quaternion model were taken from
Kubric's source (kubric/core/cameras.py, kubric/core/objects.py), but the live
render path is exercisable only inside kubricdockerhub/kubruntu.