roostField / Lab
Curriculum
Phase 02Lesson 4 of 8
80 min
Build a classical oracleWeeks 3–6

Camera models, calibration, and hand-eye

The camera is the only sensor tying the robot's frames to the world's, and it lies in well-characterized ways. Derive the pinhole model, calibrate intrinsics and hand-eye, and build the millimeter error budget that decides whether a grasp can succeed at all.

After this lesson you can
  • Derive the pinhole projection equation and explain what each entry of K means physically for a RealSense-class camera.
  • Predict stereo depth noise from baseline, focal length, and distance, and confirm the Z-squared law on a real sensor.
  • Run intrinsic and hand-eye calibration workflows and judge results by reprojection error, coverage, and physical validation — not solver convergence.
  • Construct a millimeter-level error budget from pixel, depth, and calibration errors at the gripper, reporting bias and noise separately.

Three lessons of kinematics gave you an arm that can put its gripper anywhere in its workspace to a few millimeters — entirely in its own base frame. Nothing in that math knows where the object is. The camera is the only instrument connecting the robot's coordinates to the world's, and it is a strange one: a projective encoder that destroys a dimension on capture and must be fitted, not trusted. The π₀-class policies you will train later in the OpenPI stack consume raw pixels and learn this geometry implicitly — precisely why they cannot tell you why a grasp missed. The classical oracle this phase builds needs the geometry explicit: a calibrated map from pixel (u,v)(u, v) plus depth to a metric point in the base frame, with error bars on every term.

The pinhole model: a projective encoder with four load-bearing numbers

Strip the lens away and a camera is a box with a hole. Put the pinhole at the origin of the camera frame, point the zz-axis out through the hole, and place the image plane at distance ff — the focal length — in front of it. A point P=(X,Y,Z)P = (X, Y, Z) in camera coordinates projects along the straight ray through the pinhole, and similar triangles do the rest:

xf=XZ,yf=YZx=fXZ,y=fYZ\frac{x}{f} = \frac{X}{Z}, \qquad \frac{y}{f} = \frac{Y}{Z} \quad\Longrightarrow\quad x = f\,\frac{X}{Z}, \qquad y = f\,\frac{Y}{Z}
Perspective projection by similar triangles: image coordinates are metric coordinates divided by depth. Everything else is bookkeeping around this division.

The sensor samples that plane on a pixel grid, turning metric image coordinates into pixels through four numbers: fxf_x and fyf_y are the focal length divided by the pixel pitch in each direction — units of pixels — and (cx,cy)(c_x, c_y) is the principal point, where the optical axis actually pierces the sensor. On a RealSense-class module, expect fx447f_x \approx 447 px for the 848×480 depth stream (87° horizontal FOV) and roughly 910 px for the 1280×720 color stream. The principal point sits near the image center but never exactly on it — a calibration returning exactly the center is a sign nothing was actually estimated.

s(uv1)=(fx0cx0fycy001)K(XYZ)u=fxXZ+cx,v=fyYZ+cys\begin{pmatrix} u \\ v \\ 1 \end{pmatrix} = \underbrace{\begin{pmatrix} f_x & 0 & c_x \\ 0 & f_y & c_y \\ 0 & 0 & 1 \end{pmatrix}}_{K} \begin{pmatrix} X \\ Y \\ Z \end{pmatrix} \quad\Longleftrightarrow\quad u = f_x\frac{X}{Z} + c_x, \qquad v = f_y\frac{Y}{Z} + c_y
The intrinsic matrix K. The scale factor s = Z is exactly what projection throws away.

A world-frame point first passes through the camera's extrinsics — a rigid transform into the camera frame, the same SE(3)SE(3) objects from the forward-kinematics lesson — then through KK:

s(uv1)=K[R    t](XwYwZw1)s\begin{pmatrix} u \\ v \\ 1 \end{pmatrix} = K\,\bigl[\,R \;\big|\; t\,\bigr]\begin{pmatrix} X_w \\ Y_w \\ Z_w \\ 1 \end{pmatrix}
The full chain: extrinsics move the point into the camera frame, K maps it to pixels.

