3D visionHow-to

Vertically mounted depth camera: put the rotation in the URDF joint

Set camera_link to the body convention, put the whole portrait rotation into one URDF joint, and leave the driver's optical frame transform untouched.

LumaSpec Vision10 min read Share on LinkedIn

On this page 10 sections
  1. 01The decision, in one line
  2. 02Who owns which frame
    1. camera_link: the body frame
    2. The depth optical frame: the driver's fixed quarter-turn
  3. 03The numbers: from mount to rpy
  4. 04Step by step
  5. 05What RViz should show
  6. 06Why a correct-looking cloud is not proof
  7. 07Common mistakes
  8. 08Hardware that holds the angle
  9. 09If the sensor is also on the order list
  10. 10Questions

A depth camera bolted in portrait — rolled 90° so the connector faces up, then tipped 8° toward the work — reads the room correctly and still ruins every pick if the rotation lands in the wrong frame. The fix is structural: camera_link keeps the camera's own body convention, the entire mount goes into the single URDF joint above it, and the driver-published optical frame stays untouched. Below you get the sign conventions, the exact rpy for that mount, what RViz should show when the tree is right, and the reason a point cloud that looks perfectly aligned can still hide a 90° error that MoveIt folds straight into the planning scene.

The decision, in one line

Put the whole physical mount into one fixed joint — the joint whose child is camera_link — and let camera_link keep the body convention no matter how the camera is hung. REP-103's X forward, Y left, Z up still holds; it holds for the housing, so the axes travel with the camera and camera_link's +Y points at the floor in a portrait install. That is correct, not a bug. For a 90° counterclockwise rotation with the connector up and an 8° downward tilt, the single joint carrying all of it is:

rpy="-1.5708 0.1396 0"

Everything downstream — the optical frame, the point cloud, the MoveIt depth plugin — inherits the mount through this one joint. Nothing else in the tree should rotate.

Who owns which frame

Two frames meet at the camera, and mixing up their owners is what breaks portrait mounts.

camera_link is the housing's own frame: X out of the lens, Y toward the camera's left side, Z toward its top edge, all three rotating with the camera. REP-103 describes body frames, so it follows the hardware, not the world. You own this frame, and it is defined by exactly one transform — the joint from the link your bracket bolts to. If the module also streams IMU data, those frames hang off the same body convention, one more reason to keep it honest; the IMU optical frame check covers that chain.

The depth optical frame: the driver's fixed quarter-turn

The depth data lives in an optical frame: Z forward, X right, Y down. The driver publishes the static rotation from camera_link to that frame once, for every mount, because the rotation is defined relative to the body and never changes with how you bolt the camera down. If you want the frame algebra, Transform depth point clouds between optical frame and camera frame works it out step by step.

The numbers: from mount to rpy

URDF reads rpy as fixed-axis rotation: roll about the parent's X first, then pitch about the parent's Y, then yaw about the parent's Z. Applied in that order, roll turns the camera in its cradle and pitch then tips the whole cradle about the still-horizontal parent axis — exactly how a bracket physically tilts.

radians = degrees × π / 180 90° = 90 × π / 180 = 1.5708 rad 8° = 8 × π / 180 = 0.1396 rad

Signs for this mount:

  • The connector sits at the −Y end of the body — on the module in question the vendor's own URDF model pins the plug at y = −0.053 m in camera_link. Connector up therefore means +Y points down, and that is roll = −90° = −1.5708. Positive roll would send the top edge right and the connector down.
  • The tilt is 8° down. Positive pitch tips the lens axis below horizontal, so pitch = +0.1396.
  • The bracket adds no yaw, so yaw = 0.
Mount URDF rpy
90° CCW, connector up, tilted 8° down "-1.5708 0.1396 0"
90° CCW, connector up, tilted 8° up "-1.5708 -0.1396 0"
90° CW (connector down), tilted 8° down "1.5708 0.1396 0"

Carry the axes through to check the result. The lens axis lands at (cos 0.1396, 0, −sin 0.1396) = (0.990, 0, −0.139) in the parent frame: 8° below horizontal. The connector direction lands at (0.139, 0, 0.990): up, tipped forward by the same 8°. The top edge lands at (0, 1, 0): pointing left, which is what counterclockwise looks like from behind the camera.

