Skip to content

Commit d89463b

Browse files
authored
Add OpenCV pinhole camera rays (#3901)
1 parent 37b75a2 commit d89463b

5 files changed

Lines changed: 568 additions & 2 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add `SensorTiledCamera.utils.compute_camera_rays_pinhole_opencv()` for OpenCV pinhole cameras with radial, tangential, and thin-prism distortion.

docs/concepts/sensors.rst

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,8 @@ attributes, and usage examples.
117117
* :class:`~newton.sensors.SensorIMU` -- linear acceleration and angular velocity at site frames.
118118
* :class:`~newton.sensors.SensorTiledCamera` -- raytraced color and depth rendering across multiple worlds.
119119

120-
Camera Rays from USD Data
121-
-------------------------
120+
Camera Rays from USD and Calibration Data
121+
-----------------------------------------
122122

123123
``SensorTiledCamera`` can build standard USD pinhole camera rays directly. For lens models without standard USD
124124
attributes, read the attributes you use in your pipeline and pass the numeric values into the matching helper:
@@ -144,6 +144,10 @@ attributes, read the attributes you use in your pipeline and pass the numeric va
144144
color_image=color,
145145
)
146146
147+
For OpenCV-calibrated pinhole cameras, call
148+
:meth:`~newton.sensors.SensorTiledCamera.Utils.compute_camera_rays_pinhole_opencv` with the calibrated intrinsics and
149+
radial, tangential, and optional thin-prism coefficients.
150+
147151
For fisheye cameras, extract the calibration values from your chosen USD attributes and call one of
148152
:meth:`~newton.sensors.SensorTiledCamera.Utils.compute_camera_rays_fisheye_opencv`,
149153
:meth:`~newton.sensors.SensorTiledCamera.Utils.compute_camera_rays_fisheye_ftheta`, or