Note what the projection destroyed: the scale ss. Every point along the ray through a pixel lands on that same pixel, so a single RGB camera cannot distinguish a small near object from a large far one. Everything downstream is a strategy for recovering the lost dimension — depth sensors here, registration in the next lesson.

One correction before the model is usable: real lenses bend rays. The standard Brown–Conrady model perturbs normalized coordinates with a radial polynomial (k1r2+k2r4+k3r6k_1 r^2 + k_2 r^4 + k_3 r^6) plus two tangential terms (p1p_1, p2p_2) for lens–sensor misalignment — worth 1–3 px at the edges of a narrow lens, tens of pixels on wide-FOV modules. For your rig: RealSense-class depth streams arrive rectified (distortion removed, coefficients zero), so deprojection can ignore it; the color stream is not, so fiducial detection in RGB must model distortion or undistort first.

Depth cameras: manufacturing the Z you lost

There are three mainstream ways to rebuild the missing coordinate, worth knowing at the systems level because their failure signatures differ — and you will meet all three in this phase's point clouds.

TechnologyHow Z is madeError vs distanceCharacteristic failures
Active IR stereo (RealSense D400-class)Two IR imagers triangulate on disparity; a projector paints texture onto bland surfacesGrows as Z2Z^2 — about 0.6 mm at 0.4 m, 14 mm at 2 mFlying pixels at depth edges; holes on dark or specular surfaces; minimum range ~0.2–0.3 m
Structured light (Kinect v1 generation)Projects a known dot pattern; decoding it gives correspondence, then triangulationGrows as Z2Z^2 — same triangulation mathFails in sunlight and against other IR projectors; eroded depth edges
Time of flight (Kinect Azure generation)Depth from the phase shift of modulated light — round-trip timeRoughly flat to linear, mm–cm scaleMultipath in corners and shiny concavities; wrapping ambiguity between intervals
Depth sensing technologies at a systems level

Your camera is in the first row, so derive its behavior. Two rectified imagers sit a baseline BB apart along the xx-axis. The same world point lands at uLu_L in the left image and uRu_R in the right; the disparity between them encodes depth:

uLuR=(fxXZ+cx)(fxXBZ+cx)=fxBZdZ=fxBdu_L - u_R = \Bigl(f_x\frac{X}{Z} + c_x\Bigr) - \Bigl(f_x\frac{X - B}{Z} + c_x\Bigr) = \frac{f_x B}{Z} \equiv d \quad\Longrightarrow\quad Z = \frac{f_x B}{d}
Disparity is inversely proportional to depth: near objects shift a lot between views, far ones barely at all.

Depth is the reciprocal of disparity, and that reciprocal is the entire noise story. The stereo matcher localizes disparity to roughly constant subpixel precision — call it σd0.08\sigma_d \approx 0.08 px — independent of distance. Propagate the constant through the reciprocal:

σZ=Zdσd=fxBd2σd=Z2fxBσd\sigma_Z = \left|\frac{\partial Z}{\partial d}\right|\sigma_d = \frac{f_x B}{d^2}\,\sigma_d = \frac{Z^2}{f_x B}\,\sigma_d
Constant subpixel matching noise becomes quadratically growing depth noise.

Put RealSense-class numbers in: B=50B = 50 mm and fx=447f_x = 447 px give fxB22.4f_x B \approx 22.4 px·m. At 0.4 m, σZ0.6\sigma_Z \approx 0.6 mm; at 1 m, 3.6 mm; at 2 m, 14 mm. This one formula should drive your mounting decisions. A WidowX AI has a 0.769 m reach, so a camera 0.4–0.6 m from the action lives in the sub-2 mm regime, while one bolted 1.5 m away for a nicer wide view pays quadratically for the framing.

Beyond the Z2Z^2 law, stereo depth has structured artifacts you should recognize on sight. Flying pixels appear at depth discontinuities: the correlation window straddles foreground and background, producing points that float in the space between them — exactly along object boundaries, which is exactly where a grasp planner looks. Invalid pixels (holes, depth 0) appear on IR-dark and specular surfaces, in occlusion shadows only one imager sees, and closer than the minimum range — about 0.2–0.3 m at full resolution, so mount with margin. The map also flickers temporally — a static scene wanders frame to frame — which is why every validation in this lesson averages at least 100 frames.

Checkpoint 01

You validate your depth camera at 0.4 m and measure about 0.6 mm of depth noise (standard deviation), then remount it 0.8 m from the workspace for a wider view. What noise should you expect?

Intrinsic calibration: fitting K like a tiny model

Factory calibration covers more than you might expect: the depth stream ships calibrated and rectified, intrinsics queryable at runtime. You calibrate yourself when you use the color camera for metric work (fiducials, PnP), after a lens knock or thermal cycle, and whenever you need to verify rather than trust. The workflow is stubbornly physical. Print a checkerboard — say 9×6 inner corners, 25 mm squares — and mount it dead flat on glass or aluminum; a 1 mm paper bow displaces corners by several pixels in tilted views. Better, use a ChArUco board: embedded markers give unambiguous corner identities and tolerate partial views. Lock focus and exposure. Capture 20–40 views that fill the frame, push corners into the image corners, and tilt the board 30–45° about both axes. cv2.calibrateCamera then runs Levenberg–Marquardt over intrinsics, distortion, and a nuisance pose per view, finishing in seconds on CPU — your RTX contributes nothing; the data is everything.

Foundations: From Metric Rays to Discrete Pixels

Before deriving the full projection chain, we must rigorously distinguish the continuous geometric space from the discrete digital sensor. The pinhole model is a geometric abstraction that maps 3D points to a 2D plane, but physical cameras do not record a continuous plane; they record a grid of discrete samples. Confusing these two domains is the primary source of unit errors in robotics vision. We define the image plane as the continuous surface where the optical axis intersects the focal plane, and the sensor array as the physical silicon grid that samples this plane.

Let (x,y)(x, y) denote the metric image coordinates in meters, measured from the principal point on the continuous image plane. Let (u,v)(u, v) denote the pixel coordinates in integer units, measured from the top-left corner of the sensor array. The mapping between these two spaces is governed by the pixel pitch, denoted pxp_x and pyp_y (in meters per pixel), which is the physical distance between the centers of adjacent pixels. The relationship is affine: u=xpx+cxu = \frac{x}{p_x} + c_x and v=ypy+cyv = \frac{y}{p_y} + c_y, where (cx,cy)(c_x, c_y) is the principal point expressed in pixel units.

fx=fmetricpx,fy=fmetricpyf_x = \frac{f_{\text{metric}}}{p_x}, \quad f_y = \frac{f_{\text{metric}}}{p_y}
Derivation of focal length in pixels. Since fmetricf_{\text{metric}} is in meters and pxp_x is in meters/pixel, the ratio fxf_x is dimensionless (pixels).

This dimensional analysis is critical. The intrinsic matrix KK contains fxf_x and fyf_y in units of pixels, not meters. If you treat fxf_x as a metric length, your deprojection will be off by a factor of the pixel pitch (typically 10510^{-5} to 10410^{-4}). The abstraction exists to decouple the optical geometry (focal length) from the sensor manufacturing (pixel density). A camera with a 10 mm focal length and 10 µm pixels has fx=1000f_x = 1000 px; a camera with the same optics but 5 µm pixels has fx=2000f_x = 2000 px. The geometry is identical; the digital representation is not.

In stereo vision, the baseline BB is the distance between the optical centers of the two cameras, not the physical width of the camera housing. The housing may be 50 mm wide, but if the lenses are inset, the baseline is smaller. This distinction is vital because the depth formula Z=fxBdZ = \frac{f_x B}{d} depends linearly on BB. A 10% error in BB results in a 10% error in all depth measurements. Furthermore, the disparity dd is measured in pixels, so the product fxBf_x B has units of pixels·meters. This hybrid unit is a constant for a given camera rig and simplifies noise propagation calculations.