These are fixed-axis (extrinsic) values. A calculator that applies intrinsic XYZ returns a different mount for the same numbers; URDF's convention is the one above.

Step by step

  1. Measure the mount, not the intention. Confirm which body end carries the connector and which way the bracket tips. Roll and pitch signs come from those two observations alone.
  2. Convert to radians. Degrees × π/180 gives 1.5708 for the quarter turn and 0.1396 for 8°.
  3. Write the rotation into the joint whose child is camera_link. Keep its parent as the link the bracket actually bolts to, so the file matches the hardware:
<joint name="camera_mount" type="fixed">
  <origin xyz="0.42 0 0.31" rpy="-1.5708 0.1396 0"/>
  <parent link="tool0"/>
  <child link="camera_link"/>
</joint>

The xyz values are this example's bracket offsets; yours come from the mounting drawing.

If the camera rides a moving axis rather than a fixed bracket, the same origin discipline applies, and configuring an added prismatic axis in URDF walks a moving joint through. 4. Leave the driver alone. No second publisher for camera_link → depth optical frame, no compensating rotation, no override in the launch file. One publisher per transform; two fight and the tree flickers between them. 5. Restart and inspect the TF tree. camera_link hangs from the robot link; the optical frame hangs from camera_link exactly as the driver drew it. 6. Verify in RViz before the first pick, as the next section details. 7. Close the loop. Place an asymmetric target, read it in the cloud, and command the robot to a point taken from the cloud. Agreement should land within the few millimetres your hand-eye calibration normally achieves.

What RViz should show

Add a TF display with the fixed frame at the robot base. With rpy="-1.5708 0.1396 0":

  • camera_link: +X out of the lens, 8° below horizontal; +Y straight down; +Z horizontal, pointing left.
  • depth optical frame: +Z parallel to camera_link's +X — the invariant that must never break; +X pointing up; +Y horizontal, pointing right.

The rolled optical frame is the portrait mount showing through, and it is correct: a horizontal mount shows the same quarter-turn with +X right and +Y down instead. If the optical frame's axes do not track camera_link's through the mount, something between them rotates, and that something is the bug.

Then test with geometry the floor cannot fake. A rotation error about the optical axis leaves every floor point on the floor, so a bare table proves little. Use an asymmetric target — a bracket, a keypad, anything with a clear short side. Set it 300 mm ahead of the lens and 100 mm to its left, then read the reported coordinates: ahead 300 mm, left 100 mm. With a hidden 90° in-plane error, the same target reports as 300 mm ahead and 100 mm off vertically instead of laterally — the sideways offset swaps into height while the floor still reads perfectly level.

Why a correct-looking cloud is not proof

A cloud that sits correctly in RViz proves the chain robot → camera_link → optical frame is consistent for rendering. MoveIt consumes the transforms, not the pixels: the depth plugin attaches the cloud at whatever pose the TF chain states, and collision checking, motion planning and any servo loop read those same frames. Two failure modes survive a good-looking render:

  • Compensation in the wrong place. A rotation added between camera_link and the optical frame leaves camera_link itself wrong, and every consumer that treats it as the body frame — driver features, ROI math in camera coordinates, IMU fusion — inherits the error. The render stays pretty; the robot still misses.
  • Residual in-plane error. The swap test above exists because rendering forgives exactly that.

Where a cell servos or teleoperates against camera data, reference-frame mix-ups surface as precisely this class of bug; resolving MoveIt 2 servo reference-frame errors in ROS 2 walks one such case. The rule for portrait mounts stays short: one joint carries the mount, the driver carries the quarter-turn, and nothing else rotates.

Common mistakes

  • Pitch entered negative. Positive pitch is nose-down in URDF. An 8° down-tilt is +0.1396; −0.1396 aims the lens 8° above horizontal.
  • Degrees pasted into a radians field. 8 in a radians field means 458°, and the TF display makes it obvious at a glance.
  • Two publishers for one transform. A driver-side static publisher plus a URDF joint covering the same pair fight every cycle; delete one.
  • The mount split across two joints. Half above camera_link and half between camera_link and the optical frame breaks the Z-parallel-to-X invariant while nothing renders obviously wrong. Keep the whole rotation in one place.
  • Signing off on the floor render. The section above shows why it forgives exactly the error portrait mounts produce.

