Accurancy on robot arm

Hi @becketps

I mean that AprilTag detection uses Computer Vision processing to estimate the distance from the target, not the stereo depth information.

I recommend you read the documentation of the Isaac ROS April Tag detector to learn more about how it works.

Yes, you can, but it’s not as precise as the AprilTag detection, which is based on Computer Vision processing.

Hi,

you are totally right. I tested it today but there I got exactly your distance problem. I already tried to correct it like this with that:

But it was wrong. I got again an error of 1cm. In x and y it depends now sometimes under 8mm and till 1cm in x and y. The calibration is now ok same values with 40 images.

It varries and the tag is relative to the base also not totally constant estimated. I think it has to do with the point clound. 10mm difference also there the case in one x, y or z.

if self._depth_img is not None and self._cam_info is not None:
            try:
                tf_cam_tag = self.tf_buffer.lookup_transform(
                    'zed2i_left_camera_frame_optical', self.tag_frame,
                    rclpy.time.Time(), timeout=rclpy.duration.Duration(seconds=0.1))
                tx = tf_cam_tag.transform.translation.x
                ty = tf_cam_tag.transform.translation.y
                tz = tf_cam_tag.transform.translation.z
                fx = self._cam_info.k[0]; fy = self._cam_info.k[4]
                cx = self._cam_info.k[2]; cy = self._cam_info.k[5]
                px = int(tx / tz * fx + cx)
                py = int(ty / tz * fy + cy)
                h, w = self._depth_img.shape
                px = max(15, min(px, w - 15))
                py = max(15, min(py, h - 15))
                patch = self._depth_img[py-15:py+15, px-15:px+15]
                valid = patch[np.isfinite(patch) & (patch > 0.1) & (patch < 3.0)]
                if len(valid) > 10:
                    depth_m = float(np.median(valid))
                    z_error = depth_m - tz
                    if abs(z_error) < 0.04:
                        approach_dir = T_base_tag[:3, 2] / np.linalg.norm(T_base_tag[:3, 2])
                        T_base_tag[:3, 3] += z_error * approach_dir
                        self.get_logger().info(
                            f'Depth-Korrektur: {z_error*1000:+.1f}mm',
                            throttle_duration_sec=2.0)
                    else:
                        self.get_logger().warn(
                            f'Depth-Korrektur zu groß ({z_error*1000:+.1f}mm) – ignoriert',
                            throttle_duration_sec=2.0)
            except Exception as e:
                self.get_logger().warn(f'Depth-Korrektur fehlgeschlagen: {e}',
                                    throttle_duration_sec=5.0)

Hi @becketps,

Thanks for sharing the snippet. I think the depth correction step you added is unfortunately working against you, and that’s why the residual error is not going away.

approach_dir = T_base_tag[:3, 2] / np.linalg.norm(T_base_tag[:3, 2]) T_base_tag[:3, 3] += z_error * approach_dir

The AprilTag pose returned by Isaac ROS is a visual PnP estimate expressed in the left optical frame (REP-103, OpenCV convention).
The error you observe at 70 cm is mostly lateral (X/Y in optical frame), not range. Projecting the depth residual along the tag’s approach axis in the base frame mixes three different reference frames and ends up moving X and Y of the final pose as a side effect of a Z-only depth measurement. If you really want to fuse the ZED depth, do it as a scalar update on t_optical[2] before the chain to base, with inverse-variance weighting (PnP variance grows linearly with range, ZED depth variance grows quadratically — so depth helps mostly close-up and should be down-weighted further out). Don’t touch X and Y from depth.

Two more things worth checking before you blame the algorithm:

  1. Tag size verified with a caliper. I know you said it’s “totally 8 cm”, but please measure the black-square edge with a caliper, not the outer white border. Even at 1200 dpi, printers can scale by 0.5–1%. At 70 cm with an 8 cm tag, a 1% size error is already ~7 mm of range error and contributes to lateral error too.
  2. Pose averaging method. Averaging 90 raw quaternions arithmetically is not correct; quaternions q and -q represent the same rotation, and sign flips between consecutive samples will pull the mean toward zero. Use the Markley quaternion mean (eigenvector of Σ qq^T with the largest eigenvalue) for the rotation part, and per-axis median + MAD outlier rejection for the translation part.

For the realistic accuracy you can expect: with the factory calibration of the ZED2i, an 8 cm AprilTag rigidly mounted on a flat board, a calipered tag size, and proper SE(3) averaging, ~3 mm 1-σ lateral at 70 cm is a reasonable ceiling. If you need better, the next lever is a larger tag (10–12 cm), not a different camera or recalibration.

One last thing on the calibration frame, just to be sure: since Isaac ROS AprilTag publishes in zed2i_left_camera_optical_frame, the hand-eye calibration must have been solved against that same frame. If by mistake the runtime reads tag poses in optical frame but the hand-eye matrix was solved against zed2i_left_camera_frame (or vice-versa), you get exactly the kind of stable few-cm offset you reported, because the two frames differ by a fixed ~90°.

Let me know how it goes after these checks.

I checked that so with the tag size everything is ok. I did it like you said there but still in y direction 8mm depending on the run. X is ok and z also sometimes vary

 if self.get_parameter('depth_fusion').value \
                and self._depth_img is not None \
                and self._cam_info is not None:
            try:
                tf_cam_tag = self.tf_buffer.lookup_transform(
                    'zed2i_left_camera_frame_optical', self.tag_frame,
                    rclpy.time.Time(), timeout=rclpy.duration.Duration(seconds=0.1))

                tx = tf_cam_tag.transform.translation.x
                ty = tf_cam_tag.transform.translation.y
                tz = tf_cam_tag.transform.translation.z  # PnP-Range

                fx = self._cam_info.k[0]; fy = self._cam_info.k[4]
                cx = self._cam_info.k[2]; cy = self._cam_info.k[5]
                px = int(tx / tz * fx + cx)
                py = int(ty / tz * fy + cy)
                h, w = self._depth_img.shape
                px = max(15, min(px, w - 15))
                py = max(15, min(py, h - 15))
                patch = self._depth_img[py-15:py+15, px-15:px+15]
                valid = patch[np.isfinite(patch) & (patch > 0.1) & (patch < 3.0)]

                if len(valid) > 10:
                    depth_m = float(np.median(valid))

                    if self.get_parameter('depth_fusion_weighted').value:
                        # Inverse-Variance-Gewichtung
                        sigma_pnp   = 0.01 * tz
                        sigma_depth = 0.002 * tz**2
                        w_pnp       = 1.0 / sigma_pnp**2
                        w_depth     = 1.0 / sigma_depth**2
                        z_fused     = (w_pnp * tz + w_depth * depth_m) / (w_pnp + w_depth)
                        z_error     = z_fused - tz
                    else:
                        # Direktes Update (kein Gewichten)
                        z_error = depth_m - tz

                    if abs(z_error) < 0.04:
                        # ✅ Nur Z im optischen Frame korrigieren, X/Y bleiben unangetastet
                        tf_cam_tag.transform.translation.z += z_error

                        # Korrigiertes cam→tag in base→tag umrechnen
                        T_cam_tag_corr = tf_to_matrix(tf_cam_tag)
                        tf_base_cam = self.tf_buffer.lookup_transform(
                            self.base_frame,
                            'zed2i_left_camera_frame_optical',
                            rclpy.time.Time(),
                            timeout=rclpy.duration.Duration(seconds=0.1))
                        T_base_cam = tf_to_matrix(tf_base_cam)
                        T_base_tag = T_base_cam @ T_cam_tag_corr

                        mode = "gewichtet" if self.get_parameter('depth_fusion_weighted').value \
                            else "direkt"
                        self.get_logger().info(
                            f'Depth-Korrektur ({mode}): {z_error*1000:+.1f}mm',
                            throttle_duration_sec=2.0)
                    else:
                        self.get_logger().warn(
                            f'Depth-Korrektur zu groß ({z_error*1000:+.1f}mm) – ignoriert',
                            throttle_duration_sec=2.0)

            except Exception as e:
                self.get_logger().warn(
                    f'Depth-Korrektur fehlgeschlagen: {e}',
                    throttle_duration_sec=5.0)

