RealSense Depth Camera Acceptance Test

Accept Intel RealSense D435 cameras before integration: fill rate, Z-accuracy, plane-fit and temporal noise over a distance sweep, projector, extrinsics.

TofuPilotIncoming InspectionPythonTofuPilot FrameworkGitHub
Depth camera on a linear rail facing a flat target beside a controller
Run this procedure.

Get the complete source, dependencies and setup instructions from the template repository.

Open the source on GitHub ↗

Introduction

Depth Camera Acceptance Overview

An Intel RealSense D435 is an active stereo depth camera: two global-shutter infrared imagers 50 mm apart, an infrared projector that paints a pseudo-random dot pattern on the scene so that textureless surfaces can be matched, and an ASIC that computes disparity per pixel and converts it to depth with the factory calibration. Depth comes out as Z16 frames: unsigned 16-bit integers in the camera's depth unit, 1 mm by default, with zero where the matcher found nothing. Every camera is calibrated at Intel's factory and carries its own intrinsics and extrinsics in flash; what varies from unit to unit, and with every drop and every heat cycle in transit, is how well that calibration still describes the optics.

Stereo depth error is not constant. For a disparity error of δ pixels, a focal length fx and a baseline B, the depth error at distance z is Δz = z² · δ / (fx · B): it grows with the square of the distance and shrinks with the baseline. On a D435 at 848 by 480, fx is about 421 px and B is 50 mm, so 0.08 px of disparity noise is 0.9 mm of depth at 0.5 m and 15 mm at 2 m. Intel's own Depth Quality Tool reduces a frame of a flat target to four numbers: the fill rate (share of valid pixels in the region of interest), the Z-accuracy (distance of the fitted plane against a ground truth, in percent), the plane-fit RMS error (spatial noise, in mm and in sub-pixels of disparity) and the temporal noise (per-pixel standard deviation over a burst of frames). A robotics integrator receiving cameras by the hundred needs those four numbers per serial, on a controlled target, before the camera goes onto a robot whose grasping or navigation depends on them.

Test Purpose

The procedure produces, per camera:

  • Identity: serial, product name, firmware version against an allow-list, USB link type, depth units, ASIC and projector temperatures, all read from the device
  • Stream health: 3 s of depth frame timing at 30 fps, frames per second and worst gap validated, dropped frames counted
  • Depth quality: at 0.5, 1.0, 1.5 and 2.0 m on a flat matte board, fill rate, Z-accuracy against a laser rangefinder, plane-fit RMS and temporal noise from 30 frames each, one residual map attached
  • Projector: fill rate on the textureless board with the emitter off, then on, and the laser power read back
  • Calibration: stereo baseline, depth-to-color offset and rotation, focal length, the full depth intrinsics kept as a JSON record

Depth error budget of one D435 across the 0.5 to 2 m sweep: Z-accuracy error, plane-fit RMS and temporal noise in millimetres against the 2 percent limit line.

The four distances of one run of the mock: the Z-accuracy error sits at +0.4 % of distance (8 mm at 2 m), the plane-fit RMS climbs from 0.8 to 12.6 mm along the z² curve, the temporal noise from 0.4 to 5.2 mm, and all of it stays well under the dashed 2 % line that Intel quotes for the D435.

Defects this catches before the camera ships on a robot: a decalibrated stereo pair (Z-accuracy off, plane-fit RMS up at every distance), a dead or weak projector (fill rate collapses on the board with the emitter on, the emitter-off and emitter-on numbers converge), a camera stuck on USB 2 (the link type reads 2.1, the frame rate is a fraction of 30), unapproved firmware (the perception stack was validated against specific versions), a noisy imager (temporal noise up while the plane fit is fine), and a shifted color module (RGB offset or rotation outside the D435 geometry).

Beyond the camera content, the template shows five framework mechanics: the device under test written as a plug with its stream profile passed through config:, a station-scoped rail plug that stays connected across units while the camera changes, then: error: retry with a retry budget on the connect phase for the USB re-enumeration a hot-plugged camera goes through, multi-dimensional measurements over distance with distance-normalised aggregations validated in YAML, and attach.data of a residual map and a JSON summary on the run.

Equipment & Setup

To accept depth cameras at incoming inspection, the following are required:

  • A flat target: a matte white board, 1200 by 800 mm, flat to better than 1 mm, no texture of its own so the projector check means something
  • A target rail: a motorised linear axis carrying the board from 0.5 to 2 m, with a laser rangefinder reading the board distance as the reference
  • A camera fixture: a nest that squares the camera to the rail, a USB 3.2 cable of known quality to the test PC
  • The Devices Under Test: RealSense D435 cameras as received, stock firmware
  • A TofuPilot Framework procedure to enumerate, stream, sweep, measure and record
  • The TofuPilot Dashboard to keep every camera's four metrics per distance and trend them per firmware and per delivery batch

Depth camera acceptance station: the camera in its nest at one end of a linear rail, the flat target board on the carriage, a rangefinder beside the camera, a bench instrument

The station: the camera in its nest at one end, the board on the rail carriage, the rangefinder next to the camera looking at the same board, the instrument beside the rail. Enclosing the rail against daylight is not needed for a D435 (the projector and the matcher cope with ambient light), but a constant background helps the fill-rate numbers repeat.

Hardware Components

Depth Camera

The Intel RealSense D435 (and its D435i sibling with an IMU) is the reference: 848 by 480 depth at up to 90 fps, 50 mm baseline, 87° by 58° depth field of view, ideal range 0.3 to 3 m. The D455 doubles the baseline to 95 mm and pushes the ideal range to 6 m; the same procedure accepts it with wider limits on the baseline and the RGB offset. Both talk pyrealsense2 over USB 3.2 and expose the same descriptors, options and calibration.

Stereo depth RMS error versus distance for the D435 50 mm baseline and the D455 95 mm baseline at 0.08 px sub-pixel noise, with the 2 percent line and this run's four plane-fit RMS points.

Why the sweep stops at 2 m for a D435 and would go to 4 m for a D455: at the same 0.08 px of disparity noise the 95 mm baseline halves the depth error, and the run's four plane-fit RMS points sit on the D435 curve as they should.

Target Rail and Reference

A 2 m linear axis (an igus drylin or a Festo ELGA belt axis with a stepper, 0.1 mm repeatability) carries the board; a laser rangefinder (Leica DISTO or a Keyence IL-series sensor for a fixed installation) mounted beside the camera reads the board distance to 0.15 mm and is the ground truth the Z-accuracy is computed against. The rail plug is declared scope: station: the axis homes once when the station starts and stays connected across every camera of the shift.

Close-up of a RealSense depth camera in its fixture nest, the two infrared imagers, the projector and the color camera in a row on its front

The camera in its nest: two IR imagers 50 mm apart, the projector between them, the color module beside them. Each distance is 30 frames, one plane fit per frame, one row in the four multi-dimensional measurements.

Limits

Limits for a D435 at 848 by 480 with the projector at its default 150 mW, as the template ships them:

ParameterLimitWhy
Firmware versionin 5.16.0.1, 5.15.1.0the versions the robot's perception stack was validated against
USB type3.2on 2.1 the camera streams but at a fraction of the rate
Frame rate29.5 to 30.5 fpsa USB or bandwidth problem drops frames before anything else fails
Max frame gap≤ 40 msone skipped frame at 30 fps is a 66 ms gap
Fill rate≥ 99 % at every distancea flat matte board under the projector should be fully filled
Z-accuracy≤ 2 % at every distanceIntel's datasheet figure for the D435 at 2 m
Plane-fit RMS≤ 0.15 px sub-pixeldistance-independent; typical cameras sit near 0.08 px
Temporal noise≤ 10 mm at 2 mabout 0.05 px of per-frame disparity noise
Fill rate, emitter off≤ 50 %a textureless board must not match without the pattern
Fill rate, emitter on≥ 99 %the projector restores the match
Stereo baseline49.5 to 50.5 mmthe D435 geometry as calibrated
RGB offset13.5 to 16 mmthe color module sits 15 mm from the left imager
Depth-color rotation≤ 0.5°a shifted color module misaligns the point cloud colours
Focal length410 to 435 pxfx at 848 by 480, catches a wrong stream profile or a bad calibration

Custom Firmware

None. The camera runs its stock Intel firmware; everything the procedure reads and sets is exposed by pyrealsense2: descriptors through camera_info, temperatures and the emitter through rs.option, calibration through the stream profiles. The only station-side requirement is librealsense with the kernel patches or the RSUSB backend, and a USB 3.2 port that actually enumerates at SuperSpeed.

Test Procedure

Overview

For each camera in the fixture, the procedure runs:

  1. Enumerate the camera, read serial, name, firmware, USB type, depth units and temperatures; retry once if the USB stack is still re-enumerating.
  2. Start the depth stream at 848 by 480, 30 fps, and time 3 s of frames.
  3. Move the board to 0.5, 1.0, 1.5 and 2.0 m; at each, read the rangefinder, capture 30 frames, fit a plane per frame, compute fill rate, Z-accuracy, plane-fit RMS and temporal noise.
  4. At 1 m, capture with the emitter off, then on, and compare the fill rates.
  5. Read the calibration back from the device and check baseline, RGB offset, rotation and focal length.
  6. Teardown: stop the stream, home the rail.

Why TofuPilot Framework?

TofuPilot Framework is a YAML + Python test framework built for hardware manufacturing. Instead of writing all your test logic, measurements, and limits inside Python code, you describe what the test does in a procedure.yaml file, and how in small Python phase files. The framework handles:

  • Automatic Python environment management (via uv)
  • Operator UI (no frontend code needed)
  • Measurement validation and live charts
  • Process isolation between phases and equipment plugs

Project Structure

procedure.yaml
phases
connect_camera.py
stream_check.py
depth_quality.py
projector_check.py
extrinsics_check.py
release_camera.py
plugs
realsense.py
rail.py
utils
depth.py
README.md
pyproject.toml

The camera and the rail are mocks; the plane fits and the noise statistics are real numpy, run by the framework in a uv environment with numpy and pillow. Run it with tofupilot run . --no-tui --no-kiosk --json. You can find the full source on GitHub.

The Procedure File

This is the exact file the template ships, verified end to end with the CLI:

procedure.yaml · 259 lines
procedure.yaml
name: RealSense Depth Camera Acceptance Testversion: 0.1.0description: Accepts an Intel RealSense D435 depth camera on a target rail. Reads its descriptors, checks the stream timing, measures fill rate, Z-accuracy, plane-fit and temporal noise at four distances, proves the IR projector, and checks the factory extrinsics.unit:  auto_identify: true  serial_number:    description: "Scan the camera label"    placeholder: "213522070981"    pattern: "^\\d{12}$"    default_value: "213522070981"  part_number:    default_value: "RS-D435"  metadata:    firmware_version:      description: "Firmware reported by the camera"    usb_type:      description: "USB link the camera enumerated on (3.2 or 2.1)"plugs:  - name: RealSense Camera    description: "The camera under test over pyrealsense2: descriptors, streams, options, calibration (mock)"    python: plugs.realsense:RealSenseCamera    key: camera    config:      width: 848      height: 480      fps: 30  - name: Target Rail    description: Motorised rail carrying the flat target, with a laser rangefinder as distance reference (mock)    python: plugs.rail:TargetRail    key: rail    scope: station    config:      port: "/dev/ttyUSB0"main:  - name: Connect Camera    key: connect_camera    python: phases.connect_camera    retry:      limit: 3      delay: 2s    then:      error: retry    measurements:      - name: Camera Serial        key: camera_serial        validators:          - {operator: matches, expected_value: "^\\d{12}$"}      - name: Product Name        key: product_name        validators:          - {operator: "==", expected_value: "Intel RealSense D435"}      - name: Firmware Version        key: firmware_version        description: "Only firmware validated with the robot's perception stack is accepted"        validators:          - {operator: in, expected_value: ["5.16.0.1", "5.15.1.0"]}      - name: USB Type        key: usb_type        description: "A camera on USB 2.1 streams at a fraction of the rate; the cable or the port is at fault"        validators:          - {operator: "==", expected_value: "3.2"}      - name: Depth Units        key: depth_units        unit: µm        validators:          - {operator: "==", expected_value: 1000}      - name: ASIC Temperature        key: asic_temperature        unit: °C        validators:          - {operator: ">=", expected_value: 20.0}          - {operator: "<=", expected_value: 60.0}      - name: Projector Temperature        key: projector_temperature        unit: °C        validators:          - {operator: ">=", expected_value: 20.0}          - {operator: "<=", expected_value: 60.0}  - name: Stream Check    key: stream_check    python: phases.stream_check    depends_on: [connect_camera]    measurements:      - name: Frame Interval        key: frame_interval        title: Depth frame interval over 3 s at 30 fps        x_axis:          legend: Frame        y_axis:          - legend: Interval            key: interval            unit: ms            aggregations:              - type: fps                validators:                  - {operator: ">=", expected_value: 29.5}                  - {operator: "<=", expected_value: 30.5}              - type: max_gap_ms                unit: ms                validators:                  - {operator: "<=", expected_value: 40.0}      - name: Frames Dropped        key: frames_dropped        validators:          - {operator: "==", expected_value: 0}  - name: Depth Quality Sweep    key: depth_quality    python: phases.depth_quality    depends_on: [stream_check]    timeout: 300s    ui:      components:        - key: sweep_progress          type: progress          label: "Target distances"          description: "0.5, 1.0, 1.5 and 2.0 m, 30 frames each"          default_value: 0          max: 100    measurements:      - name: Fill Rate        key: fill_rate        title: Valid depth pixels in the ROI per distance        x_axis:          legend: Distance          unit: mm        y_axis:          - legend: Fill rate            key: fill            unit: "%"            aggregations:              - type: min_pct                unit: "%"                validators:                  - {operator: ">=", expected_value: 99.0}      - name: Z-Accuracy        key: z_accuracy        title: Plane distance error against the rangefinder per distance        x_axis:          legend: Distance          unit: mm        y_axis:          - legend: Error            key: error            unit: "%"            aggregations:              - type: max_abs_pct                unit: "%"                validators:                  - {operator: "<=", expected_value: 2.0}      - name: Plane Fit RMS        key: plane_fit_rms        title: Plane-fit residual RMS per distance        description: "Grows with the square of the distance; the aggregation converts it back to disparity pixels, which does not"        x_axis:          legend: Distance          unit: mm        y_axis:          - legend: RMS            key: rms            unit: mm            aggregations:              - type: max_subpixel_px                unit: px                validators:                  - {operator: "<=", expected_value: 0.15}      - name: Temporal Noise        key: temporal_noise        title: Per-pixel depth noise over 30 frames per distance        x_axis:          legend: Distance          unit: mm        y_axis:          - legend: Noise            key: noise            unit: mm            aggregations:              - type: max_mm                unit: mm                validators:                  - {operator: "<=", expected_value: 10.0}  - name: Projector Check    key: projector_check    python: phases.projector_check    depends_on: [depth_quality]    measurements:      - name: Laser Power        key: laser_power_mw        unit: mW        validators:          - {operator: "==", expected_value: 150}      - name: Fill Rate Emitter Off        key: fill_rate_emitter_off        unit: "%"        description: "A matte white board has no texture, so without the projected pattern the matcher must lose most pixels"        validators:          - {operator: "<=", expected_value: 50.0}      - name: Fill Rate Emitter On        key: fill_rate_emitter_on        unit: "%"        validators:          - {operator: ">=", expected_value: 99.0}      - name: Emitter Gain        key: emitter_gain_pct        unit: "%"        validators:          - {operator: ">=", expected_value: 40.0}  - name: Extrinsics Check    key: extrinsics_check    python: phases.extrinsics_check    depends_on: [projector_check]    measurements:      - name: Stereo Baseline        key: stereo_baseline_mm        unit: mm        validators:          - {operator: ">=", expected_value: 49.5}          - {operator: "<=", expected_value: 50.5}      - name: RGB Offset        key: rgb_offset_mm        unit: mm        description: "Depth to color translation, 15 mm on a D435 by construction"        validators:          - {operator: ">=", expected_value: 13.5}          - {operator: "<=", expected_value: 16.0}      - name: Depth-Color Rotation        key: depth_color_rotation_deg        unit: °        validators:          - {operator: "<=", expected_value: 0.5}      - name: Focal Length        key: focal_length_px        unit: px        validators:          - {operator: ">=", expected_value: 410.0}          - {operator: "<=", expected_value: 435.0}      - name: Depth Intrinsics        key: depth_intrinsics        description: "The full depth intrinsics as the device reports them, kept whole for the record"teardown:  - name: Release Camera    key: release_camera    python: phases.release_camera    measurements:      - name: Stream Stopped        key: stream_stopped        validators:          - {operator: "==", expected_value: true}      - name: Rail Homed        key: rail_homed        validators:          - {operator: "==", expected_value: true}

Framework features to notice:

  1. The device under test is a plug: plugs.realsense:RealSenseCamera wraps pyrealsense2, and the stream profile (width, height, fps) travels through config: into its __init__, so the same plug accepts a D455 at 1280 by 720 by editing the YAML.
  2. Two plug scopes on one bench: the rail is scope: station, homed once and held across every camera of the shift; the camera keeps the default scope and is re-created for every unit, which is what a device under test needs. In a one-shot tofupilot run a station plug behaves as execution scope, so the template runs unchanged.
  3. Retry on error: connect_camera carries retry: {limit: 3, delay: 2s} and then: {error: retry}. A RealSense plugged in a moment ago fails query_devices() while the USB stack re-enumerates; the first attempt errors, the second passes, and the run page shows both attempts. One thing learned on the way: the override is honoured on main phases, not on setup phases, where the engine declares the setup failed before it looks at then, which is why the connect phase sits at the head of main with everything depending on it.
  4. Distance-normalised aggregations: plane_fit_rms is recorded in mm per distance, where it must grow with z², and validated through its max_subpixel_px aggregation, which the phase computes as RMS · fx · B / z² and which does not depend on distance. The same pattern gives fill_rate its min_pct, z_accuracy its max_abs_pct and temporal_noise its max_mm, all four named by the phase and checked in YAML.
  5. String validators for the descriptors: firmware_version with in against the allow-list, usb_type with == to "3.2", camera_serial with matches on twelve digits; the descriptors also land in unit.metadata, so a run can be filtered by firmware later.

Connect Camera

The camera plug's open() enumerates the device and reads its descriptors; the phase records them, stamps firmware and USB type on the unit, and writes which rail and target the run used into run.metadata:

phases/connect_camera.py
def connect_camera(measurements, unit, run, camera, log):    """Setup: enumerate the camera, read its descriptors, stamp firmware and USB link on the unit."""    info = camera.open()    log.info(f"{info['name']} {info['serial']}: firmware {info['firmware']}, USB {info['usb_type']}, ASIC {info['asic_temperature_c']:.1f} C")    measurements.camera_serial = info["serial"]    measurements.product_name = info["name"]    measurements.firmware_version = info["firmware"]    measurements.usb_type = info["usb_type"]    measurements.depth_units = info["depth_units_um"]    measurements.asic_temperature = info["asic_temperature_c"]    measurements.projector_temperature = info["projector_temperature_c"]    unit.metadata["firmware_version"] = info["firmware"]    unit.metadata["usb_type"] = info["usb_type"]    run.metadata["rail_id"] = "RAIL-02"    run.metadata["target"] = "matte white board 1200x800"

On the mock the first open() raises RuntimeError("No device connected"), the way librealsense does right after a hot-plug; the engine waits 2 s and runs the phase again, and attempt 2 records the descriptors.