Hardware that holds the angle

A portrait mount lives or dies by repeatability: the roll and pitch only help if the bracket still holds them after a bump, a cable snag or a week of vibration. Two stages cover the job. Set the roll on a manual rotation stage and the pitch on a manual tilt stage, lock both, and re-check at commissioning:

Camera standsManual XY + rotation stage, 60 mm, dia. 60 mm base (LS-MNT-0220)LS-MNT-0220
Type
Rotary stages
$269.00View part
Camera standsSlope-angle slide, manual tilt stage (LS-MNT-0001)LS-MNT-0001
Type
CCD camera stand and positioning stage (LS-MNT-S01)
$339.00View part

For bench bring-up, before the bracket is machined, a slide-table stand holds the camera at a fixed height while you run the frame checks. The LS-MNT-0040 stands 700 mm high over a 400 x 450 mm base, enough working volume for most tabletop cells.

Camera standsMachine vision camera stand, professional slide-table type, height 700 mm, 400 x 450 mm base (LS-MNT-0040)LS-MNT-0040
Type
Camera stands
$629.00View partBuild a system with it

Browse the full camera stands range or the accessories finder. Where the camera sits relative to the scene — standoff, angle, coverage — is the subject of the 3D camera working distance and positioning guide, and the stand configurator assembles the station around the mount.

If the sensor is also on the order list

Everything above is mount-side and applies to whatever depth camera hangs there. If the camera line item is still open, a projected structured-light stereo camera such as the MV-DPS200P-02 fills the same role in a robot cell — dense depth for collision awareness and pick poses — and the frame recipe above carries over unchanged.

3D sensorsHikrobot MV-DPS200P-02 Projected structured-light 3D cameraMV-DPS200P-02
Type
Stereo / structured light
$8,829.00 · quotedView and quote

Compare the 3D sensors range in the camera finder, and before ordering run the 3D camera robot integration compatibility check so SDK, trigger and mounting interfaces match the rest of the cell. To size camera, mount and controller together, start at the vision configurator; the Learn guides hub collects the frame, calibration and picking guides around this one.

Questions

Does REP-103 still apply when the camera is mounted rotated?

Yes, but to the camera body, not the world. A body frame keeps X out of the lens, Y toward the housing's left and Z toward its top edge, and those axes rotate with the mount — in a portrait install camera_link's +Y points at the floor, which is correct. The physical rotation belongs in the single parent joint, so the convention survives any mounting angle.

What rpy do I need if the tilt is upward instead of downward?

Flip the pitch sign: rpy="-1.5708 -0.1396 0" for the same 90° counterclockwise roll with an 8° upward tilt. URDF pitch is positive nose-down, so an upward tilt is negative. Convert any other angle with degrees × π/180 — 5° is 0.0873, 10° is 0.1745.

Can I put the rotation between camera_link and the optical frame instead?

No. The driver owns that transform and publishes it as a fixed body-to-optical quarter-turn for every mount. Adding your own rotation there, or a second publisher for the same pair, breaks the invariant that the optical frame's Z stays parallel to camera_link's X, and any tool reading camera_link as the body frame inherits a false orientation.

Why do my pick points land 90° off even though the floor reads level?

A rotation error about the optical axis leaves a flat floor exactly where it was, so the render looks fine while every image-plane coordinate is rotated. A part 300 mm ahead and 100 mm left of the lens reports as 300 mm ahead and 100 mm off in height instead. Test with an asymmetric target, not a bare floor.

Does the 8° tilt change the depth numbers themselves?

Depth is measured along each pixel's ray, so the tilt changes coverage and the slant range to a target, not the TF recipe. A target 500 mm ahead horizontally sits at 500/cos 8° ≈ 505 mm slant range, about 1% more. Keep the tilt in the same joint as the roll and the recipe is unchanged.