Hi @becketps,

The code itself now looks right; Z is updated in the optical frame and the chain to base is rebuilt cleanly. But before tuning the fusion any further, I think you’re chasing the wrong axis.

Depth fusion cannot fix a lateral (Y) error.

The tf_cam_tag.transform.translation.z += z_error step only moves the pose along the camera optical axis. It mathematically cannot reduce a residual in optical-frame X or Y. So the ~8 mm you still see in Y is not a depth problem, and no amount of variance tuning will touch it.

The fact that Z now “sometimes varies” after enabling fusion is also expected: you’ve replaced a stable PnP range with a depth sample taken on a flat low-texture white surface, where neural stereo interpolates from surrounding context. You’ve effectively traded a small bias for stochastic noise. If PnP Z was fine before, I’d disable depth fusion, or at least make σ_depth much more realistic;0.002 * tz² (≈1 mm at 70 cm) is far too optimistic for a flat textureless target; 0.01 * tz² is closer to reality.

Where a stable Y offset at 70 cm actually comes from

In order of likelihood:

  1. Angular residual in the hand-eye matrix. Just 0.6° around the optical axis projects to ~7 mm of lateral error at 70 cm. With 40 images this is well within the noise floor of Tsai/Park/Andreff if rotation diversity was poor. Look at the angular residual reported by the solver, not the translation residual. Re-run with poses including strong rotations (≥30°) around all three TCP axes, not mostly translations. This is by far the most common cause of a stable single-axis offset.
  2. Tag origin vs. your intended target center. Isaac ROS AprilTag publishes the pose at the geometric center of the black region, not the printed sheet. If the white margins around the black border on your printout aren’t perfectly symmetric — very common on home prints — the “center” you’re aiming for and the detector’s origin differ by exactly that asymmetry. Measure the four white margins around the black square with a caliper.
  3. TCP frame definition (wpg_tcp). A constant offset between the URDF TCP and the actual physical tool point produces exactly this kind of fixed lateral bias. Worth re-checking the gripper offsets from the UR15 flange.

Clean isolation test

To separate hand-eye from PnP: fix the tag in the workspace, then visually servo the TCP in image space until the projected tag center lands exactly on (cx, cy). Measure the physical offset between TCP and the tag center with a ruler. If you get ~8 mm in the same direction as before, the issue is with calibration /TCP definition, not the detector.

Let me know what the solver reports for the angular residual and what the margin measurement on the printed tag looks like.

I have printed myself a calibration pin which I put on the robot flange. Also the Calibration on the robot tells me that it is 280mm in front of the Flange. Thats also my real TCP. Therefore the TCP is right. TCP is oriented in z direction to approch the marker and the x and y in lateral way.

One question to point 2:

Why does the white space play a role when the tag has only the origin there? I have a cut machine and can make it similar. I already did like you see.

That here is my setup. Are you sure that it makes sense to measure here anything from the TCP till the tag? The TCP ist right adjusted 280mm in front of flange

When I see the tag in the Middle where x and y counts with reference to robot ur15 base frame

  • Translation: [-0.083, -1.056, 0.641]
  • Rotation: in Quaternion (xyzw) [0.705, -0.029, -0.027, 0.709]
  • Rotation: in RPY (radian) [1.565, -0.003, -0.079]
  • Rotation: in RPY (degree) [89.687, -0.153, -4.506]
  • Matrix:
    0.997 -0.002 -0.079 -0.083
    -0.079 0.006 -0.997 -1.056
    0.003 1.000 0.005 0.641
    0.000 0.000 0.000 1.000

When I place the TCP in the Middle of the marker:

  • Translation: [-0.032, -1.075, 0.819]
  • Rotation: in Quaternion (xyzw) [0.699, 0.009, 0.012, 0.715]
  • Rotation: in RPY (radian) [1.549, -0.004, 0.031]
  • Rotation: in RPY (degree) [88.776, -0.241, 1.754]
  • Matrix:
    1.000 -0.005 0.031 -0.032
    0.031 0.021 -0.999 -1.075
    0.004 1.000 0.021 0.819
    0.000 0.000 0.000 1.000
    ^C[INFO] [1778526049.921272947] [rclcpp]: signal_handler(SIGINT/SIGTERM)

Then Second test after your Hint 1 2 3
ros2 run tf2_ros tf2_echo ur15_base wpg_tcp

When the Tag middle is in the picture

At time 1778534018.811993699

  • Translation: [-0.070, -0.927, 0.653]
  • Rotation: in Quaternion (xyzw) [0.746, -0.040, -0.045, 0.663]
  • Rotation: in RPY (radian) [1.687, 0.014, -0.121]
  • Rotation: in RPY (degree) [96.659, 0.823, -6.914]
  • Matrix:
    0.993 0.000 -0.121 -0.070
    -0.120 -0.117 -0.986 -0.927
    -0.014 0.993 -0.116 0.653
    0.000 0.000 0.000 1.000

When the TCP is in the middle on the tag:

  • Translation: [-0.031, -1.072, 0.822]
  • Rotation: in Quaternion (xyzw) [0.700, -0.009, -0.059, 0.712]
  • Rotation: in RPY (radian) [1.550, 0.069, -0.097]
  • Rotation: in RPY (degree) [88.826, 3.959, -5.563]
  • Matrix:
    0.993 0.071 -0.096 -0.031
    -0.097 0.014 -0.995 -1.072
    -0.069 0.997 0.020 0.822
    0.000 0.000 0.000 1.000
    At time 1778533885.190003079

It still now results in 8mm to the right and 2mm to high.

Here you said that the calibration has to be performed against the left optical frame. However, earlier the recommendation was to use the frame that is specified in the URDF. How do these two statements fit together?

The camera_link frame always has to be used in the URDF, because that is the physical frame where the camera is mounted to the robot. The left_camera_frame_optical cannot be assigned a separate pose in the URDF, since it is an internal frame that is already defined relative to the camera frame by the ZED driver.

So during hand-eye calibration with a ZED camera in ROS 2, the URDF joint should indeed use zed2i_camera_link as the child link, not zed2i_left_camera_frame_optical.

Why?

The ZED SDK publishes its own internal TF tree starting at zed2i_camera_link. The optical frame zed2i_left_camera_frame_optical is a child of that. If you set zed2i_left_camera_frame_optical as the URDF joint child, zed2i_camera_link becomes a second root link with no parent, which causes MoveIt to crash with:

Two root links found: [world] and [zed2i_camera_link]

Correct approach:

  1. calibrateHandEye gives you T_tcp_optical (transform from TCP to optical frame)
  2. You need to convert this to T_tcp_camera_link using the ZED-internal transform:
   T_tcp_camera_link = T_tcp_optical @ inv(T_camera_link_optical)

where T_camera_link_optical comes from:

bash

   ros2 run tf2_ros tf2_echo zed2i_camera_link zed2i_left_camera_frame_optical
  1. The resulting T_tcp_camera_link values go into the URDF:

xml

   <joint name="zed2i_calibration_joint" type="fixed">
     <parent link="wpg_tcp"/>
     <child link="zed2i_camera_link"/>
     <origin xyz="x y z" rpy="r p y"/>
   </joint>
<?xml version="1.0"?>
<robot xmlns:xacro="http://www.ros.org/wiki/xacro" name="my_gripper">

  <xacro:include filename="$(find wpg_description)/urdf/weiss_gripper.xacro"/>
  <xacro:include filename="$(find wpg_description)/urdf/zed2i_camera.xacro"/>

  <!-- Args immer auf Top-Level! -->
  <xacro:arg name="standalone"   default="false"/>
  <xacro:arg name="camera_model" default="zed2i"/>
  <xacro:arg name="tf_prefix"    default="wpg_"/>
  <xacro:arg name="quickchanger" default="true"/>
  <xacro:arg name="sensor"       default="false"/>
  <xacro:arg name="finger_kit"   default="120"/>

  <!-- MACRO Version -->
  <!-- <xacro:macro name="weiss_gripper_with_camera" params="parent_link tf_prefix camera_model:=none">
    <xacro:weiss_gripper parent_link="${parent_link}" tf_prefix="${tf_prefix}"/> -->
  <xacro:macro name="weiss_gripper_with_camera" params="
    parent_link
    tf_prefix
    camera_model:=none
    quickchanger:=true
    sensor:=false
    finger_kit:=120
  ">
     <!-- ← Das fehlte! -->
    <xacro:weiss_gripper
      parent_link="${parent_link}"
      tf_prefix="${tf_prefix}"
      quickchanger="${quickchanger}"
      sensor="${sensor}"
      finger_kit="${finger_kit}"/>
  
    <xacro:if value="${camera_model == 'zed2i'}">
      <!-- <xacro:zed2i_mount parent_link="${tf_prefix}flange_mount"/> -->
      <xacro:zed2i_mount parent_link="${tf_prefix}tcp"/> 
    </xacro:if>
  </xacro:macro>
