Source code for blueprints.camera

"""
Cameras provide a rendered image for each time step. A :class:`Camera` can be attached to a :class:`blueprints.body.Body` 
or to the :class:`blueprints.world.World` directly. If a Camera is attached to a :class:`blueprints.body.Body` 
in a :class:`Agent <blueprints.agent.Abent>` its rendered images are contained in the Agents observations. All 
cameras can be viewed when :meth:`blueprints.world.World.view` by setting the ``camera`` in the ``Rendering`` 
section.

Camera Orientation
------------------

Similarly to :class:`blueprints.light.Light` Cameras can either be fixed in orientation locally/globally or 
tracking another Body by setting the :attr:`Camera.mode` and optionally :attr:`Camera.target`. For a detailed 
description see :attr:`Camera.mode`.

.. code-block::
	:caption: Local orientation

	>>> body = blue.Body(cameras=blue.Camera(mode='fixed'))

.. code-block::
	:caption: Global orientation

	>>> body = blue.Body(cameras=blue.Camera(mode='track'))

.. code-block::
	:caption: Tracking Body

	>>> one  = blue.Body(name='one', cameras=blue.Camera(mode='targetbody'))
	>>> two  = blue.Body(name='two')
	>>> root = blue.Body(name='root', bodies=[one, two])
	>>> # Version one:
	>>> root.bodies['one'].cameras.target = root.bodies['two']
	>>> # Version two:
	>>> root.bodies['two'].targeting_cameras = root.bodies['one'].cameras

.. code-block:: mxml
	:caption: XML example
	
	<body name="root">
	    <body name="one">
	        <camera mode="targetbody" name="unnamed_camera" target="two" />
	    </body>
	    <body name="two" />
	</body>

Using the ``target`` attribute for a Camera results in continous tracking of the target Thing. 
If instead a Camera should be initially oriented to center an object, without tracking it during 
the simulation, the :meth:`Camera.looking` method can be used to align the camera to a position or 
direction.

.. code-block::
	:caption: Looking in a Direction

	>>> camera = camera.looking(direction=[1, 1, 1])

.. code-block:: mxml
	:caption: Looking in a Direction

	<camera euler="2.3561945 -0.6154797 0.5235988" />


To center a position either a Thing or a global position can be passed.


.. code-block::
	:caption: Looking at Positions/Things

	>>> body = blue.Body(pos=[-1, 0, 1])
	# VERSION 1
	>>> camera = camera.looking(pos=body)
	# VERSION 2
	>>> camera = camera.looking(pos=[-1, 0, 1])

.. code-block:: mxml
	:caption: Looking at Positions/Things

	<camera euler="1.5707964 2.3561945 1.5707964" />


Recording
---------
To record videos with a Camera you can use the methods :meth:`World.start_recording <blueprints.world.World.start_recording>`, 
:meth:`World.step <blueprints.world.World.step>` and :meth:`World.stop_recording <blueprints.world.World.stop_recording>`.
For more details refer to :meth:`start_recording <blueprints.world.World.start_recording>`.

.. code-block::
	:caption: Recording Videos

	>>> world.start_recording(cam_1, cam_2, cam_3, filename='my.gif', loop=0)
	>>> world.step(seconds=5)
	>>> world.stop_recording()
"""

import xml.etree.ElementTree as xml
import numpy as np
import mujoco
from mujoco.glfw import glfw

import blueprints as blue
from blueprints.utils.geometry import RADIANS_TO_DEGREES, DEGREES_TO_RADIANS, TAU
import time



[docs] class Camera(blue.CameraType, blue.thing.CyclicalThing, blue.thing.MoveableThing): """ This class is available through the shortcut :class:`blueprints.Camera <Camera>`. Most attribute descriptions are partially taken from `Mujoco <https://mujoco.readthedocs.io/en/latest/XMLreference.html#body-camera>`__. Attributes ---------- fieldofview : int | float, optional The angle of field of view in radians. ipd : float Inter-pupilary distance. This attribute only has an effect during stereoscopic rendering. It specifies the distance between the left and right viewpoints. Each viewpoint is shifted by +/- half of the distance specified here, along the X axis of the camera frame. target : blue.Body | None The target attribute specifies a Body that is tracked by the camera. Compatible :attr:`mode` values are ('targetbody', 'targetbodycom'). mode : str The possible values are: * ``'fixed'``: Position and orientation will change w.r.t. the parents frame of reference. * ``'track'``: Position is at a constant offset from the parent in world coordinates, while the camera orientation is constant in world coordinates. * ``'trackcom'``: is similar to ``'track'`` but the constant spatial offset is defined relative to the center of mass of the kinematic subtree starting at the parent Body. This can be used to keep an entire mechanism in view. Note that the subtree center of mass for the World is the center of mass of the entire model. * ``'targetbody'``: the Camera position is fixed in the parent Body, while the Camera orientation is adjusted so that it always points towards the targeted Body (which is specified with the target attribute) * ``'targetbodycom'``: is the same as ``'targetbody'`` but the Camera is oriented towards the center of mass of the subtree starting at the target Cody. """
[docs] @blue.restrict def __init__(self, pos: np.ndarray|list[int|float] = [0., 0., 0.], alpha: int|float = TAU/4, beta: int|float = 0., gamma: int|float = 0., mode: str = 'fixed', resolution: np.ndarray|list[int] = [480, 480], fieldofview: int|float = TAU/8, ipd: int|float = 0.068, fps: int|float|None = 30, name: str|None = None, x: int|float|np.int32|np.int64|np.float32|np.float64|None = None, y: int|float|np.int32|np.int64|np.float32|np.float64|None = None, z: int|float|np.int32|np.int64|np.float32|np.float64|None = None, quat: list[int|float]|np.ndarray|None = None, **kwargs) -> None: """ Parameters ---------- pos : np.ndarray | list[int | float], optional Represents the position of the object. Changing this attribute also changes the properties :attr:`x`, :attr:`y` and :attr:`z`. alpha : int | float, optional (Improper) euler angle of rotation around the x-axis in radian. Changing this value also changes the :attr:`euler` property. beta : int | float, optional (Improper) euler angle of rotation around the y-axis in radian. Changing this value also changes the :attr:`euler` property. gamma : int | float, optional (Improper) euler angle of rotation around the z-axis in radian. Changing this value also changes the :attr:`euler` property. mode : str, optional The mode defines who the position and orientation is changed over time. See :attr:`mode` for a detailed description. fieldofview : int | float, optional The angle of field of view in radians. ipd : int | float, optional Inter-pupilary distance. This attribute only has an effect during stereoscopic rendering. It specifies the distance between the left and right viewpoints. Each viewpoint is shifted by +/- half of the distance specified here, along the X axis of the camera frame. name : str | None, optional The user specified name of the Camera. It might change in the case of a naming conflict. x : int | float |np.int32 | np.int64 | np.float32 | np.float64 | None, optional If `pos` is not specified, this argument sets the X position coordinate. y : int | float |np.int32 | np.int64 | np.float32 | np.float64 | None, optional If `pos` is not specified, this argument sets the Y position coordinate. z : int | float |np.int32 | np.int64 | np.float32 | np.float64 | None, optional If `pos` is not specified, this argument sets the Z position coordinate. quat: list [ int | float ] | np.ndarray | None, optional If set, the quaternion orientation overwrites the euler angles ``alpha``, ``beta`` and ``gamma``. **kwargs Keyword arguments are passed to ``super().__init__``. """ assert mode in self._MODES self.resolution = resolution self.mode = mode self.fieldofview = float(fieldofview) self.ipd = ipd self.fps = fps super().__init__(pos=pos, alpha=alpha, beta=beta, gamma=gamma, quat=quat, name=name, x=x, y=y, z=z, **kwargs)
@property def observation(self): """ The Camera observation is retrieved as the image rendered by the mujoco Renderer using OpenGL. Returns ------- np.ndarray RGB image using the specified camera settings. """ if not isinstance(self.root, blue.WorldType): raise Exception(f'Camera observations can only be called once they have been build as part of a World.') width, height = self.resolution #context = mujoco.GLContext(height, width) if not hasattr(self, '_renderer'): #self._renderer = mujoco.Renderer(self.root._mj_model, width, height) self._renderer = mujoco.Renderer(self.root._mj_model, height, width) self._last_render = float('-inf') if self.root._mj_data.time > self._last_render + self.frameinterval: self._renderer.update_scene(self.root._mj_data, self.name) for geom in self.root.all.geoms: if isinstance(geom, blue.HFieldGeomType): mujoco.mjr_uploadHField(self.root._mj_model, self._renderer._mjr_context, geom._index) self._rgb = self._renderer.render() self._last_render = self.root._mj_data.time #print() #exit() #context.free() return self._rgb
[docs] @blue.restrict def start_recording(self, filename: str, fps: int = 60, **kwargs): """ This shorthand method starts the recording of a video. It can only be used if the camera is already attached within a :class:`World <blueprints.world.World>`. For details read :meth:`World.start_recording <blueprints.world.World.start_recording>`. Parameters ---------- filename : str The name (with a proper file extension indicating the format) of the video. If multiple cameras are passed, multiple videos will be created with the name of each camera prefixed to the filename. fps : int The frames per second **kwargs : dict Additional keyword arguments that are passed to imageio to write the video. """ if isinstance(self.root, blue.WorldType): self.root.start_recording(self, filename=filename, fps=fps, **kwargs) else: raise Exception('A camera can only start recording if it is inhabitant of a World.')
[docs] @blue.restrict def stop_recording(self): """ This shorthand method stops the recording of a video. It can only be used if the camera is already attached within a :class:`World <blueprints.world.World>`. For details read :meth:`World.start_recording <blueprints.world.World.start_recording>`. """ if isinstance(self.root, blue.WorldType): self.root.stop_recording(self) else: raise Exception('A camera can only stop recording if it is inhabitant of a World.')
[docs] @blue.restrict def looking(self, pos: list[int|float]|np.ndarray|blue.MoveableThingType|None = None, direction: list[int|float]|np.ndarray|None = None, **kwargs) -> blue.CameraType: """ The method creates a new camera that is focussed on pos. The Camera orientation is adjusted to center the ``pos`` argument in the image. Alternatively a direction in which the camera should look can be specified. This only constraints two degrees of freedom, so the thrid degree is chosen such that the camera records parallel to the ground. Parameters ---------- pos : list [ int | float] | np.ndarray | blue.MoveableThingType | None If set, it can either be a (global) position or a :class:`MoveableThing <blueprints.thing.moveable.MoveableThing>` that will be centered direction : list [ int | float] | np.ndarray | None If set, the returned camera will be oriented parallel to the direction vector. Returns ------- blue.CameraType A new Camera looking into the specified direction or onto the specified position/Thing """ if pos is None and direction is None: raise ValueError(f'Precisely one argument pos or direction has to be set, but both were None.') if pos is not None and direction is not None: raise ValueError(f'Precisely one argument pos or direction has to be set, but both were given: pos={pos} direction={direction}') if pos is not None: if isinstance(pos, list): pos = np.array(pos, dtype=np.float32) head = self.global_pos tail = self._get_pos(pos) path = tail - head else: path = direction # BORROWING MATH FROM TUBE length = np.linalg.norm(path) # RECONSTRUCT REFERENCE FRAME R_z = path/length R_z1, R_z2, R_z3 = R_z if R_z1 == R_z2 == 0: alpha = 0 if R_z3 > 0 else blue.geometry.PI beta = 0 gamma = 0 else: R_x = blue.geometry.Vector.normalize(np.array([R_z2, -R_z1, 0])) R_y = np.cross(R_z, R_x) R = np.stack([R_x,-R_y,-R_z], axis=1) # RECONSTRUCT ANGLES alpha, beta, gamma = blue.geometry.Rotation.reference_frame_to_euler(R) return self.align(alpha=alpha, beta=beta, gamma=gamma, globally=True, **kwargs)
# XML METHODS/FUNCTIONS @blue.restrict @classmethod def _from_xml_element(cls, xml_element) -> blue.ThingType: init_args, post_args, rest_args = cls._xml_element_args(xml_element) init_args['copy'] = False obj = object.__new__(cls) obj.__init__(**init_args) for key, val in post_args.items(): setattr(obj, key, val) if 'target' in rest_args: obj._target_name = rest_args['target'] return obj @blue.restrict def _build(self, parent, world, indicies, **kwargs): """ This method is called to build the xml. Parameters ---------- parent : xml.etree.ElementTree.Element The xml element of its parent world : WorldType The World from which the build method was called initially Returns ------- xml.etree.ElementTree.Element The builded xml element of the Thing. """ if self.target is not None: return super()._build(parent, world, indicies, target=self.target.name) else: return super()._build(parent, world, indicies) # KINEMATIC TREE PROPERTIES @property def target(self) -> blue.BodyType|None: """ The target attribute specifies a Body that is tracked by the camera. Compatible :attr:`mode` values are ('targetbody', 'targetbodycom'). Returns ------- blue.BodyType | None """ if hasattr(self, '_target'): return self._target else: return None @target.setter @blue.restrict def target(self, target: blue.BodyType|None) -> None: """ The target attribute specifies a Body that is tracked by the camera. Compatible :attr:`mode` values are ('targetbody', 'targetbodycom'). Parameters ---------- target : blue.BodyType | None A body which is tracked by the camera. The Body must be part of the same kinematic tree as he Camera. Raises ------ ValueError If the parent is not part of the same kinematic tree or the camera has no parent an error is raised. """ if self.parent is None and not self._IGNORE_CHECKS: raise ValueError(f'To set the target reference, parent must be set first.') if self.parent is not None and self.parent is target: raise ValueError(f'Camera target cannot be the same as its parent. Use the modes ("fixed", "track" "trackcom") instead.') if self.target is not None: self.target.detach(self) if target is not None: target._targeting_cameras.append(self) self._target = target # XML PROPERTIES @property def resolution(self) -> np.ndarray: """ The resolution is used for :attr:`observation` rendering and :meth:`recording <blueprints.world.World.start_recording>`. Rendering in :meth:`World.view <blueprints.world.World.view>` uses OpenGL defaults. Returns ------- np.ndarray The resolution of the camera. """ return self._resolution.copy() @resolution.setter @blue.restrict def resolution(self, resolution: np.ndarray|list[int|float]) -> None: if isinstance(resolution, np.ndarray) and resolution.shape != (2,): raise ValueError(f'Position attribute resolution must have a dimension of 2 got {resolution.shape} instead.') if isinstance(resolution, list) and len(resolution) != 2: raise ValueError(f'Position attribute resolution must have a dimension of 2 got {len(resolution)} instead.') self._resolution = np.array(resolution, dtype=np.uint16) @property @blue.restrict def ipd(self) -> float: """ This attribute only has an effect during stereoscopic rendering. It specifies the distance between the left and right viewpoints. Each viewpoint is shifted by :math:`\\pm` half of the distance specified here, along the :math:`X` axis of the camera frame. Returns ------- float Inter-pupilary distance """ return self._ipd @ipd.setter @blue.restrict def ipd(self, ipd: int|float) -> None: """ Parameters ---------- ipd : float | int Inter-pupilary distance """ self._ipd = float(ipd) @property @blue.restrict def fieldofview(self) -> float: """ Vertical field-of-view of the camera. Returns ------- float Field of view """ return self._fieldofview @fieldofview.setter @blue.restrict def fieldofview(self, fieldofview: int|float) -> None: """ Parameters ---------- fieldofview : float | int Field of view """ self._fieldofview = float(fieldofview) @property @blue.restrict def fps(self) -> float|None: """ Frames per second Returns ------- float """ return self._fps @fps.setter @blue.restrict def fps(self, fps: int|float|None) -> None: """ If `fps` is set to `None` a new image will be rendered on each forward step of the physics engine. Parameters ---------- fps : float | int | None Frames per second """ if fps is not None and fps <= 0.: raise ValueError('Frames per second can only be positive.') self._fps = float(fps) if fps is not None else None # DERIVATIONS @property def fovy(self) -> float: """ Vertical field-of-view of the camera. Returns ------- float field of view in degrees """ return self.fieldofview * RADIANS_TO_DEGREES @fovy.setter @blue.restrict def fovy(self, fovy: float): """ Parameters ---------- fovy : float field of view in degrees """ self.fieldofview = fovy * DEGREES_TO_RADIANS @property def frameinterval(self) -> float: """ The delay between each rendering in seconds Returns ------- float """ if self.fps is None: return 0. else: return 1/self.fps