newton/_src/sensors/warp_raytrace/camera_utils.py

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,232 @@ def compute_camera_rays_pinhole_from_aperture_kernel(
440440
out_rays[output_camera_index, py, px, 1] = wp.normalize(ray_direction_camera_space)
441441

442442

443+
_PINHOLE_OPENCV_INVERSION_ITERATIONS = 10
444+
_PINHOLE_OPENCV_LINE_SEARCH_ITERATIONS = 6
445+
# Residuals are in normalized image coordinates; convergence ~1e-6, acceptance ~2e-5 RMS.
446+
_PINHOLE_OPENCV_CONVERGENCE_TOLERANCE_SQUARED = 1.0e-12
447+
_PINHOLE_OPENCV_ACCEPTANCE_TOLERANCE_SQUARED = 4.0e-10
448+
_PINHOLE_OPENCV_SOLVER_EPSILON = 1.0e-8
449+
450+
451+
@wp.func
452+
def _distort_pinhole_opencv(
453+
x: wp.float32,
454+
y: wp.float32,
455+
k1: wp.float32,
456+
k2: wp.float32,
457+
k3: wp.float32,
458+
k4: wp.float32,
459+
k5: wp.float32,
460+
k6: wp.float32,
461+
p1: wp.float32,
462+
p2: wp.float32,
463+
s1: wp.float32,
464+
s2: wp.float32,
465+
s3: wp.float32,
466+
s4: wp.float32,
467+
) -> wp.vec2f:
468+
radius_squared = x * x + y * y
469+
radius_fourth = radius_squared * radius_squared
470+
radius_sixth = radius_fourth * radius_squared
471+
radial = (1.0 + k1 * radius_squared + k2 * radius_fourth + k3 * radius_sixth) / (
472+
1.0 + k4 * radius_squared + k5 * radius_fourth + k6 * radius_sixth
473+
)
474+
tangential_prism_x = (
475+
2.0 * p1 * x * y + p2 * (radius_squared + 2.0 * x * x) + s1 * radius_squared + s2 * radius_fourth
476+
)
477+
tangential_prism_y = (
478+
p1 * (radius_squared + 2.0 * y * y) + 2.0 * p2 * x * y + s3 * radius_squared + s4 * radius_fourth
479+
)
480+
return wp.vec2f(x * radial + tangential_prism_x, y * radial + tangential_prism_y)
481+
482+
483+
@wp.func
484+
def _invert_pinhole_opencv(
485+
x_distorted: wp.float32,
486+
y_distorted: wp.float32,
487+
k1: wp.float32,
488+
k2: wp.float32,
489+
k3: wp.float32,
490+
k4: wp.float32,
491+
k5: wp.float32,
492+
k6: wp.float32,
493+
p1: wp.float32,
494+
p2: wp.float32,
495+
s1: wp.float32,
496+
s2: wp.float32,
497+
s3: wp.float32,
498+
s4: wp.float32,
499+
) -> wp.vec3f:
500+
# Damped Newton inversion; unverifiable pixels return the zero sentinel.
501+
x = x_distorted
502+
y = y_distorted
503+
valid = True
504+
done = False
505+
for _ in range(_PINHOLE_OPENCV_INVERSION_ITERATIONS):
506+
if valid and not done:
507+
distorted = _distort_pinhole_opencv(x, y, k1, k2, k3, k4, k5, k6, p1, p2, s1, s2, s3, s4)
508+
residual_x = distorted[0] - x_distorted
509+
residual_y = distorted[1] - y_distorted
510+
residual_squared = residual_x * residual_x + residual_y * residual_y
511+
512+
if not wp.isfinite(residual_squared):
513+
valid = False
514+
elif residual_squared <= _PINHOLE_OPENCV_CONVERGENCE_TOLERANCE_SQUARED:
515+
done = True
516+
else:
517+
radius_squared = x * x + y * y
518+
radius_fourth = radius_squared * radius_squared
519+
radius_sixth = radius_fourth * radius_squared
520+
numerator = 1.0 + k1 * radius_squared + k2 * radius_fourth + k3 * radius_sixth
521+
denominator = 1.0 + k4 * radius_squared + k5 * radius_fourth + k6 * radius_sixth
522+
523+
if wp.abs(denominator) <= _PINHOLE_OPENCV_SOLVER_EPSILON:
524+
valid = False
525+
else:
526+
radial = numerator / denominator
527+
numerator_derivative = k1 + 2.0 * k2 * radius_squared + 3.0 * k3 * radius_fourth
528+
denominator_derivative = k4 + 2.0 * k5 * radius_squared + 3.0 * k6 * radius_fourth
529+
radial_derivative = (numerator_derivative * denominator - numerator * denominator_derivative) / (
530+
denominator * denominator
531+
)
532+
radial_derivative_x = 2.0 * x * radial_derivative
533+
radial_derivative_y = 2.0 * y * radial_derivative
534+
535+
jacobian_00 = (
536+
radial
537+
+ x * radial_derivative_x
538+
+ 2.0 * p1 * y
539+
+ 6.0 * p2 * x
540+
+ 2.0 * s1 * x
541+
+ 4.0 * s2 * radius_squared * x
542+
)
543+
jacobian_01 = (
544+
x * radial_derivative_y
545+
+ 2.0 * p1 * x
546+
+ 2.0 * p2 * y
547+
+ 2.0 * s1 * y
548+
+ 4.0 * s2 * radius_squared * y
549+
)
550+
jacobian_10 = (
551+
y * radial_derivative_x
552+
+ 2.0 * p1 * x
553+
+ 2.0 * p2 * y
554+
+ 2.0 * s3 * x
555+
+ 4.0 * s4 * radius_squared * x
556+
)
557+
jacobian_11 = (
558+
radial
559+
+ y * radial_derivative_y
560+
+ 6.0 * p1 * y
561+
+ 2.0 * p2 * x
562+
+ 2.0 * s3 * y
563+
+ 4.0 * s4 * radius_squared * y
564+
)
565+
determinant = jacobian_00 * jacobian_11 - jacobian_01 * jacobian_10
566+
567+
if not wp.isfinite(determinant) or wp.abs(determinant) <= _PINHOLE_OPENCV_SOLVER_EPSILON:
568+
valid = False
569+
else:
570+
delta_x = (jacobian_11 * residual_x - jacobian_01 * residual_y) / determinant
571+
delta_y = (jacobian_00 * residual_y - jacobian_10 * residual_x) / determinant
572+
if not wp.isfinite(delta_x) or not wp.isfinite(delta_y):
573+
valid = False
574+
else:
575+
base_x = x
576+
base_y = y
577+
step = 1.0
578+
accepted = False
579+
for _line_search_iteration in range(_PINHOLE_OPENCV_LINE_SEARCH_ITERATIONS):
580+
candidate_x = base_x - step * delta_x
581+
candidate_y = base_y - step * delta_y
582+
candidate_distorted = _distort_pinhole_opencv(
583+
candidate_x,
584+
candidate_y,
585+
k1,
586+
k2,
587+
k3,
588+
k4,
589+
k5,
590+
k6,
591+
p1,
592+
p2,
593+
s1,
594+
s2,
595+
s3,
596+
s4,
597+
)
598+
candidate_residual_x = candidate_distorted[0] - x_distorted
599+
candidate_residual_y = candidate_distorted[1] - y_distorted
600+
candidate_residual_squared = (
601+
candidate_residual_x * candidate_residual_x
602+
+ candidate_residual_y * candidate_residual_y
603+
)
604+
if (
605+
not accepted
606+
and wp.isfinite(candidate_residual_squared)
607+
and candidate_residual_squared < residual_squared
608+
):
609+
x = candidate_x
610+
y = candidate_y
611+
accepted = True
612+
step *= 0.5
613+
# Stalled: no step reduced the residual; stop iterating.
614+
if not accepted:
615+
done = True
616+
617+
# Accept only if the forward model reproduces the target within tolerance.
618+
if valid:
619+
distorted = _distort_pinhole_opencv(x, y, k1, k2, k3, k4, k5, k6, p1, p2, s1, s2, s3, s4)
620+
residual_x = distorted[0] - x_distorted
621+
residual_y = distorted[1] - y_distorted
622+
residual_squared = residual_x * residual_x + residual_y * residual_y
623+
valid = wp.isfinite(residual_squared) and residual_squared <= _PINHOLE_OPENCV_ACCEPTANCE_TOLERANCE_SQUARED
624+
625+
if valid:
626+
return wp.normalize(wp.vec3f(x, -y, -1.0))
627+
return wp.vec3f(0.0)
628+
629+
630+
@wp.kernel(enable_backward=False)
631+
def compute_camera_rays_pinhole_opencv_kernel(
632+
width: int,
633+
height: int,
634+
image_width: wp.float32,
635+
image_height: wp.float32,
636+
fx: wp.float32,
637+
fy: wp.float32,
638+
cx: wp.float32,
639+
cy: wp.float32,
640+
k1: wp.float32,
641+
k2: wp.float32,
642+
k3: wp.float32,
643+
k4: wp.float32,
644+
k5: wp.float32,
645+
k6: wp.float32,
646+
p1: wp.float32,
647+
p2: wp.float32,
648+
s1: wp.float32,
649+
s2: wp.float32,
650+
s3: wp.float32,
651+
s4: wp.float32,
652+
camera_index: int,
653+
out_rays: wp.array4d[wp.vec3f],
654+
):
655+
py, px = wp.tid()
656+
u = ((float(px) + 0.5) / float(width)) * image_width
657+
v = ((float(py) + 0.5) / float(height)) * image_height
658+
x_distorted = (u - cx) / fx
659+
y_distorted = (v - cy) / fy
660+
661+
ray_direction_camera_space = _invert_pinhole_opencv(
662+
x_distorted, y_distorted, k1, k2, k3, k4, k5, k6, p1, p2, s1, s2, s3, s4
663+
)
664+
665+
out_rays[camera_index, py, px, 0] = wp.vec3f(0.0)
666+
out_rays[camera_index, py, px, 1] = ray_direction_camera_space
667+
668+
443669
@wp.kernel(enable_backward=False)
444670
def compute_camera_rays_fisheye_opencv_kernel(
445671
width: int,

newton/_src/sensors/warp_raytrace/utils.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,124 @@ def compute_camera_rays_usd_pinhole(
674674
camera_index=camera_index,
675675
)
676676

677+
def compute_camera_rays_pinhole_opencv(
678+
self,
679+
width: int,
680+
height: int,
681+
fx: float,
682+
fy: float,
683+
cx: float,
684+
cy: float,
685+
*,
686+
image_width: float | None = None,
687+
image_height: float | None = None,
688+
k1: float = 0.0,
689+
k2: float = 0.0,
690+
k3: float = 0.0,
691+
k4: float = 0.0,
692+
k5: float = 0.0,
693+
k6: float = 0.0,
694+
p1: float = 0.0,
695+
p2: float = 0.0,
696+
s1: float = 0.0,
697+
s2: float = 0.0,
698+
s3: float = 0.0,
699+
s4: float = 0.0,
700+
out_rays: wp.array4d[wp.vec3f] | None = None,
701+
camera_index: int = 0,
702+
) -> wp.array4d[wp.vec3f]:
703+
"""Compute camera-space ray directions for OpenCV pinhole cameras.
704+
705+
Inverts OpenCV's rational radial, tangential, and thin-prism distortion
706+
model with damped Newton iteration. The four- and five-coefficient
707+
variants are represented by leaving unused coefficients at zero. The
708+
forward distortion mapping must be invertible over the calibrated image
709+
domain. Pixels whose inverse cannot be verified within the solver
710+
tolerance receive a zero direction.
711+
712+
Args:
713+
width: Output image width [px].
714+
height: Output image height [px].
715+
fx: Horizontal focal length [px].
716+
fy: Vertical focal length [px].
717+
cx: Principal point x-coordinate [px].
718+
cy: Principal point y-coordinate [px].
719+
image_width: Calibration image width [px]. If ``None``, uses
720+
*width*.
721+
image_height: Calibration image height [px]. If ``None``, uses
722+
*height*.
723+
k1: First numerator radial distortion coefficient.
724+
k2: Second numerator radial distortion coefficient.
725+
k3: Third numerator radial distortion coefficient.
726+
k4: First denominator radial distortion coefficient.
727+
k5: Second denominator radial distortion coefficient.
728+
k6: Third denominator radial distortion coefficient.
729+
p1: First tangential distortion coefficient.
730+
p2: Second tangential distortion coefficient.
731+
s1: First thin-prism distortion coefficient.
732+
s2: Second thin-prism distortion coefficient.
733+
s3: Third thin-prism distortion coefficient.
734+
s4: Fourth thin-prism distortion coefficient.
735+
out_rays: Optional output array to write into, shape
736+
``(out_camera_count, height, width, 2)``. If ``None``,
737+
allocates a new array.
738+
camera_index: Camera index in *out_rays* to write. Ignored when
739+
*out_rays* is ``None``.
740+
741+
Returns:
742+
camera_rays: *out_rays* if provided, otherwise a new array with
743+
shape ``(1, height, width, 2)`` and dtype ``vec3f``.
744+
745+
Raises:
746+
ValueError: If any calibration value is non-finite, or if focal
747+
lengths or calibration image dimensions are non-positive.
748+
"""
749+
image_width = width if image_width is None else image_width
750+
image_height = height if image_height is None else image_height
751+
if not (math.isfinite(fx) and math.isfinite(fy) and fx > 0.0 and fy > 0.0):
752+
raise ValueError("fx and fy must be finite and positive.")
753+
if not (
754+
math.isfinite(image_width) and math.isfinite(image_height) and image_width > 0.0 and image_height > 0.0
755+
):
756+
raise ValueError("image_width and image_height must be finite and positive.")
757+
if not all(math.isfinite(value) for value in (cx, cy, k1, k2, k3, k4, k5, k6, p1, p2, s1, s2, s3, s4)):
758+
raise ValueError("cx, cy, and distortion coefficients must be finite.")
759+
out_rays, camera_index = camera_utils._validate_camera_ray_output(
760+
width, height, 1, out_rays, camera_index, self.__render_context.device
761+
)
762+
763+
wp.launch(
764+
kernel=camera_utils.compute_camera_rays_pinhole_opencv_kernel,
765+
dim=(height, width),
766+
inputs=[
767+
width,
768+
height,
769+
image_width,
770+
image_height,
771+
fx,
772+
fy,
773+
cx,
774+
cy,
775+
k1,
776+
k2,
777+
k3,
778+
k4,
779+
k5,
780+
k6,
781+
p1,
782+
p2,
783+
s1,
784+
s2,
785+
s3,
786+
s4,
787+
camera_index,
788+
out_rays,
789+
],
790+
device=self.__render_context.device,
791+
)
792+
793+
return out_rays
794+
677795
def compute_camera_rays_fisheye_opencv(
678796
self,
679797
width: int,

0 commit comments

Comments
 (0)