</robot>

And here for ZED Camera

<?xml version="1.0"?>
<robot xmlns:xacro="http://www.ros.org/wiki/xacro">
<xacro:include filename="$(find zed_wrapper)/urdf/zed_macro.urdf.xacro" />

<xacro:macro name="zed2i_mount" params="parent_link">

  <xacro:property name="cam_x"     value="-0.003622"/>
  <xacro:property name="cam_y"     value="0.153387"/>
  <xacro:property name="cam_z"     value="-0.209547"/>
  <xacro:property name="cam_roll"  value="2.864207"/>
  <xacro:property name="cam_pitch" value="-1.531465"/>
  <xacro:property name="cam_yaw"   value="1.868415"/>

  <xacro:zed_camera name="zed2i" model="zed2i" enable_gnss="false" custom_baseline="0">
    <origin xyz="0 0 0" rpy="0 0 0"/>
  </xacro:zed_camera>

  <!-- Parent ist wpg_tcp – Kalibrierung wurde von wpg_tcp aus gemessen -->
  <joint name="zed2i_calibration_joint" type="fixed">
    <parent link="wpg_tcp"/>
    <!-- <child link="zed2i_left_camera_frame_optical"/> -->
    <child link="zed2i_camera_link"/>
    <origin xyz="${cam_x} ${cam_y} ${cam_z}" rpy="${cam_roll} ${cam_pitch} ${cam_yaw}"/>
  </joint>

</xacro:macro>
</robot>

Hi @becketps,
Yes, your reasoning is correct, and the two recommendations are fully consistent, they just live at different layers.

What’s actually happening

calibrateHandEye() returns a transform in whatever camera frame produced the marker observations. Since Isaac ROS AprilTag publishes poses in zed2i_left_camera_frame_optical, the OpenCV solver gives you T_tcp_optical. That’s the “physically meaningful” calibration result; it’s the one that, when chained with the AprilTag detector output, gives consistent geometry.

The URDF is a separate concern: it describes the physical mounting of the camera, and the only rigid link the ZED driver allows you to attach to is zed2i_camera_link. The driver itself publishes the internal sub-tree camera_link → left_camera_frame → left_camera_optical_frame as static transforms (defined by zed_macro.urdf.xacro). You cannot, and should not, try to override those.

So your conversion is exactly right:

T_tcp_camera_link = T_tcp_optical · inv(T_camera_link_optical)

where T_camera_link_optical is the static transform published by the ZED driver and read with:

ros2 run tf2_ros tf2_echo zed2i_camera_link zed2i_left_camera_frame_optical

That value is constant per model, for the ZED 2i it’s essentially a fixed ~−90° pitch plus a small lateral offset to the left optical center, so you only need to compute it once.

A small sanity check

After you publish your URDF with the converted T_tcp_camera_link, verify the round-trip:

ros2 run tf2_ros tf2_echo wpg_tcp zed2i_left_camera_frame_optical

That should reproduce, within numerical noise, the original T_tcp_optical returned by calibrateHandEye(). If it doesn’t, the conversion has a sign or order issue somewhere.

Closing the loop on the residual error