Worked Example: Deriving Depth Noise from Disparity

We now derive the depth noise σZ\sigma_Z from the disparity noise σd\sigma_d using the chain rule. This derivation reveals why stereo depth degrades quadratically with distance. Assume the stereo matcher has a constant standard deviation σd\sigma_d in disparity space, independent of depth ZZ. This is a standard assumption for subpixel matching algorithms, which localize features to a fixed precision regardless of the object's distance.

Z=fxBd    Zd=fxBd2Z = \frac{f_x B}{d} \implies \frac{\partial Z}{\partial d} = -\frac{f_x B}{d^2}
Differentiating the depth-disparity relationship. The negative sign indicates that as disparity increases, depth decreases.

The variance of ZZ is approximated by the square of the partial derivative times the variance of dd: σZ2=(Zd)2σd2\sigma_Z^2 = \left( \frac{\partial Z}{\partial d} \right)^2 \sigma_d^2. Taking the square root gives the standard deviation: σZ=Zdσd=fxBd2σd\sigma_Z = \left| \frac{\partial Z}{\partial d} \right| \sigma_d = \frac{f_x B}{d^2} \sigma_d. Substituting d=fxBZd = \frac{f_x B}{Z} into this expression yields the final form: σZ=Z2fxBσd\sigma_Z = \frac{Z^2}{f_x B} \sigma_d. This equation shows that depth noise scales with the square of the distance ZZ.

ParameterSymbolValueUnits
Focal length (pixels)fxf_x447px
BaselineBB0.05m
Disparity noiseσd\sigma_d0.08px
DistanceZZ1.0m
Disparity at Zdd22.35px
Depth noiseσZ\sigma_Z0.0036m
Numerical Example: Depth Noise Calculation for a RealSense-class Camera

Let us compute σZ\sigma_Z for Z=1.0Z = 1.0 m. First, calculate the constant fxB=447×0.05=22.35f_x B = 447 \times 0.05 = 22.35 px·m. The disparity at Z=1.0Z = 1.0 m is d=22.351.0=22.35d = \frac{22.35}{1.0} = 22.35 px. Now, apply the noise formula: σZ=(1.0)222.35×0.08=1.022.35×0.080.0447×0.080.00358\sigma_Z = \frac{(1.0)^2}{22.35} \times 0.08 = \frac{1.0}{22.35} \times 0.08 \approx 0.0447 \times 0.08 \approx 0.00358 m, or 3.6 mm. If we double the distance to Z=2.0Z = 2.0 m, the disparity halves to d=11.175d = 11.175 px, and the noise quadruples to σZ14.3\sigma_Z \approx 14.3 mm. This quadratic growth is the fundamental limitation of stereo vision at long ranges.

Checkpoint 02

Why does the depth noise σZ\sigma_Z grow quadratically with distance ZZ?

The headline metric is RMS reprojection error: project the board's known 3D corners through the fitted model, measure pixel distance to the detected corners, and take the root mean square over all corners and views. For a 640-wide image, a careful calibration lands at 0.15–0.3 px; under 0.5 px is acceptable; anything above 1 px is a structural problem, not bad luck. The causes are boringly repeatable:

  • A non-flat board. A 1 mm bow shifts corners by pixels in oblique views; the optimizer absorbs it into the distortion coefficients, poisoning them.
  • All views fronto-parallel. Without tilt, focal length and board distance are nearly indistinguishable; the solver returns a confident fxf_x with enormous covariance.
  • No corners near the image edges. The distortion polynomial is unconstrained exactly where it is largest; it invents geometry at the periphery.
  • Autofocus or auto-exposure left on. Focus changes focal length mid-session; the dataset samples several different cameras and fits none.
  • Motion blur and rolling shutter. Waving the board by hand shears the geometry; trigger capture on detected stillness.
  • Wrong square-size metadata. The invisible one: reprojection error is unaffected — square size rescales board poses, not pixels — but every metric measurement downstream is silently scaled.

Extrinsics and hand-eye: where is the camera, really?

Intrinsics say how the camera maps rays to pixels; extrinsics say where those rays live in the robot's world. Two mounting patterns dominate. Eye-to-hand: the camera is fixed to the table or frame; the unknown is the constant base-to-camera transform. Eye-in-hand: the camera rides the wrist; the unknown is the constant gripper-to-camera transform, and the camera's base-frame pose is FK times that mount. The trade is occlusion against motion: a fixed camera watches the arm block its own view at the grasp moment; a wrist camera keeps the target centered through the approach but blurs while moving and costs a little payload — a RealSense-class module with cable is 75–100 g against the WidowX AI's 1.5 kg rating, a rounding error. The standard answer for your rig, and the layout π₀-style setups use, is both: one over-the-shoulder view, one wrist view — which is exactly how the WidowX AI Follower ships, with an Intel RealSense D405 already on an arm mount for the wrist view.

Calibrating either mount is the same problem: a rigid transform you cannot measure with a ruler. The trick is to let the arm do the measuring. Take the eye-in-hand case and fix a fiducial board to the table. At arm pose ii, the board's base-frame pose factors as FK (gripper in base) times the unknown mount XX (camera in gripper) times the detection (board in camera, from PnP on its corners). The board never moves, so the product is identical at every pose; equating two poses eliminates the board entirely:

Tgb(i)XTtc(i)=Tgb(j)XTtc(j)Tgb(j)1Tgb(i)A (from FK)X=XTtc(j)Ttc(i)1B (from detections)T^{b}_{g}(i)\,X\,T^{c}_{t}(i) = T^{b}_{g}(j)\,X\,T^{c}_{t}(j) \quad\Longrightarrow\quad \underbrace{T^{b}_{g}(j)^{-1}\,T^{b}_{g}(i)}_{A\ \text{(from FK)}}\,X = X\,\underbrace{T^{c}_{t}(j)\,T^{c}_{t}(i)^{-1}}_{B\ \text{(from detections)}}
A static board makes the base-to-target product pose-invariant; equating it at poses i and j yields the classical hand-eye equation AX = XB.

Split AX=XBAX = XB into rotation and translation parts: RARX=RXRBR_A R_X = R_X R_B and (RAI)tX=RXtBtA(R_A - I)\,t_X = R_X t_B - t_A. Classical solvers — Tsai–Lenz and descendants, wrapped by cv2.calibrateHandEye — solve the rotation equation first (each motion pair says the rotation axes of AA and BB correspond under RXR_X), then substitute into the linear translation equation. The structure dictates the data: rotations about at least two non-parallel axes, because one axis leaves RXR_X free to spin about it, and (RAI)(R_A - I) must be exercised for tXt_X to appear at all. A practical session: 15–30 poses, wrist rotated ±30° or more about two distinct axes, board sharp and fully visible, FK pose and image stored as a synchronized pair — your Phase 01 timestamping discipline, cashed in. Validate on held-out poses: predict where the board should appear under the solved XX and measure the pixel gap.

Checkpoint 03

You collect 25 eye-in-hand calibration poses by translating the gripper across a 5×5 grid at fixed orientation. Detections are clean, FK is synchronized, yet the solved mount transform X is wildly wrong. Why?

From pixels to millimeters: the error budget

Now invert the projection and follow errors through it. Given a pixel and its depth, the lateral coordinate and its two sensitivities are:

X=(ucx)Zfx,Xu=Zfx,XZ=ucxfx=tanθoff-axisX = \frac{(u - c_x)\,Z}{f_x}, \qquad \frac{\partial X}{\partial u} = \frac{Z}{f_x}, \qquad \frac{\partial X}{\partial Z} = \frac{u - c_x}{f_x} = \tan\theta_{\text{off-axis}}
Deprojection and its sensitivities: one pixel costs Z over f_x; depth error leaks laterally by tan of the off-axis angle.