Stream Check

Three seconds of depth frames at 30 fps, the interval between consecutive frames recorded as a multi-dimensional measurement, the frame rate and the worst gap as its aggregations:

phases/stream_check.py
TIMING_S = 3.0def stream_check(measurements, camera, log):    """Start the depth stream and check it delivers every frame on time."""    camera.start()    intervals = camera.frame_intervals(TIMING_S)    nominal = 1000.0 / 30    dropped = sum(1 for dt in intervals if dt > 1.5 * nominal)    fps = 1000.0 / (sum(intervals) / len(intervals))    log.info(f"{len(intervals)} frames in {TIMING_S:.0f} s: {fps:.2f} fps, max gap {max(intervals):.1f} ms, {dropped} dropped")    measurements.frame_interval.x_axis = list(range(1, len(intervals) + 1))    measurements.frame_interval.y_axis.interval = intervals    measurements.frame_interval.y_axis.interval.aggregations.fps = fps    measurements.frame_interval.y_axis.interval.aggregations.max_gap_ms = max(intervals)    measurements.frames_dropped = dropped

On the mock: 90 frames, 29.95 fps, 34.1 ms worst gap, none dropped. A camera that enumerated on USB 2.1 fails here twice, on the USB type in the previous phase and on the frame rate in this one.

Depth Quality Sweep

The heart of the procedure. For each distance, the rail moves, the rangefinder gives the reference, the camera captures 30 frames of the region of interest and saves them to a file; the phase deprojects every pixel with the depth intrinsics, fits a plane per frame in metric space (so a target that is not perfectly square to the camera still yields the distance along the optical axis), and reduces the burst to the four Depth Quality Tool metrics:

phases/depth_quality.py · 54 lines
phases/depth_quality.py
DISTANCES_MM = [500, 1000, 1500, 2000]N_FRAMES = 30def depth_quality(measurements, attach, camera, rail, ui, log):    """Walk the target through four distances and measure the four Depth Quality Tool metrics at each."""    intr = camera.depth_intrinsics()    baseline = camera.extrinsics()["stereo_baseline_mm"]    fill, z_err, rms, subpixel, temporal = [], [], [], [], []    residual_map = None    for i, d in enumerate(DISTANCES_MM):        rail.move_to(d)        ref = rail.reference_mm()        cap = camera.capture_depth(N_FRAMES, ref)        frames = np.load(cap["path"]).astype(np.float64) * cap["depth_units_um"] / 1000.0  # mm        valid = frames > 0        per_frame_rms, per_frame_dist = [], []        for z, ok in zip(frames, valid):            x, y, z = deproject(z, cap["roi"], intr)            dist, residual = fit_plane(x, y, z, ok)            per_frame_dist.append(dist)            per_frame_rms.append(float(np.sqrt(np.nanmean(residual**2))))        dist = float(np.mean(per_frame_dist))        always_valid = valid.all(axis=0)        noise = float(frames[:, always_valid].std(axis=0).mean())        fill.append(100.0 * float(valid.mean()))        z_err.append(100.0 * (dist - ref) / ref)        rms.append(float(np.mean(per_frame_rms)))        subpixel.append(rms[-1] * intr["fx"] * baseline / dist**2)        temporal.append(noise)        residual_map = residual  # the last distance is where the residuals are largest        log.info(f"{d} mm (ref {ref:.1f}): fill {fill[-1]:.2f} %, z error {z_err[-1]:+.2f} %, plane RMS {rms[-1]:.2f} mm = {subpixel[-1]:.3f} px, temporal {noise:.2f} mm")        ui.sweep_progress = int(100 * (i + 1) / len(DISTANCES_MM))    measurements.fill_rate.x_axis = DISTANCES_MM    measurements.fill_rate.y_axis.fill = fill    measurements.fill_rate.y_axis.fill.aggregations.min_pct = min(fill)    measurements.z_accuracy.x_axis = DISTANCES_MM    measurements.z_accuracy.y_axis.error = z_err    measurements.z_accuracy.y_axis.error.aggregations.max_abs_pct = max(abs(e) for e in z_err)    measurements.plane_fit_rms.x_axis = DISTANCES_MM    measurements.plane_fit_rms.y_axis.rms = rms    measurements.plane_fit_rms.y_axis.rms.aggregations.max_subpixel_px = max(subpixel)    measurements.temporal_noise.x_axis = DISTANCES_MM    measurements.temporal_noise.y_axis.noise = temporal    measurements.temporal_noise.y_axis.noise.aggregations.max_mm = max(temporal)    attach.data(residual_png(residual_map, rms[-1]), f"plane_residuals_{DISTANCES_MM[-1]}mm.png")    summary = {"distances_mm": DISTANCES_MM, "fill_rate_pct": fill, "z_accuracy_pct": z_err,               "plane_fit_rms_mm": rms, "subpixel_rms_px": subpixel, "temporal_noise_mm": temporal}    attach.data(json.dumps(summary, indent=2).encode(), "depth_quality.json")

Plane-fit RMS per distance: in millimetres it follows a k times z squared curve from 0.84 to 12.65 mm, in disparity pixels it stays at 0.066 to 0.070 px under the 0.15 px limit.

The two faces of the same measurement on the mock run: 0.84, 3.17, 7.12 and 12.65 mm of plane-fit RMS, on a k·z² curve with k = 3.16 mm per m², and 0.070, 0.066, 0.066 and 0.066 px once converted back to disparity. A limit in millimetres would have to be a different number at every distance; the sub-pixel figure is one limit for the whole sweep and compares directly across the D400 family.

The Z-accuracy reads +0.40 % at every distance, the signature of a stereo pair whose true baseline is 0.4 % shorter than the calibrated one. Fill rate is 99.8 % throughout, temporal noise 0.43 to 5.22 mm. The residual map attached to the run shows the 2 m plane fit pixel by pixel: blue closer than the plane, red farther, black where the matcher returned nothing.

Projector Check

A matte white board has no texture of its own, so a stereo matcher without the projected pattern loses most of it. The phase captures at 1 m with the emitter off, then on, and records both fill rates and their difference:

phases/projector_check.py · 20 lines
phases/projector_check.py
CHECK_MM = 1000N_FRAMES = 10def projector_check(measurements, camera, rail, log):    """Prove the IR projector works: fill rate on a textureless board with the emitter off, then on."""    rail.move_to(CHECK_MM)    ref = rail.reference_mm()    camera.set_emitter(False)    off = fill_rate(camera.capture_depth(N_FRAMES, ref))    camera.set_emitter(True)    power = camera.laser_power_mw()    on = fill_rate(camera.capture_depth(N_FRAMES, ref))    log.info(f"fill rate at {CHECK_MM} mm: {off:.1f} % emitter off, {on:.1f} % emitter on at {power:.0f} mW")    measurements.laser_power_mw = power    measurements.fill_rate_emitter_off = off    measurements.fill_rate_emitter_on = on    measurements.emitter_gain_pct = on - off

Fill rate on a matte white board at 1 m: 31.0 percent with the emitter off against a 50 percent ceiling, 99.8 percent with the emitter on against a 99 percent floor.

31.0 % with the emitter off, 99.8 % with it on, a gain of 69 points at 150 mW. A dead projector shows both numbers near 31 %; a weak one shows the emitter-on fill rate sliding under 99 % at 1 m long before it does at 0.5 m.

Extrinsics Check

The factory calibration is read back from the device and checked against the D435 geometry, with the full intrinsics kept as a JSON measurement:

phases/extrinsics_check.py
def extrinsics_check(measurements, camera, log):    """Read the factory calibration back from the device and check it against the D435 geometry."""    intr = camera.depth_intrinsics()    extr = camera.extrinsics()    rgb_offset = math.sqrt(sum(t * t for t in extr["depth_to_color_translation_mm"]))    log.info(f"baseline {extr['stereo_baseline_mm']:.2f} mm, RGB offset {rgb_offset:.2f} mm, rotation {extr['depth_to_color_rotation_deg']:.2f} deg, fx {intr['fx']:.1f} px")    measurements.stereo_baseline_mm = extr["stereo_baseline_mm"]    measurements.rgb_offset_mm = rgb_offset    measurements.depth_color_rotation_deg = extr["depth_to_color_rotation_deg"]    measurements.focal_length_px = intr["fx"]    measurements.depth_intrinsics = intr