Coming back to your previous test (post #26), 8 mm to the right and 2 mm too high after all of this is consistent and stable in one direction, which is exactly the signature of an angular residual in the hand-eye calibration, not a frame-conversion mistake. A 0.6–0.7° error around the optical axis projects to ~8 mm at 70 cm, right in the range you’re seeing.

The question I asked in #25 that’s still open: what’s the angular residual reported by the hand-eye solver, and how diverse were the 40 calibration poses in rotation? If they were mostly translations of the TCP with similar orientations, the rotation part of the calibration is essentially underdetermined and you can have several tenths of a degree of error with a very small reprojection number. Re-running with poses that include ≥30° rotations around each TCP axis usually closes the lateral residual.

If you can share the solver’s per-axis residual breakdown (or even just the angular RMS), we can confirm this is where the remaining 8 mm comes from.

  1. Before I calibrated

ros2 run tf2_ros tf2_echo wpg_tcp zed2i_left_camera_frame_optical
[INFO] [1778619611.648021214] [tf2_echo]: Waiting for transform wpg_tcp → zed2i_left_camera_frame_optical: Invalid frame ID “wpg_tcp” passed to canTransform argument target_frame - frame does not exist
At time 0.0

  • Translation: [0.053, 0.170, -0.218]
  • Rotation: in Quaternion (xyzw) [0.000, 0.012, 1.000, -0.014]
  • Rotation: in RPY (radian) [0.024, -0.001, -3.113]
  • Rotation: in RPY (degree) [1.359, -0.074, -178.364]
  • Matrix:
    -1.000 0.029 0.001 0.053
    -0.029 -0.999 0.024 0.170
    0.001 0.024 1.000 -0.218
    0.000 0.000 0.000 1.000
  1. Residual Error After I calibrated

may function estimate

def compute_angular_residual(self, results):
    """
    Berechnet den Winkel-Residual der Hand-Eye Kalibrierung.
    Laut Support: >0.6° → lateraler Fehler von ~7mm bei 70cm
    
    Formel: für jedes Pose-Paar prüfen ob A·X = X·B gilt
    Residual = mittlere Winkelabweichung in Grad
    """
    self.get_logger().info('── Winkel-Residual Analyse ──────────────────')
    
    for method_name, T_x in results:
        R_x = T_x[:3, :3]
        t_x = T_x[:3, 3]
        
        angle_errors = []
        trans_errors = []
        
        n = len(self.robot_poses)
        for i in range(n):
            for j in range(i + 1, n):
                # A = relative Gripper-Bewegung
                T_a = np.linalg.inv(self.robot_poses[i]) @ self.robot_poses[j]
                # B = relative Kamera-Bewegung  
                T_b = np.linalg.inv(self.camera_poses[i]) @ self.camera_poses[j]

                 # ── NEU: Paare mit zu großer Rotation überspringen ──
                rel_angle = np.degrees(
                    Rotation.from_matrix(T_a[:3,:3]).magnitude())
                if rel_angle > 90.0:
                    continue
                
                # Prüfe: R_a · R_x = R_x · R_b
                lhs = T_a[:3, :3] @ R_x
                rhs = R_x @ T_b[:3, :3]
                
                # Rotationsfehler als Winkel
                R_err = lhs @ rhs.T
                # Winkel aus Rotationsmatrix
                trace = np.clip((np.trace(R_err) - 1) / 2, -1, 1)
                angle_deg = np.degrees(np.arccos(trace))
                angle_errors.append(angle_deg)
                
                # Translationsfehler
                lhs_t = T_a[:3, :3] @ t_x + T_a[:3, 3]
                rhs_t = R_x @ T_b[:3, 3] + t_x
                trans_errors.append(np.linalg.norm(lhs_t - rhs_t) * 1000)
        if not angle_errors:
            self.get_logger().warn(
                f'  [{method_name}] Keine Pose-Paare unter 90° – '
                f'zu viel Rotation in den Samples!')
            continue
        
        mean_angle = np.mean(angle_errors)
        max_angle  = np.max(angle_errors)
        mean_trans = np.mean(trans_errors)
        
        # Bewertung laut Support
        if mean_angle < 0.3:
            quality = '✓ sehr gut'
        elif mean_angle < 0.6:
            quality = '~ ok'
        else:
            quality = '✗ zu groß → Kalibrierung wiederholen!'
        
        # Lateraler Fehler bei 70cm abschätzen
        lateral_mm = np.tan(np.radians(mean_angle)) * 700
        
        self.get_logger().info(f'  [{method_name}]')
        self.get_logger().info(f'    Winkel-Residual: {mean_angle:.3f}° (max {max_angle:.3f}°)  {quality}')
        self.get_logger().info(f'    Trans-Residual:  {mean_trans:.2f}mm')
        self.get_logger().info(f'    → Lateraler Fehler bei 70cm: ~{lateral_mm:.1f}mm')
    
    self.get_logger().info('─────────────────────────────────────────────')
    
    # Warnung wenn Rotationsdiversität zu gering
    self._check_rotation_diversity()

2b

I ran another calibration with 40 samples and better rotational diversity. The translation result is again very stable and consistent with previous runs:
xyz = [-0.0064, 0.1556, -0.2072]
rpy = [2.9489, -1.5345, 1.7910] rad
Reprojection error: 0.0213px (excellent)
However the angular residual formula still outputs ~95–101° for all methods:
[Park] Angular residual: 101.322° (max 178.852°)
[Horaud] Angular residual: 101.322° (max 178.851°)
[Tsai-Lenz] Angular residual: 95.667° (max 175.183°)
[Andreff] Angular residual: 101.322° (max 178.851°)
[Daniilidis] Angular residual: 101.323° (max 178.851°)
Rotation diversity:
Max rotation between poses: 179.4°
Mean rotation between poses: 70.8°
My question: The translation converges stably across 4+ independent calibration runs to essentially the same value. The reprojection error is 0.0213px. But the angular residual formula always returns ~95–101° with max ~179°, which seems physically impossible.
Is there a bug in my formula? Or is the 179.4° max rotation between poses the actual problem — causing a 180° flip ambiguity that breaks the residual calculation entirely?

  1. After Calibration sanity check
    root@msi:/workspaces/isaac_ros-dev# ros2 run tf2_ros tf2_echo wpg_tcp zed2i_left_camera_frame_optical
    [INFO] [1778625411.897470799] [tf2_echo]: Waiting for transform wpg_tcp → zed2i_left_camera_frame_optical: Invalid frame ID “wpg_tcp” passed to canTransform argument target_frame - frame does not exist
    At time 0.0
  • Translation: [0.053, 0.172, -0.217]
  • Rotation: in Quaternion (xyzw) [-0.004, 0.018, 1.000, -0.014]
  • Rotation: in RPY (radian) [0.036, 0.007, -3.114]
  • Rotation: in RPY (degree) [2.039, 0.398, -178.419]
  • Matrix:
    -1.000 0.027 -0.008 0.053
    -0.028 -0.999 0.035 0.172
    -0.007 0.036 0.999 -0.217
    0.000 0.000 0.000 1.000

Please tell me if something is missing.

I have now in x direction to the side 6mm and in y direction 4mm difference.

Update

I found and fixed a bug in my angular residual formula. The B matrix was computed in the wrong direction:
python# Wrong (old):
T_b = np.linalg.inv(camera_poses[i]) @ camera_poses[j]

Correct (new):

T_b = camera_poses[i] @ np.linalg.inv(camera_poses[j])

After the fix, the residual now shows physically meaningful values:
[Park] Angular residual: 0.424° → estimated lateral error ~5.2mm at 70cm
[Horaud] Angular residual: 0.423° → estimated lateral error ~5.2mm
Sanity check after URDF update:
ros2 run tf2_ros tf2_echo wpg_tcp zed2i_left_camera_frame_optical
Translation: [0.055, 0.169, -0.216]
RPY (deg): [-0.027, 0.385, -178.754]
This matches the calibrated T_tcp_optical closely. The round-trip conversion is correct.
Remaining lateral error is 6mm in X and 4mm

. Is further improvement possible or is this within the expected accuracy for this setup?

Any update to that topic here?

Hi @becketps,

Good debugging on the residual formula — the B side has to be the camera-to-world motion between captures, so cam_poses[i] @ inv(cam_poses[j]) is the correct convention (i.e. T_{i←j} on the camera side, matching T_{i←j} on the gripper side that you build with inv(robot_poses[i]) @ robot_poses[j]). Now the numbers make physical sense.

Let’s read what they’re telling you.

Where you are now

  • Reprojection error 0.02 px — the intrinsics + target detection are not your bottleneck.
  • Translation converging across 4+ independent calibrations to the same value within ~0.2 mm — the solver is not underdetermined.
  • Angular residual ~0.42° — the solver is at the limit of what your input data carries in rotation.
  • Predicted lateral error from that residual: tan(0.42°) × 700 mm ≈ 5.1 mm.
  • Measured lateral error: 6 mm in X, 4 mm in Y.

Measured = predicted, within measurement noise. You are not chasing a bug anymore. You are sitting on the noise floor of the rotation half of AX = XB with this dataset. The translation half is essentially solved; the residual you see at the TCP is the angular residual projected onto a 70 cm lever arm.

That is the realistic accuracy for an OpenCV hand-eye with the ZED2i at this distance. The earlier 3 mm 1σ figure I quoted is achievable but requires pushing the rotation conditioning further — see below.

What actually lowers a 0.4° angular residual

  1. Rotation conditioning of the pose set. Your stats show max 179.4° and mean 70.8° between poses. That mean is fine, but the 179° extreme is the wrong kind of diversity — near-180° relative rotations are exactly where the axis-angle representation becomes ill-conditioned and where Park/Horaud/Daniilidis get noisy. What helps is many pose pairs with relative rotations in the 30–120° range, distributed across all three TCP axes, not a few extreme ones. A useful sanity check: bin your pose pairs by which TCP axis dominates the rotation and confirm all three bins are populated. If one axis is under-represented, the residual on the corresponding optical-frame axis is what you’ll see as lateral error at the TCP — which lines up with you seeing X and Y but not Z.

  2. Drop the near-180° pairs from the solver input, not just from the residual estimator. Right now you skip rel_angle > 90° only for evaluation. The solver itself is still seeing the 179° pairs. Re-run the calibration excluding pairs above ~120° relative rotation — you should see the angular residual drop without the translation moving meaningfully.

  3. Use Daniilidis (dual-quaternion) and compare. It solves rotation and translation jointly and is usually the most robust of the OpenCV methods when rotation diversity is good but not extreme. If Tsai-Lenz disagrees with Daniilidis by more than ~0.1°, that’s a sign the dataset is borderline; if they agree, the residual is real and is the floor for this geometry.

  4. Tag size at this working distance. PnP angular noise on a planar target falls roughly as 1/L² where L is the tag edge in pixels. Going from 8 cm to 12 cm at 70 cm cuts the angular noise of the per-image cam pose by ~2.25× and feeds a cleaner B side into the solver. This is the single biggest lever beyond pose conditioning.

  5. Check whether the residual lives in optical-frame X or Y. Decompose your hand-eye rotation residual into the three optical-frame axes (small-angle vector θ = vee(log(R_err))). Lateral error at the TCP comes almost entirely from residual rotation around the optical-frame X and Y axes (a residual around Z would show up as roll of the gripper around the approach line, not lateral offset). This tells you which rotation samples you need more of.

The honest answer to your question

For an eye-in-hand ZED2i with Isaac ROS AprilTag at 70 cm, with the chain you’ve now cleaned up, 5–7 mm lateral is the typical accuracy you’ll converge to without going to a larger target and tightly curated pose sets. 3–4 mm is reachable with a 12 cm tag and rotation-balanced sampling. Below 3 mm you stop being limited by hand-eye and start being limited by UR15 repeatability (~0.05 mm) plus mechanical compliance of the gripper/mount under the camera’s own weight — at which point the next step is not better calibration but a stiffer mount and visual servoing in the final centimeters rather than open-loop positioning from a single pose estimate.

Given your end goal (positioning the TCP on the tag center), if you can close the loop visually for the last few cm — i.e. detect the tag again from the approach pose and command a small correction — the 6 mm becomes a non-issue regardless of what the static calibration converges to.

Let me know what the residual looks like after dropping the near-180° pairs from the solver input.

Here I implemented the residuals for each rotation. Like you said Tsai and Daniilidis are almost the same.

It is strange that al the models want to have more rotation around x which we did.

[INFO] [1780071826.781087479] [handeye_calibration]: Alle Samples erfasst → Kalibriere …
[INFO] [1780071826.782440696] [handeye_calibration]: Berechne Kalibrierung mit 40 Samples …
[INFO] [1780071826.784255354] [handeye_calibration]: Reprojektionsfehler Samples: min=0.133px mean=0.179px max=0.273px
[INFO] [1780071827.074712506] [handeye_calibration]: 120°-Filter: 28/40 Samples für Solver (12 entfernt)
[INFO] [1780071827.080803292] [handeye_calibration]: ✓ Tsai-Lenz
[INFO] [1780071827.088887131] [handeye_calibration]: ✓ Park
[INFO] [1780071827.095785151] [handeye_calibration]: ✓ Horaud
[INFO] [1780071827.113262668] [handeye_calibration]: ✓ Andreff
[INFO] [1780071827.122772277] [handeye_calibration]: ✓ Daniilidis
[INFO] [1780071827.124614028] [handeye_calibration]: Tsai vs Daniilidis: 0.120° 0.41mm [:warning: Dataset borderline!]
[INFO] [1780071827.125239870] [handeye_calibration]: ── Winkel-Residual Analyse ──────────────────
[INFO] [1780071827.635219833] [handeye_calibration]: [Daniilidis]
[INFO] [1780071827.635921386] [handeye_calibration]: Winkel-Residual: 0.262° (max 0.698°) ✓ sehr gut
[INFO] [1780071827.636483511] [handeye_calibration]: Trans-Residual: 2.42mm
[INFO] [1780071827.636985716] [handeye_calibration]: → Lateraler Fehler bei 70cm: ~3.2mm
[INFO] [1780071827.638149021] [handeye_calibration]: Residual opt. Achse: X=0.167° Y=0.125° Z=0.099°
[INFO] [1780071827.638797268] [handeye_calibration]: → Mehr Rotation um TCP-X-Achse sammeln!
[INFO] [1780071827.989686631] [handeye_calibration]: [Park]
[INFO] [1780071827.990298607] [handeye_calibration]: Winkel-Residual: 0.250° (max 0.673°) ✓ sehr gut
[INFO] [1780071827.990784997] [handeye_calibration]: Trans-Residual: 2.37mm
[INFO] [1780071827.991245268] [handeye_calibration]: → Lateraler Fehler bei 70cm: ~3.1mm
[INFO] [1780071827.992260365] [handeye_calibration]: Residual opt. Achse: X=0.169° Y=0.124° Z=0.075°
[INFO] [1780071827.992844385] [handeye_calibration]: → Mehr Rotation um TCP-X-Achse sammeln!
[INFO] [1780071828.435490805] [handeye_calibration]: [Tsai-Lenz]
[INFO] [1780071828.436098015] [handeye_calibration]: Winkel-Residual: 0.275° (max 0.734°) ✓ sehr gut
[INFO] [1780071828.436589349] [handeye_calibration]: Trans-Residual: 2.50mm
[INFO] [1780071828.437050285] [handeye_calibration]: → Lateraler Fehler bei 70cm: ~3.4mm
[INFO] [1780071828.438235735] [handeye_calibration]: Residual opt. Achse: X=0.166° Y=0.143° Z=0.104°
[INFO] [1780071828.438832768] [handeye_calibration]: → Mehr Rotation um TCP-X-Achse sammeln!
[INFO] [1780071828.816335046] [handeye_calibration]: [Horaud]
[INFO] [1780071828.816800275] [handeye_calibration]: Winkel-Residual: 0.250° (max 0.673°) ✓ sehr gut
[INFO] [1780071828.817222195] [handeye_calibration]: Trans-Residual: 2.38mm
[INFO] [1780071828.817628825] [handeye_calibration]: → Lateraler Fehler bei 70cm: ~3.1mm
[INFO] [1780071828.818479509] [handeye_calibration]: Residual opt. Achse: X=0.169° Y=0.124° Z=0.075°
[INFO] [1780071828.818970075] [handeye_calibration]: → Mehr Rotation um TCP-X-Achse sammeln!
[INFO] [1780071829.485597087] [handeye_calibration]: [Andreff]
[INFO] [1780071829.485991458] [handeye_calibration]: Winkel-Residual: 0.249° (max 0.675°) ✓ sehr gut
[INFO] [1780071829.486319966] [handeye_calibration]: Trans-Residual: 2.48mm
[INFO] [1780071829.486638623] [handeye_calibration]: → Lateraler Fehler bei 70cm: ~3.0mm
[INFO] [1780071829.487281851] [handeye_calibration]: Residual opt. Achse: X=0.169° Y=0.125° Z=0.073°
[INFO] [1780071829.487647429] [handeye_calibration]: → Mehr Rotation um TCP-X-Achse sammeln!
[INFO] [1780071829.487956286] [handeye_calibration]: ─────────────────────────────────────────────
[INFO] [1780071829.506537784] [handeye_calibration]: ── Rotationsdiversität ──────────────────────
[INFO] [1780071829.506905893] [handeye_calibration]: Max. Rotation zwischen Posen: 163.2°
[INFO] [1780071829.507216245] [handeye_calibration]: Mittl. Rotation zwischen Posen: 51.6°
[WARN] [1780071829.507598506] [handeye_calibration]: ✗ Zu viel Rotation (163.2° > 150°) → 180° Flip!
→ Kalibrierung ungültig! Max. 90° zwischen Posen bleiben
[INFO] [1780071829.507904246] [handeye_calibration]: ─────────────────────────────────────────────
[INFO] [1780071829.509944755] [handeye_calibration]: ── Reprojektionsfehler ──────────────────────
[INFO] [1780071829.510309831] [handeye_calibration]: Mittelwert : 0.1786px [✓ sehr gut]
[INFO] [1780071829.510591057] [handeye_calibration]: Maximum : 0.2731px
[INFO] [1780071829.510893328] [handeye_calibration]: Richtwerte : <0.5px=sehr gut | 0.5-1.0px=ok | >1.0px=Problem
[INFO] [1780071829.511186068] [handeye_calibration]: ─────────────────────────────────────────────
[INFO] [1780071829.511579529] [handeye_calibration]: ✓ URDF-Korrektur angewendet (gecachter ZED-Transform)
[INFO] [1780071829.659029877] [handeye_calibration]: MATLAB JSON gespeichert: handeye_calibration_20260529_182349_matlab.json
[INFO] [1780071829.659593736] [handeye_calibration]: ============================================================
[INFO] [1780071829.660071947] [handeye_calibration]: GESPEICHERT: handeye_calibration_20260529_182349.yaml
[INFO] [1780071829.660687619] [handeye_calibration]:
[INFO] [1780071829.661145189] [handeye_calibration]: === ERGEBNIS (Daniilidis) – T_tool0_camera_link ===
[INFO] [1780071829.661581830] [handeye_calibration]: xyz x=-0.0050 y=0.1521 z=-0.2053
[INFO] [1780071829.662086573] [handeye_calibration]: rpy R=12.88° P=-89.70° Y=-101.34°
[INFO] [1780071829.662665267] [handeye_calibration]: Reprojektionsfehler: 0.1786px
[INFO] [1780071829.663107810] [handeye_calibration]:
[INFO] [1780071829.663534317] [handeye_calibration]: === FÜR URDF KOPIEREN ===
[INFO] [1780071829.663968302] [handeye_calibration]: xyz=“-0.004994 0.152124 -0.205333”
[INFO] [1780071829.664576436] [handeye_calibration]: rpy=“0.224836 -1.565640 -1.768739”
[INFO] [1780071829.665198701] [handeye_calibration]: ============================================================
[INFO] [1780071829.665701819] [handeye_calibration]: ZUSAMMENFASSUNG:
[INFO] [1780071829.666186050] [handeye_calibration]: Reproj: 0.179px ✓
[INFO] [1780071829.666761762] [handeye_calibration]: URDF OK: ✓
[INFO] [1780071829.667251829] [handeye_calibration]: → Nächster Schritt: Kalibrierung in URDF eintragen und visuell prüfen
[INFO] [1780071829.667729326] [handeye_calibration]: ============================================================

Our second run to recalibrate gives me
[INFO] [1780074813.393325219] [handeye_calibration]: Berechne Kalibrierung mit 40 Samples …
[INFO] [1780074813.393843447] [handeye_calibration]: Reprojektionsfehler Samples: min=0.124px mean=0.168px max=0.224px
[INFO] [1780074813.924295495] [handeye_calibration]: 120°-Filter: 33/40 Samples für Solver (7 entfernt)
[INFO] [1780074813.935010715] [handeye_calibration]: ✓ Tsai-Lenz
[INFO] [1780074813.950711630] [handeye_calibration]: ✓ Park
[INFO] [1780074813.964776949] [handeye_calibration]: ✓ Horaud
[INFO] [1780074814.009520279] [handeye_calibration]: ✓ Andreff
[INFO] [1780074814.023346894] [handeye_calibration]: ✓ Daniilidis
[INFO] [1780074814.024684740] [handeye_calibration]: Tsai vs Daniilidis: 0.242° 0.78mm [:warning: Dataset borderline!]
[INFO] [1780074814.025796389] [handeye_calibration]: ── Winkel-Residual Analyse ──────────────────
[INFO] [1780074814.506537168] [handeye_calibration]: [Daniilidis]
[INFO] [1780074814.507107584] [handeye_calibration]: Winkel-Residual: 0.331° (max 0.781°) ~ ok
[INFO] [1780074814.507608834] [handeye_calibration]: Trans-Residual: 3.37mm
[INFO] [1780074814.508107114] [handeye_calibration]: → Lateraler Fehler bei 70cm: ~4.0mm
[INFO] [1780074814.509110673] [handeye_calibration]: Residual opt. Achse: X=0.204° Y=0.191° Z=0.095°
[INFO] [1780074814.509726807] [handeye_calibration]: → Mehr Rotation um TCP-X-Achse sammeln!
[INFO] [1780074815.093236557] [handeye_calibration]: [Park]
[INFO] [1780074815.093664487] [handeye_calibration]: Winkel-Residual: 0.320° (max 0.824°) ~ ok
[INFO] [1780074815.094088811] [handeye_calibration]: Trans-Residual: 3.27mm
[INFO] [1780074815.094463733] [handeye_calibration]: → Lateraler Fehler bei 70cm: ~3.9mm
[INFO] [1780074815.095241128] [handeye_calibration]: Residual opt. Achse: X=0.203° Y=0.186° Z=0.080°
[INFO] [1780074815.095717623] [handeye_calibration]: → Mehr Rotation um TCP-X-Achse sammeln!
[INFO] [1780074815.467150617] [handeye_calibration]: [Tsai-Lenz]
[INFO] [1780074815.467603380] [handeye_calibration]: Winkel-Residual: 0.348° (max 0.842°) ~ ok
[INFO] [1780074815.467954986] [handeye_calibration]: Trans-Residual: 3.59mm
[INFO] [1780074815.468312286] [handeye_calibration]: → Lateraler Fehler bei 70cm: ~4.3mm
[INFO] [1780074815.469021712] [handeye_calibration]: Residual opt. Achse: X=0.196° Y=0.220° Z=0.091°
[INFO] [1780074815.469566577] [handeye_calibration]: → Mehr Rotation um TCP-Y-Achse sammeln!
[INFO] [1780074816.030125563] [handeye_calibration]: [Horaud]
[INFO] [1780074816.030712158] [handeye_calibration]: Winkel-Residual: 0.320° (max 0.825°) ~ ok
[INFO] [1780074816.031217999] [handeye_calibration]: Trans-Residual: 3.27mm
[INFO] [1780074816.031707162] [handeye_calibration]: → Lateraler Fehler bei 70cm: ~3.9mm
[INFO] [1780074816.032927204] [handeye_calibration]: Residual opt. Achse: X=0.203° Y=0.186° Z=0.080°
[INFO] [1780074816.033629609] [handeye_calibration]: → Mehr Rotation um TCP-X-Achse sammeln!
[INFO] [1780074816.489616940] [handeye_calibration]: [Andreff]
[INFO] [1780074816.490728465] [handeye_calibration]: Winkel-Residual: 0.319° (max 0.828°) ~ ok
[INFO] [1780074816.491505316] [handeye_calibration]: Trans-Residual: 3.26mm
[INFO] [1780074816.492352991] [handeye_calibration]: → Lateraler Fehler bei 70cm: ~3.9mm
[INFO] [1780074816.493814381] [handeye_calibration]: Residual opt. Achse: X=0.203° Y=0.186° Z=0.079°
[INFO] [1780074816.494774531] [handeye_calibration]: → Mehr Rotation um TCP-X-Achse sammeln!
[INFO] [1780074816.495481885] [handeye_calibration]: ─────────────────────────────────────────────
[INFO] [1780074816.528314053] [handeye_calibration]: ── Rotationsdiversität ──────────────────────
[INFO] [1780074816.529270270] [handeye_calibration]: Max. Rotation zwischen Posen: 148.1°
[INFO] [1780074816.530025144] [handeye_calibration]: Mittl. Rotation zwischen Posen: 50.3°
[INFO] [1780074816.530739229] [handeye_calibration]: ✓ Rotationsdiversität ausreichend
[INFO] [1780074816.531431370] [handeye_calibration]: ─────────────────────────────────────────────
[INFO] [1780074816.535179345] [handeye_calibration]: ── Reprojektionsfehler ──────────────────────
[INFO] [1780074816.535936155] [handeye_calibration]: Mittelwert : 0.1675px [✓ sehr gut]
[INFO] [1780074816.536567110] [handeye_calibration]: Maximum : 0.2237px
[INFO] [1780074816.537161657] [handeye_calibration]: Richtwerte : <0.5px=sehr gut | 0.5-1.0px=ok | >1.0px=Problem
[INFO] [1780074816.537732365] [handeye_calibration]: ─────────────────────────────────────────────
[INFO] [1780074816.539023759] [handeye_calibration]: ✓ URDF-Korrektur angewendet (gecachter ZED-Transform)
[INFO] [1780074816.668937597] [handeye_calibration]: MATLAB JSON gespeichert: handeye_calibration_20260529_191336_matlab.json
[INFO] [1780074816.670067998] [handeye_calibration]: ============================================================
[INFO] [1780074816.670927246] [handeye_calibration]: GESPEICHERT: handeye_calibration_20260529_191336.yaml
[INFO] [1780074816.671651783] [handeye_calibration]:
[INFO] [1780074816.672199030] [handeye_calibration]: === ERGEBNIS (Daniilidis) – T_tool0_camera_link ===
[INFO] [1780074816.672698317] [handeye_calibration]: xyz x=-0.0044 y=0.1520 z=-0.2044
[INFO] [1780074816.673205392] [handeye_calibration]: rpy R=13.09° P=-89.75° Y=-101.54°
[INFO] [1780074816.673675005] [handeye_calibration]: Reprojektionsfehler: 0.1675px
[INFO] [1780074816.674152803] [handeye_calibration]:
[INFO] [1780074816.674622558] [handeye_calibration]: === FÜR URDF KOPIEREN ===
[INFO] [1780074816.675120847] [handeye_calibration]: xyz=“-0.004382 0.152044 -0.204369”
[INFO] [1780074816.675632920] [handeye_calibration]: rpy=“0.228380 -1.566371 -1.772147”
[INFO] [1780074816.676185605] [handeye_calibration]: ============================================================
[INFO] [1780074816.676661797] [handeye_calibration]: ZUSAMMENFASSUNG:
[INFO] [1780074816.677131612] [handeye_calibration]: Reproj: 0.168px ✓
[INFO] [1780074816.677592958] [handeye_calibration]: URDF OK: ✓

is there something else you can think of to optimize? It is always an angular shift around x axis which matters I think. The rotation is the problem I think

I tested it with a praofessional board but still have the same errors as before.

Is that the max precision we can expect?

[INFO] [1780474897.872106450] [handeye_calibration]: Alle Samples erfasst → Kalibriere …
[INFO] [1780474897.872600420] [handeye_calibration]: Berechne Kalibrierung mit 40 Samples …
[INFO] [1780474897.873328578] [handeye_calibration]: Reprojektionsfehler Samples: min=0.095px mean=0.156px max=0.317px
[INFO] [1780474898.085725889] [handeye_calibration]: 120°-Filter: 34/40 Samples für Solver (6 entfernt)
[INFO] [1780474898.093477554] [handeye_calibration]: ✓ Tsai-Lenz
[INFO] [1780474898.103270232] [handeye_calibration]: ✓ Park
[INFO] [1780474898.118817764] [handeye_calibration]: ✓ Horaud
[INFO] [1780474898.174166516] [handeye_calibration]: ✓ Andreff
[INFO] [1780474898.187316727] [handeye_calibration]: ✓ Daniilidis
[INFO] [1780474898.188812529] [handeye_calibration]: Tsai vs Daniilidis: 40.458° 138.59mm [:warning: Dataset borderline!]
[INFO] [1780474898.189438470] [handeye_calibration]: ── Winkel-Residual Analyse ──────────────────
[INFO] [1780474898.511316682] [handeye_calibration]: [Daniilidis]
[INFO] [1780474898.511721625] [handeye_calibration]: Winkel-Residual: 3.493° (max 37.868°) ✗ zu groß → Kalibrierung wiederholen!
[INFO] [1780474898.512056396] [handeye_calibration]: Trans-Residual: 30.06mm
[INFO] [1780474898.512379382] [handeye_calibration]: → Lateraler Fehler bei 70cm: ~42.7mm
[INFO] [1780474898.513053796] [handeye_calibration]: Residual opt. Achse: X=1.846° Y=1.255° Z=2.123°
[INFO] [1780474898.513566115] [handeye_calibration]: → Mehr Rotation um TCP-Z-Achse sammeln!
[INFO] [1780474899.132148290] [handeye_calibration]: [Park]
[INFO] [1780474899.132817628] [handeye_calibration]: Winkel-Residual: 2.693° (max 37.112°) ✗ zu groß → Kalibrierung wiederholen!
[INFO] [1780474899.133348972] [handeye_calibration]: Trans-Residual: 23.04mm
[INFO] [1780474899.134092468] [handeye_calibration]: → Lateraler Fehler bei 70cm: ~32.9mm
[INFO] [1780474899.135137221] [handeye_calibration]: Residual opt. Achse: X=1.336° Y=1.078° Z=1.679°
[INFO] [1780474899.136051472] [handeye_calibration]: → Mehr Rotation um TCP-Z-Achse sammeln!
[INFO] [1780474899.897106114] [handeye_calibration]: [Tsai-Lenz]
[INFO] [1780474899.898051935] [handeye_calibration]: Winkel-Residual: 20.929° (max 49.525°) ✗ zu groß → Kalibrierung wiederholen!
[INFO] [1780474899.898644344] [handeye_calibration]: Trans-Residual: 222.65mm
[INFO] [1780474899.899207134] [handeye_calibration]: → Lateraler Fehler bei 70cm: ~267.7mm
[INFO] [1780474899.900788258] [handeye_calibration]: Residual opt. Achse: X=10.583° Y=13.656° Z=6.832°
[INFO] [1780474899.901740116] [handeye_calibration]: → Mehr Rotation um TCP-Y-Achse sammeln!
[INFO] [1780474900.590461585] [handeye_calibration]: [Horaud]
[INFO] [1780474900.591090903] [handeye_calibration]: Winkel-Residual: 2.669° (max 37.095°) ✗ zu groß → Kalibrierung wiederholen!
[INFO] [1780474900.591595950] [handeye_calibration]: Trans-Residual: 22.88mm
[INFO] [1780474900.592066050] [handeye_calibration]: → Lateraler Fehler bei 70cm: ~32.6mm
[INFO] [1780474900.593032630] [handeye_calibration]: Residual opt. Achse: X=1.324° Y=1.066° Z=1.667°
[INFO] [1780474900.593847953] [handeye_calibration]: → Mehr Rotation um TCP-Z-Achse sammeln!
[INFO] [1780474901.260752203] [handeye_calibration]: [Andreff]
[INFO] [1780474901.261446183] [handeye_calibration]: Winkel-Residual: 2.448° (max 36.983°) ✗ zu groß → Kalibrierung wiederholen!
[INFO] [1780474901.261996811] [handeye_calibration]: Trans-Residual: 133.54mm
[INFO] [1780474901.262515744] [handeye_calibration]: → Lateraler Fehler bei 70cm: ~29.9mm
[INFO] [1780474901.263581345] [handeye_calibration]: Residual opt. Achse: X=1.205° Y=0.964° Z=1.571°
[INFO] [1780474901.264216559] [handeye_calibration]: → Mehr Rotation um TCP-Z-Achse sammeln!
[INFO] [1780474901.264783024] [handeye_calibration]: ─────────────────────────────────────────────
[INFO] [1780474901.305112393] [handeye_calibration]: ── Rotationsdiversität ──────────────────────
[INFO] [1780474901.305777284] [handeye_calibration]: Max. Rotation zwischen Posen: 148.7°
[INFO] [1780474901.306334364] [handeye_calibration]: Mittl. Rotation zwischen Posen: 49.2°
[INFO] [1780474901.306858348] [handeye_calibration]: ✓ Rotationsdiversität ausreichend
[INFO] [1780474901.307359962] [handeye_calibration]: ─────────────────────────────────────────────
[INFO] [1780474901.311361265] [handeye_calibration]: ── Reprojektionsfehler ──────────────────────
[INFO] [1780474901.312292860] [handeye_calibration]: Mittelwert : 0.1555px [✓ sehr gut]
[INFO] [1780474901.312846878] [handeye_calibration]: Maximum : 0.3165px
[INFO] [1780474901.313367420] [handeye_calibration]: Richtwerte : <0.5px=sehr gut | 0.5-1.0px=ok | >1.0px=Problem
[INFO] [1780474901.313904258] [handeye_calibration]: ─────────────────────────────────────────────
[INFO] [1780474901.314858695] [handeye_calibration]: ✓ URDF-Korrektur angewendet (gecachter ZED-Transform)
[INFO] [1780474901.467829790] [handeye_calibration]: MATLAB JSON gespeichert: handeye_calibration_20260603_102141_matlab.json
[INFO] [1780474901.468514008] [handeye_calibration]: ============================================================
[INFO] [1780474901.469064742] [handeye_calibration]: GESPEICHERT: handeye_calibration_20260603_102141.yaml
[INFO] [1780474901.469794997] [handeye_calibration]:
[INFO] [1780474901.470316184] [handeye_calibration]: === ERGEBNIS (Daniilidis) – T_tool0_camera_link ===
[INFO] [1780474901.470795301] [handeye_calibration]: xyz x=-0.0152 y=0.1638 z=-0.2055
[INFO] [1780474901.471274424] [handeye_calibration]: rpy R=47.65° P=-87.53° Y=-135.72°
[INFO] [1780474901.472001272] [handeye_calibration]: Reprojektionsfehler: 0.1555px
[INFO] [1780474901.472487889] [handeye_calibration]:
[INFO] [1780474901.472964282] [handeye_calibration]: === FÜR URDF KOPIEREN ===
[INFO] [1780474901.473682026] [handeye_calibration]: xyz=“-0.015213 0.163820 -0.205465”
[INFO] [1780474901.474211653] [handeye_calibration]: rpy=“0.831705 -1.527714 -2.368781”
[INFO] [1780474901.474681270] [handeye_calibration]: ============================================================
[INFO] [1780474901.475166960] [handeye_calibration]: ZUSAMMENFASSUNG:
[INFO] [1780474901.476020187] [handeye_calibration]: Reproj: 0.156px ✓
[INFO] [1780474901.476523944] [handeye_calibration]: URDF OK: ✓
[INFO] [1780474901.476995125] [handeye_calibration]: → Nächster Schritt: Kalibrierung in URDF eintragen und visuell prüfen
[INFO] [1780474901.477744059] [handeye_calibration]: ============================================================

px
[INFO] [1780502301.272889142] [handeye_calibration]: Alle Samples erfasst → Kalibriere …
[INFO] [1780502301.273436473] [handeye_calibration]: Berechne Kalibrierung mit 40 Samples …
[INFO] [1780502301.274241171] [handeye_calibration]: Reprojektionsfehler Samples: min=0.089px mean=0.137px max=0.239px
[INFO] [1780502301.630989980] [handeye_calibration]: 120°-Filter: 35/40 Samples für Solver (5 entfernt)
[INFO] [1780502301.653026631] [handeye_calibration]: ✓ Tsai-Lenz
[INFO] [1780502301.669153929] [handeye_calibration]: ✓ Park
[INFO] [1780502301.681935716] [handeye_calibration]: ✓ Horaud
[INFO] [1780502301.713581422] [handeye_calibration]: ✓ Andreff
[INFO] [1780502301.728479243] [handeye_calibration]: ✓ Daniilidis
[INFO] [1780502301.729897339] [handeye_calibration]: Tsai vs Daniilidis: 73.259° 278.05mm [:warning: Dataset borderline!]
[INFO] [1780502301.730604201] [handeye_calibration]: ── Winkel-Residual Analyse ──────────────────
[INFO] [1780502302.275975534] [handeye_calibration]: [Daniilidis]
[INFO] [1780502302.276901121] [handeye_calibration]: Winkel-Residual: 15.236° (max 96.861°) ✗ zu groß → Kalibrierung wiederholen!
[INFO] [1780502302.277664729] [handeye_calibration]: Trans-Residual: 92.83mm
[INFO] [1780502302.278348004] [handeye_calibration]: → Lateraler Fehler bei 70cm: ~190.7mm
[INFO] [1780502302.279744880] [handeye_calibration]: Residual opt. Achse: X=5.162° Y=8.757° Z=9.529°
[INFO] [1780502302.280602185] [handeye_calibration]: → Mehr Rotation um TCP-Z-Achse sammeln!
[INFO] [1780502303.264381152] [handeye_calibration]: [Park]
[INFO] [1780502303.265048107] [handeye_calibration]: Winkel-Residual: 17.330° (max 89.083°) ✗ zu groß → Kalibrierung wiederholen!
[INFO] [1780502303.266188491] [handeye_calibration]: Trans-Residual: 104.98mm
[INFO] [1780502303.266927163] [handeye_calibration]: → Lateraler Fehler bei 70cm: ~218.4mm
[INFO] [1780502303.268110324] [handeye_calibration]: Residual opt. Achse: X=6.984° Y=9.460° Z=9.793°
[INFO] [1780502303.269038144] [handeye_calibration]: → Mehr Rotation um TCP-Z-Achse sammeln!
[INFO] [1780502303.783109005] [handeye_calibration]: [Tsai-Lenz]
[INFO] [1780502303.784435559] [handeye_calibration]: Winkel-Residual: 40.279° (max 86.894°) ✗ zu groß → Kalibrierung wiederholen!
[INFO] [1780502303.785343566] [handeye_calibration]: Trans-Residual: 298.81mm
[INFO] [1780502303.786267154] [handeye_calibration]: → Lateraler Fehler bei 70cm: ~593.2mm
[INFO] [1780502303.788142326] [handeye_calibration]: Residual opt. Achse: X=18.397° Y=22.296° Z=19.637°
[INFO] [1780502303.789307565] [handeye_calibration]: → Mehr Rotation um TCP-Y-Achse sammeln!
[INFO] [1780502304.249212969] [handeye_calibration]: [Horaud]
[INFO] [1780502304.253446818] [handeye_calibration]: Winkel-Residual: 17.309° (max 89.145°) ✗ zu groß → Kalibrierung wiederholen!
[INFO] [1780502304.254185376] [handeye_calibration]: Trans-Residual: 104.78mm
[INFO] [1780502304.256255499] [handeye_calibration]: → Lateraler Fehler bei 70cm: ~218.2mm
[INFO] [1780502304.257579734] [handeye_calibration]: Residual opt. Achse: X=6.970° Y=9.447° Z=9.792°
[INFO] [1780502304.261541020] [handeye_calibration]: → Mehr Rotation um TCP-Z-Achse sammeln!
[INFO] [1780502304.778570991] [handeye_calibration]: [Andreff]
[INFO] [1780502304.779812446] [handeye_calibration]: Winkel-Residual: 15.590° (max 93.073°) ✗ zu groß → Kalibrierung wiederholen!
[INFO] [1780502304.780716174] [handeye_calibration]: Trans-Residual: 224.05mm
[INFO] [1780502304.781414061] [handeye_calibration]: → Lateraler Fehler bei 70cm: ~195.3mm
[INFO] [1780502304.782701507] [handeye_calibration]: Residual opt. Achse: X=5.663° Y=8.978° Z=9.045°
[INFO] [1780502304.783485791] [handeye_calibration]: → Mehr Rotation um TCP-Z-Achse sammeln!
[INFO] [1780502304.784096138] [handeye_calibration]: ─────────────────────────────────────────────
[INFO] [1780502304.842969886] [handeye_calibration]: ── Rotationsdiversität ──────────────────────
[INFO] [1780502304.845754611] [handeye_calibration]: Max. Rotation zwischen Posen: 132.6°
[INFO] [1780502304.850195482] [handeye_calibration]: Mittl. Rotation zwischen Posen: 50.4°
[INFO] [1780502304.851285369] [handeye_calibration]: ✓ Rotationsdiversität ausreichend
[INFO] [1780502304.852746931] [handeye_calibration]: ─────────────────────────────────────────────
[INFO] [1780502304.867480898] [handeye_calibration]: ── Reprojektionsfehler ──────────────────────
[INFO] [1780502304.869043787] [handeye_calibration]: Mittelwert : 0.1369px [✓ sehr gut]
[INFO] [1780502304.874309557] [handeye_calibration]: Maximum : 0.2388px
[INFO] [1780502304.875384009] [handeye_calibration]: Richtwerte : <0.5px=sehr gut | 0.5-1.0px=ok | >1.0px=Problem
[INFO] [1780502304.876100234] [handeye_calibration]: ─────────────────────────────────────────────
[INFO] [1780502304.877200517] [handeye_calibration]: ✓ URDF-Korrektur angewendet (gecachter ZED-Transform)
[INFO] [1780502305.052382916] [handeye_calibration]: MATLAB JSON gespeichert: handeye_calibration_20260603_175824_matlab.json
[INFO] [1780502305.053644588] [handeye_calibration]: ============================================================
[INFO] [1780502305.054544524] [handeye_calibration]: GESPEICHERT: handeye_calibration_20260603_175824.yaml
[INFO] [1780502305.055203239] [handeye_calibration]:
[INFO] [1780502305.055802741] [handeye_calibration]: === ERGEBNIS (Daniilidis) – T_tool0_camera_link ===
[INFO] [1780502305.056400746] [handeye_calibration]: xyz x=0.0141 y=0.1502 z=-0.1874
[INFO] [1780502305.056982174] [handeye_calibration]: rpy R=37.28° P=-85.61° Y=-120.81°
[INFO] [1780502305.057505521] [handeye_calibration]: Reprojektionsfehler: 0.1369px
[INFO] [1780502305.059827497] [handeye_calibration]:
[INFO] [1780502305.060648657] [handeye_calibration]: === FÜR URDF KOPIEREN ===
[INFO] [1780502305.061425904] [handeye_calibration]: xyz=“0.014083 0.150174 -0.187373”
[INFO] [1780502305.062200496] [handeye_calibration]: rpy=“0.650610 -1.494138 -2.108468”
[INFO] [1780502305.062997863] [handeye_calibration]: ============================================================
[INFO] [1780502305.064063978] [handeye_calibration]: ZUSAMMENFASSUNG:
[INFO] [1780502305.065779887] [handeye_calibration]: Reproj: 0.137px ✓
[INFO] [1780502305.066419041] [handeye_calibration]: URDF OK: ✓
[INFO] [1780502305.067022398] [handeye_calibration]: → Nächster Schritt: Kalibrierung in URDF eintragen und visuell prüfen
[INFO] [1780502305.067611880] [handeye_calibration]: ============================================================