Skip to content

Add native OpenCV lens-distortion cameras for OVRTX#6608

Open
liorbenhorin wants to merge 11 commits into
isaac-sim:developfrom
liorbenhorin:liorbenhorin/opencv-lens-distortion-cameras
Open

Add native OpenCV lens-distortion cameras for OVRTX#6608
liorbenhorin wants to merge 11 commits into
isaac-sim:developfrom
liorbenhorin:liorbenhorin/opencv-lens-distortion-cameras

Conversation

@liorbenhorin

Copy link
Copy Markdown

Description

Isaac Lab's stock camera cfgs (PinholeCameraCfg, FisheyeCameraCfg) can only express a focal-length + aperture projection. They cannot represent an OpenCV fx/fy/cx/cy + distortion-coefficient intrinsic model — the format real-camera and LeRobot calibrations come in — so a calibrated real camera (non-square pixels, off-center principal point, lens distortion) cannot be reproduced in simulation.

This PR adds first-class support for OpenCV lens-distortion cameras, rendered natively through the RTX/OVRTX renderer:

  • Config: new OpenCvDistortionCfg base with OpenCvPinholeDistortionCfg (k1..k6, p1, p2, s1..s4) and OpenCvFisheyeDistortionCfg (k1..k4) sub-configs, plus a distortion field on PinholeCameraCfg. The model is renderer-agnostic — it lives on the camera cfg, and each renderer decides how to consume it.
  • Authoring: spawn_camera authors the model on the camera prim as the omni:lensdistortion:* USD API (schema + model token + fx/fy/cx/cy + imageSize + coefficients). OVRTX honors this natively — no renderer-package change is required.
  • Intrinsics readback: Camera._update_intrinsic_matrices now reads the authored fx/fy/cx/cy when a distortion model is present, so camera.data.intrinsic_matrices reflects a real calibration instead of assuming fx == fy and a centered principal point.
  • Scope: the RTX/OVRTX path is implemented. The Newton distortion path is intentionally out of scope; it is left as a documented no-op that logs a warning and renders undistorted, and the cfg is designed so Newton can consume the same model later.

This upstreams a capability that has been proven out-of-tree in the SO-101 sim-to-real workshop; once merged, that workshop can delete its private OpenCV-pinhole spawner and use these APIs directly.

Dependencies: none (no new required or optional dependencies).

Fixes # (N/A — new feature)

Type of change

  • New feature (non-breaking change which adds functionality)

Screenshots

Rendering the grid-textured ground plane - Coefficients exaggerated (--k_scale 15) for visibility.

Screenshot 2026-07-19 135849