On the mock: baseline 50.03 mm, RGB offset 14.72 mm, rotation 0.12°, fx 421.6 px. The extrinsics say what the camera believes about itself; the sweep before it says whether that belief still matches the optics.

Mock Plugs

The camera mock hides a true stereo geometry and answers capture_depth() with Z16 frames computed from it: the device reports a calibrated baseline of 50.03 mm while the true optical baseline is 49.83 mm (so every depth reads 0.4 % long), disparity carries a fixed spatial pattern of 0.06 px and a per-frame temporal noise of 0.028 px, the board is tilted 0.6° and 0.4° off square, 0.2 % of pixels are holes with the projector on and 69 % without it. Depth is quantised to the 1 mm depth unit. The phases never read these constants:

plugs/realsense.py · 29 lines
plugs/realsense.py
# The camera's real optics, unknown to the procedure.BASELINE_TRUE_MM = 49.83  # 0.4 % short of the calibrated value: depth reads longSPATIAL_PX = 0.060  # fixed disparity pattern (lens, sensor, ASIC)TEMPORAL_PX = 0.028  # per-frame disparity noiseTILT_DEG = (0.6, -0.4)  # the target is never perfectly square to the cameraHOLE_RATE = 0.002  # invalid pixels with the projector onEMITTER_OFF_FILL = 0.31  # a matte white board has no texture of its own    def capture_depth(self, n_frames, scene_mm) -> dict:        """Capture `n_frames` Z16 frames of the ROI and save them to depth_capture.npy.        `scene_mm` builds the mock scene: a flat matte board at that distance. A real plug ignores        it, the board is whatever the rail put in front of the lens."""        n, d = int(n_frames), float(scene_mm)        x0, y0, rw, rh = self._roi        tx, ty = math.tan(math.radians(TILT_DEG[0])), math.tan(math.radians(TILT_DEG[1]))        z_true = d / (1.0 - tx * self._nx - ty * self._ny)  # plane through (0, 0, d), normal tilted        disparity = FX * BASELINE_TRUE_MM / z_true        gain = 1.0 if self._emitter else 3.0  # without the pattern the matcher is noisier        hole_rate = HOLE_RATE if self._emitter else 1.0 - EMITTER_OFF_FILL        frames = np.empty((n, rh, rw), dtype=np.uint16)        for i in range(n):            measured = disparity + gain * (self._pattern_px + self._rng.normal(0.0, TEMPORAL_PX, (rh, rw)))            z_units = np.rint(FX * BASELINE_CAL_MM / measured * 1000.0 / DEPTH_UNITS_UM)            z_units[self._rng.random((rh, rw)) < hole_rate] = 0            frames[i] = z_units.astype(np.uint16)        np.save(CAPTURE_PATH, frames)        return {"path": CAPTURE_PATH, "frames": n, "roi": list(self._roi), "depth_units_um": DEPTH_UNITS_UM}

Plug calls cross a JSON boundary, and 30 frames of a 170 by 96 region are half a million values, so the frames go through a .npy file and the call returns its path and the ROI; the phase loads the array. On a real station, open() is rs.context().query_devices() plus get_info() for the descriptors, start() builds an rs.config from the width, height and fps the procedure passed through config: and starts an rs.pipeline, capture_depth() collects wait_for_frames().get_depth_frame() into the same array and ignores its second argument, set_emitter() and laser_power_mw() are rs.option.emitter_enabled and rs.option.laser_power, and the intrinsics and extrinsics come from the stream profiles. The rail plug drives the real axis and reads the rangefinder. The phases, the measurements and the limits do not change.

Run your first test in minutes