Read the partial derivatives as prices. At Z=0.45Z = 0.45 m with fx=447f_x = 447 px, one pixel of image error costs Z/fx1.0Z / f_x \approx 1.0 mm of lateral position — a round number worth memorizing, which doubles at a 0.9 m mount. Depth error passes straight into the point's zz and leaks laterally through tanθ\tan\theta: near the edge of the 87° FOV the factor approaches 0.95, so a 5 mm depth bias drags the point almost 5 mm sideways too. Rotation error costs displacement proportional to lever arm, εθL\varepsilon \approx \theta L: half a degree (8.7 mrad) over 0.45 m is 3.9 mm. Now the budget for a concrete task — a 25 mm cube, camera 0.45 m away, eye-to-hand, the WidowX AI's 40 mm gripper opening leaving roughly ±7 mm of usable centering tolerance:

Error sourceMagnitudeConversion at 0.45 mAt the objectBias or noise?
Pixel error in detection±1 pxtimes Z/fx = 1.0 mm/px±1.0 mmNoise — averages down
Depth noise (stereo matching)±0.7 mmdirect along the ray±0.7 mmNoise
Depth bias (factory calibration, temperature)2–5 mmalong the ray, plus lateral leak of tan(off-axis)2–6 mmBias
Hand-eye rotation error0.5° (8.7 mrad)times 0.45 m lever armup to 3.9 mmBias
Hand-eye translation error1–2 mmdirect1–2 mmBias
FK error at the wrist (WidowX-AI-class)2–4 mmdirect2–4 mmBias, pose-dependent
Error budget: grasping a 25 mm cube at Z = 0.45 m with a RealSense-class camera

The punchline is the last column. The noise rows combine in quadrature to barely 1.3 mm — harmless, and averaging 100 frames divides them by 10. The bias rows do not average: they add, worst case in the same direction, and at 8–12 mm they can consume the entire ±7 mm tolerance while every individual number looked respectable. Hence the phase goal of separating failure classes: a grasp that misses consistently in one base-frame direction is a calibration bug; one that misses in a different direction every trial is a perception bug. Same failure rate, opposite fixes.

Time to make this executable. Write these two functions yourself, once, and test them against the vendor implementation (rs2_deproject_pixel_to_point in pyrealsense2) so that every convention — pixel origin, axis directions, distortion handling — is verified rather than assumed. They are the atoms of the next three lessons.

camera_geometry.py — the two atoms of metric visionpython
import numpy as np

def project(points_cam, K, dist=None):
    """(N, 3) camera-frame points in meters -> (N, 2) pixel coordinates.

    dist: optional Brown-Conrady coefficients (k1, k2, p1, p2, k3),
    OpenCV ordering. Pass dist=None for rectified streams.
    """
    p = np.asarray(points_cam, dtype=np.float64)
    x = p[:, 0] / p[:, 2]
    y = p[:, 1] / p[:, 2]
    if dist is not None:
        k1, k2, p1, p2, k3 = dist
        r2 = x * x + y * y
        radial = 1.0 + k1 * r2 + k2 * r2**2 + k3 * r2**3
        x, y = (x * radial + 2.0 * p1 * x * y + p2 * (r2 + 2.0 * x * x),
                y * radial + p1 * (r2 + 2.0 * y * y) + 2.0 * p2 * x * y)
    u = K[0, 0] * x + K[0, 2]
    v = K[1, 1] * y + K[1, 2]
    return np.stack([u, v], axis=1)

def deproject(pixels, depth_m, K):
    """(N, 2) pixels + (N,) depths in meters -> (N, 3) camera-frame points.

    Assumes a rectified stream (RealSense-class depth streams are).
    Depth 0 means an invalid pixel: filter those out before calling.
    """
    px = np.asarray(pixels, dtype=np.float64)
    z = np.asarray(depth_m, dtype=np.float64)
    x = (px[:, 0] - K[0, 2]) / K[0, 0] * z
    y = (px[:, 1] - K[1, 2]) / K[1, 1] * z
    return np.stack([x, y, z], axis=1)

# Round-trip sanity check with RealSense-class depth intrinsics
K = np.array([[447.0, 0.0, 424.0],
              [0.0, 447.0, 240.0],
              [0.0, 0.0, 1.0]])
pt = np.array([[0.10, -0.05, 0.45]])   # 10 cm right, 5 cm up, 45 cm out
uv = project(pt, K)                    # -> approx [523.3, 190.3]
assert np.allclose(deproject(uv, pt[:, 2], K), pt)

Validation closes the loop between calibration numbers and physical reality: measure an object whose size you know to better precision than the camera can resolve, and attribute the disagreement. A caliper-measured aluminum block is ideal.

validate_depth.py — a known-size object as ground truthpython
import numpy as np

def robust_depth(depth_image_m, u, v, half=2):
    """Median depth in a (2*half+1)^2 window, ignoring invalid zeros.

    Single-pixel depth at an object edge is exactly where flying
    pixels live; the median rejects them.
    """
    patch = depth_image_m[v - half : v + half + 1, u - half : u + half + 1]
    valid = patch[patch > 0]
    return float(np.median(valid)) if valid.size else 0.0

def edge_width_mm(uv_a, uv_b, z_a, z_b, K):
    """Metric distance between two pixel+depth measurements, in mm."""
    pts = deproject(np.array([uv_a, uv_b], dtype=np.float64),
                    np.array([z_a, z_b], dtype=np.float64), K)
    return 1000.0 * float(np.linalg.norm(pts[1] - pts[0]))

def report(widths_mm, true_mm):
    w = np.asarray(widths_mm, dtype=np.float64)
    bias = w.mean() - true_mm
    print("frames:        ", w.size)
    print("true width:    ", true_mm, "mm")
    print("measured mean: ", round(float(w.mean()), 3), "mm")
    print("bias:          ", round(float(bias), 3), "mm  (systematic; will NOT average away)")
    print("std (noise):   ", round(float(w.std()), 3), "mm  (random; shrinks as 1/sqrt(N))")

# Per distance (0.30 m, 0.45 m, 0.60 m):
#   1. place a caliper-measured block (e.g. 60.00 mm) facing the camera
#   2. locate its two top-edge endpoints in the aligned depth frame
#   3. widths.append(edge_width_mm(...)) over ~100 frames,
#      using robust_depth() at each endpoint
#   4. report(widths, 60.00)
Studio exercise 01

Put error bars on your depth camera

Run the validation workflow at three distances — roughly 0.30, 0.45, and 0.60 m — with a caliper-measured object, at least 100 frames per distance. Produce: (1) a table of bias and standard deviation at each distance, reported separately; (2) a plot of measured std versus distance, overlaid with the predicted σZ=Z2σd/(fxB)\sigma_Z = Z^2 \sigma_d / (f_x B) curve using your camera's actual fxf_x and baseline and a fitted σd\sigma_d; (3) the invalid-pixel fraction in a 40×40 px window on the object face at each distance. Close with one paragraph: which error dominates at your planned mounting distance — bias or noise?

Need a hint?

Query intrinsics and baseline from the device, not the datasheet — pyrealsense2 exposes both. Median-filter depth around each endpoint or flying pixels will dominate your std. The exponent is diagnostic: matching noise grows quadratically with distance; a depth-scale bias grows linearly.

Where this goes next: Inverse kinematics: analytic, numeric, and constrained gave you the map from a desired gripper pose to joint angles; this lesson built the map from pixels to metric points worth aiming that IK at. The missing middle is turning a calibrated point cloud into the 6-DoF pose of the object you want to grasp — that is Pose estimation and point-cloud registration (see MIT Robotic Manipulation, Chapter 4 (opens in a new tab)), where your intrinsics become the projection model inside PnP and ICP, and your measured noise decides which correspondences to trust. Bring the numbers: the error-budget table is the first row of the failure-attribution ledger this phase exists to build.