camera.py (8087B)
1 # 2 # SPDX-FileCopyrightText: Copyright (c) 2021-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. 3 # SPDX-License-Identifier: Apache-2.0 4 # 5 """ 6 Implements a camera for rendering of the scene. 7 A camera defines a viewpoint for rendering. 8 """ 9 10 from .object import Object 11 import mitsuba as mi 12 import numpy as np 13 14 15 class Camera(Object): 16 # pylint: disable=line-too-long 17 r"""Camera(name, position, orientation=[0.,0.,0.], look_at=None) 18 19 A camera defines a position and view direction for rendering the scene. 20 21 In its local coordinate system, a camera looks toward the positive X-axis 22 with the positive Z-axis being the upward direction. 23 24 Input 25 ------ 26 name : str 27 Name. 28 Cannot be `"preview"`, as it is reserved for the viewpoint of the 29 interactive viewer. 30 31 position : [3], float 32 Position :math:`(x,y,z)` [m] as three-dimensional vector 33 34 orientation : [3], float 35 Orientation :math:`(\alpha, \beta, \gamma)` specified 36 through three angles corresponding to a 3D rotation 37 as defined in :eq:`rotation`. 38 This parameter is ignored if ``look_at`` is not `None`. 39 Defaults to `[0,0,0]`. 40 41 look_at : [3], float | :class:`~sionna.rt.Transmitter` | :class:`~sionna.rt.Receiver` | :class:`~sionna.rt.RIS` | :class:`~sionna.rt.Camera` | None 42 A position or the instance of a :class:`~sionna.rt.Transmitter`, 43 :class:`~sionna.rt.Receiver`, :class:`~sionna.rt.RIS`, or :class:`~sionna.rt.Camera` to look at. 44 If set to `None`, then ``orientation`` is used to orientate the device. 45 """ 46 47 # The convention of Mitsuba for camera is Y as up and look toward Z+. 48 # However, Sionna uses Z as up and looks toward X+, for consistency 49 # with radio devices. 50 # The following transform peforms a rotation to ensure Sionna's 51 # convention. 52 # Note: Mitsuba uses degrees 53 mi_2_sionna = ( mi.ScalarTransform4f.rotate([0,0,1], 90.0) 54 @ mi.ScalarTransform4f.rotate([1,0,0], 90.0) ) 55 56 def __init__(self, name, position, orientation=(0.,0.,0.), look_at=None): 57 58 # Keep track of the "to world" transform. 59 # Initialized to identity. 60 self._to_world = mi.ScalarTransform4f() 61 62 # Position and orientation are set through this call 63 super().__init__(name, position, orientation, look_at) 64 65 @property 66 def position(self): 67 """ 68 [3], float : Get/set the position :math:`(x,y,z)` as three-dimensional 69 vector 70 """ 71 return Camera.world_to_position(self._to_world) 72 73 @position.setter 74 def position(self, new_position): 75 new_position = np.array(new_position) 76 if not (new_position.ndim == 1 and new_position.shape[0] == 3): 77 msg = "Position must be shaped as [x,y,z] (rank=1 and shape=[3])" 78 raise ValueError(msg) 79 # Update transform 80 to_world = self._to_world.matrix.numpy() 81 to_world[:3,3] = new_position 82 self._to_world = mi.ScalarTransform4f(to_world) 83 84 @property 85 def orientation(self): 86 r""" 87 [3], float : Get/set the orientation :math:`(\alpha, \beta, \gamma)` 88 specified through three angles corresponding to a 3D rotation 89 as defined in :eq:`rotation`. 90 """ 91 return Camera.world_to_angles(self._to_world) 92 93 @orientation.setter 94 def orientation(self, new_orientation): 95 new_orientation = np.array(new_orientation) 96 if not (new_orientation.ndim == 1 and new_orientation.shape[0] == 3): 97 msg = "Orientation must be shaped as [a,b,c] (rank=1 and shape=[3])" 98 raise ValueError(msg) 99 100 # Mitsuba transform 101 # Note: Mitsuba uses degrees 102 new_orientation = new_orientation*180.0/np.pi 103 rot_x = mi.ScalarTransform4f.rotate([1,0,0], new_orientation[2]) 104 rot_y = mi.ScalarTransform4f.rotate([0,1,0], new_orientation[1]) 105 rot_z = mi.ScalarTransform4f.rotate([0,0,1], new_orientation[0]) 106 rot_mat = rot_z@rot_y@rot_x@Camera.mi_2_sionna 107 # Translation to keep the current position 108 trs = mi.ScalarTransform4f.translate(self.position) 109 to_world = trs@rot_mat 110 # Update in Mitsuba 111 self._to_world = to_world 112 113 def look_at(self, target): 114 r""" 115 Sets the orientation so that the camera looks at a position, radio 116 device, or another camera. 117 118 Given a point :math:`\mathbf{x}\in\mathbb{R}^3` with spherical angles 119 :math:`\theta` and :math:`\varphi`, the orientation of the camera 120 will be set equal to :math:`(\varphi, \frac{\pi}{2}-\theta, 0.0)`. 121 122 Input 123 ----- 124 target : [3], float | :class:`~sionna.rt.Transmitter` | :class:`~sionna.rt.Receiver` | :class:`~sionna.rt.Camera` | str 125 A position or the name or instance of a 126 :class:`~sionna.rt.Transmitter`, :class:`~sionna.rt.Receiver`, or 127 :class:`~sionna.rt.Camera` in the scene to look at. 128 """ 129 # Get position to look at 130 if isinstance(target, str): 131 if self.scene is None: 132 msg = f"Cannot look for radio device '{target}' as the camera"\ 133 " is not part of the scene" 134 raise ValueError(msg) 135 item = self.scene.get(target) 136 if not isinstance(item, Object): 137 msg = f"No radio device or camera named '{target}' found." 138 raise ValueError(msg) 139 else: 140 target = item.position.numpy() 141 else: 142 target = np.array(target).astype(float) 143 if not ( (target.ndim == 1) and (target.shape[0] == 3) ): 144 raise ValueError("`x` must be a three-element vector)") 145 146 # If the position and the target are on a line that is parallel to z, 147 # then the look-at transform is ill-defined as z is up. 148 # In this case, we add a small epsilon to x to avoid this. 149 if np.allclose(self.position[:2], target[:2]): 150 target[0] = target[0] + 1e-3 151 # Look-at transform 152 trf = mi.ScalarTransform4f.look_at(self.position, target, 153 [0.0, 0.0, 1.0]) # Sionna uses Z-up 154 # Set the rotation matrix of the Mitsuba sensor 155 self._to_world = trf 156 157 ############################################## 158 # Internal methods and class functions. 159 # Should not be appear in the end user 160 # documentation 161 ############################################## 162 163 @property 164 def world_transform(self): 165 return self._to_world 166 167 @staticmethod 168 def world_to_angles(to_world): 169 """ 170 Extract the orientation angles corresponding to a ``to_world`` transform 171 172 Input 173 ------ 174 to_world : :class:`~mitsuba.ScalarTransform4f` 175 Transform. 176 177 Output 178 ------- 179 : [3], float 180 Orientation angles `[a,b,c]`. 181 """ 182 183 # Undo the rotation to switch from Mitsuba to Sionna convention 184 to_world = to_world@Camera.mi_2_sionna.inverse() 185 186 # Extract the rotation matrix 187 to_world = to_world.matrix.numpy() 188 if to_world.ndim == 3: 189 to_world = to_world[0] 190 r_mat = to_world[:3,:3] 191 192 # Compute angles 193 x_ang = np.arctan2(r_mat[2,1], r_mat[2,2]) 194 y_ang = np.arctan2(-r_mat[2,0], 195 np.sqrt(np.square(r_mat[2,1]) + np.square(r_mat[2,2]))) 196 z_ang = np.arctan2(r_mat[1,0], r_mat[0,0]) 197 198 return np.array([z_ang, y_ang, x_ang]) 199 200 @staticmethod 201 def world_to_position(to_world): 202 """ 203 Extract the position corresponding to a ``to_world`` transform 204 205 Input 206 ------ 207 to_world : :class:`~mitsuba.ScalarTransform4f` 208 Transform. 209 210 Output 211 ------- 212 : [3], float 213 Position `[x,y,z]`. 214 """ 215 216 to_world = to_world.matrix.numpy() 217 if to_world.ndim == 3: 218 to_world = to_world[0] 219 position = to_world[:3,3] 220 return position