Checklist

  • I have read and understood the contr
    (https://isaac-sim.github.io/IsaacLab/mainl)
  • I have run the pre-commit checks (ht/isaaclab.sh --format
  • I have made corresponding changes to
  • My changes generate no new warnings
  • I have added tests that prove my fixure works
  • I have updated the changelog and the extension's config/extension.toml file
  • I have added my name to the CONTRIBUists there

Add OpenCvPinholeDistortionCfg and OpenCvFisheyeDistortionCfg and a
distortion field on PinholeCameraCfg so a real camera calibration
(fx/fy/cx/cy plus distortion coefficients) can be reproduced in sim.
The model is authored at spawn as the omni:lensdistortion USD API,
which the RTX/OVRTX renderer honors natively.

Make the intrinsics readback distortion-aware so
camera.data.intrinsic_matrices reflects a non-square or off-center
calibration instead of assuming fx == fy and a centered principal
point. The Newton renderer does not yet apply the model and is left as
a documented no-op with a warning.
Propagate OpenCvDistortionCfg, OpenCvPinholeDistortionCfg and
OpenCvFisheyeDistortionCfg through the isaaclab.sim.spawners and
isaaclab.sim lazy-export stubs, so they are importable as
isaaclab.sim.<Cfg> like the sibling PinholeCameraCfg and
FisheyeCameraCfg.
@liorbenhorin
liorbenhorin requested a review from a team July 19, 2026 11:03
@github-actions github-actions Bot added documentation Improvements or additions to documentation isaac-lab Related to Isaac Lab team labels Jul 19, 2026
@greptile-apps

greptile-apps Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds first-class OpenCV lens-distortion camera support to Isaac Lab, enabling simulation of real-camera calibrations (non-square pixels, off-center principal point, radial/tangential/thin-prism coefficients) through the RTX/OVRTX renderer natively via the omni:lensdistortion:* USD API.

  • New config types (OpenCvPinholeDistortionCfg, OpenCvFisheyeDistortionCfg) extend a common OpenCvDistortionCfg base; a distortion field is added to PinholeCameraCfg (and inherited by FisheyeCameraCfg), keeping the API renderer-agnostic.
  • Authoring path (spawn_camera_author_opencv_distortion) writes the USD API schema and all intrinsic/coefficient attributes on the camera prim; the generic camelCase loop correctly skips the distortion field.
  • Readback path (Camera._update_intrinsic_matrices) preferentially reads fx/fy/cx/cy from the authored distortion attributes when present, so camera.data.intrinsic_matrices reflects the real calibration instead of the symmetric focal-length/aperture approximation.
  • Newton renderer logs a clear warning that the model is a documented no-op pending future implementation.

Confidence Score: 4/5

Safe to merge with one targeted fix; the new spawning and USD-authoring paths are well-designed and the test coverage is thorough.

The intrinsic-matrix readback in camera.py calls float(attr.Get()) for fx, fy, cx, and cy without checking whether Get() returned None. Any prim that carries the omni:lensdistortion:model token without the companion intrinsic attributes — possible with hand-crafted USD scenes or third-party exporters — will raise TypeError on every camera update step, making the camera sensor completely unusable. The rest of the change (spawning, config classes, Newton warning, tests) is solid.

source/isaaclab/isaaclab/sensors/camera/camera.py — the fx/fy/cx/cy attribute reads in _update_intrinsic_matrices need None guards matching the existing imageSize check.

Important Files Changed

Filename Overview
source/isaaclab/isaaclab/sim/spawners/sensors/sensors_cfg.py Adds OpenCvDistortionCfg base class and two concrete subclasses (pinhole, fisheye), plus a distortion field on PinholeCameraCfg (inherited by FisheyeCameraCfg). Fields, defaults, and docstrings are well-structured.
source/isaaclab/isaaclab/sim/spawners/sensors/sensors.py Adds _author_opencv_distortion helper that writes omni:lensdistortion:* USD attrs; correctly excluded from the generic camelCase loop via _IGNORED_PARAMS. The _set_attr closure logic is sound.
source/isaaclab/isaaclab/sensors/camera/camera.py _update_intrinsic_matrices now reads fx/fy/cx/cy from the authored distortion model when present. The imageSize None-check is correct, but fx/fy/cx/cy reads call float(attr.Get()) without guarding against None, which will raise TypeError on any partially-authored prim.
source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py Adds a clear warning when a distortion cfg is present under Newton but the model is not yet applied; correctly placed before the ISP cfg guard and documents the intended extension point.
source/isaaclab/test/sensors/test_opencv_distortion.py Kit-less authoring and readback tests using a fake Camera stub. Coverage is solid: spawn, apply_lens_distortion=False zeroing, USD export survival, no-distortion case, and intrinsic matrix reconstruction.
source/isaaclab/test/sensors/test_camera_opencv_distortion_ovrtx.py Integration test rendering through OVRTX; correctly skips when optional modules are absent and uses a scaled k-coefficient to make the barrel effect detectable. The test only runs on cuda:0 with no CPU fallback.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User
    participant PinholeCameraCfg
    participant spawn_camera
    participant _author_opencv_distortion
    participant USD_Prim
    participant Camera._update_intrinsic_matrices

    User->>PinholeCameraCfg: "set distortion=OpenCvPinholeDistortionCfg(fx,fy,cx,cy,k1..k6,...)"
    User->>spawn_camera: spawn_camera(prim_path, cfg)
    spawn_camera->>USD_Prim: create/get camera prim
    spawn_camera->>spawn_camera: skip distortion in generic camelCase loop
    spawn_camera->>_author_opencv_distortion: cfg.distortion is not None
    _author_opencv_distortion->>USD_Prim: AddAppliedSchema(OmniLensDistortionOpenCvPinholeAPI)
    _author_opencv_distortion->>USD_Prim: "Set omni:lensdistortion:model = opencvPinhole"
    _author_opencv_distortion->>USD_Prim: Set omni:lensdistortion:opencvPinhole:fx/fy/cx/cy/imageSize
    _author_opencv_distortion->>USD_Prim: Set omni:lensdistortion:opencvPinhole:k1..k6/p1/p2/s1..s4
    spawn_camera-->>User: prim

    Note over Camera._update_intrinsic_matrices: Per-step update
    Camera._update_intrinsic_matrices->>USD_Prim: GetAttribute(omni:lensdistortion:model).Get()
    alt distortion model present
        USD_Prim-->>Camera._update_intrinsic_matrices: model token e.g. opencvPinhole
        Camera._update_intrinsic_matrices->>USD_Prim: Get prefix:fx / fy / cx / cy
        Camera._update_intrinsic_matrices->>Camera._update_intrinsic_matrices: build K from OpenCV intrinsics
    else no distortion
        Camera._update_intrinsic_matrices->>USD_Prim: GetFocalLengthAttr / GetHorizontalApertureAttr
        Camera._update_intrinsic_matrices->>Camera._update_intrinsic_matrices: "build K assuming fx==fy centered pp"
    end
    Camera._update_intrinsic_matrices-->>User: camera.data.intrinsic_matrices
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant User
    participant PinholeCameraCfg
    participant spawn_camera
    participant _author_opencv_distortion
    participant USD_Prim
    participant Camera._update_intrinsic_matrices

    User->>PinholeCameraCfg: "set distortion=OpenCvPinholeDistortionCfg(fx,fy,cx,cy,k1..k6,...)"
    User->>spawn_camera: spawn_camera(prim_path, cfg)
    spawn_camera->>USD_Prim: create/get camera prim
    spawn_camera->>spawn_camera: skip distortion in generic camelCase loop
    spawn_camera->>_author_opencv_distortion: cfg.distortion is not None
    _author_opencv_distortion->>USD_Prim: AddAppliedSchema(OmniLensDistortionOpenCvPinholeAPI)
    _author_opencv_distortion->>USD_Prim: "Set omni:lensdistortion:model = opencvPinhole"
    _author_opencv_distortion->>USD_Prim: Set omni:lensdistortion:opencvPinhole:fx/fy/cx/cy/imageSize
    _author_opencv_distortion->>USD_Prim: Set omni:lensdistortion:opencvPinhole:k1..k6/p1/p2/s1..s4
    spawn_camera-->>User: prim

    Note over Camera._update_intrinsic_matrices: Per-step update
    Camera._update_intrinsic_matrices->>USD_Prim: GetAttribute(omni:lensdistortion:model).Get()
    alt distortion model present
        USD_Prim-->>Camera._update_intrinsic_matrices: model token e.g. opencvPinhole
        Camera._update_intrinsic_matrices->>USD_Prim: Get prefix:fx / fy / cx / cy
        Camera._update_intrinsic_matrices->>Camera._update_intrinsic_matrices: build K from OpenCV intrinsics
    else no distortion
        Camera._update_intrinsic_matrices->>USD_Prim: GetFocalLengthAttr / GetHorizontalApertureAttr
        Camera._update_intrinsic_matrices->>Camera._update_intrinsic_matrices: "build K assuming fx==fy centered pp"
    end
    Camera._update_intrinsic_matrices-->>User: camera.data.intrinsic_matrices
Loading

Reviews (1): Last reviewed commit: "Add Lior Ben Horin to CONTRIBUTORS.md" | Re-trigger Greptile

Comment on lines +666 to +669
f_x = float(raw_prim.GetAttribute(f"{prefix}:fx").Get())
f_y = float(raw_prim.GetAttribute(f"{prefix}:fy").Get())
c_x = float(raw_prim.GetAttribute(f"{prefix}:cx").Get())
c_y = float(raw_prim.GetAttribute(f"{prefix}:cy").Get())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unguarded None from missing distortion intrinsic attrs

GetAttribute(...).Get() returns None when the attribute is not authored on the prim; float(None) then raises TypeError on every subsequent update step. The imageSize read two lines below is already None-checked, but fx/fy/cx/cy are not. A USD scene that carries omni:lensdistortion:model without the matching intrinsic attributes (e.g., a hand-authored prim or a third-party export that only writes the schema token) will crash the camera sensor continuously from the first update call.

Comment on lines +674 to +682
if not self._warned_distortion_image_size:
logger.warning(
"Camera lens-distortion 'imageSize' %s does not match the render resolution"
" (%d, %d). The reported intrinsic matrix is in the calibrated pixel space.",
(int(image_size[0]), int(image_size[1])),
width,
height,
)
self._warned_distortion_image_size = True

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Single warning flag covers all N environment cameras

_warned_distortion_image_size is a per-Camera-instance flag, but one Camera object manages N sensor prims (one per environment). Once any single env's prim triggers the warning, the flag is set and all subsequent per-prim mismatches in the same instance — including those in different environments with different calibration sizes — are silently dropped. In a large multi-env setup a user would see one warning and have no indication that the mismatch also affects all other envs. Consider including the env index in the message, or emitting the warning per prim the first time it fires.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fall back to the focal-length/aperture projection when a camera prim
carries the omni:lensdistortion:model token without the fx/fy/cx/cy
intrinsics, instead of dereferencing a None attribute value on every
update step and raising TypeError continuously.

Key the image-size mismatch warning on the authored size and include the
prim path and environment index, so a genuinely different calibration in
another environment is surfaced rather than masked by the first prim's
one-time flag.

Add regression tests covering the missing-intrinsics fallback and the
per-size warning behavior.
@liorbenhorin

Copy link
Copy Markdown
Author

greptile issues rexsolved

Extract the authored OpenCV lens-distortion readback out of the
per-camera loop in Camera._update_intrinsic_matrices into a dedicated
_read_authored_opencv_intrinsics helper. The loop body drops from four
levels of nesting to a single authored-vs-fallback branch, keeping the
warn-once guards and behavior unchanged.

Also refresh the method docstring, which still claimed intrinsics always
assume square pixels, and tidy the warning-guard comment.
The distortion field is defined on PinholeCameraCfg and inherited by
FisheyeCameraCfg, the two cfg types spawn_camera accepts, so the
defensive getattr lookup is unnecessary and inconsistent with the direct
attribute access used elsewhere in the function.
The kit-less test_opencv_distortion.py carried only a skipif and no
level marker, unlike every sibling in test/sensors/ (kit-less unit
tests use pytest.mark.unit). Without the level marker the file is not
categorized as a unit test in the JUnit report nor selected by
'-m unit', so CI's unit lane skips it. Add pytest.mark.unit to the
module-level pytestmark to match the convention.
The (height, width) viewport read is constant across the per-env loop in
_update_intrinsic_matrices; compute it once before the loop rather than on
every iteration.
Describe the Camera.data intrinsics change in the changelog without
cross-referencing a private method. Add reciprocal See Also notes between
FisheyeCameraCfg and OpenCvFisheyeDistortionCfg, and mention the optional
OpenCV calibration in the sensors spawner module docstring.

Broaden the Newton renderer warning to state that a non-square fx, the
principal point, and the distortion coefficients are all dropped (Newton
derives one field of view from fy), not only the distortion.
A camera with an authored OpenCV lens-distortion model owns its fx/fy/cx/cy
through the omni:lensdistortion:* calibration, which the readback prefers;
set_intrinsic_matrices only writes the square-pixel, centered focal-length/
aperture attributes and cannot express it.

Skip such cameras per entry (with a warning) and leave their calibration
untouched, while still updating any ordinary cameras selected in the same
call. Regression-tested with a mixed distortion/plain batch.
Render the OpenCV fisheye model and an undistorted pinhole through OVRTX
and assert the frames differ well beyond render noise, guarding that the
renderer honors the OmniLensDistortionOpenCvFisheyeAPI schema.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation isaac-lab Related to Isaac Lab team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant