Source code for silx.gui.plot3d.scene.camera

# /*##########################################################################
#
# Copyright (c) 2015-2018 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ###########################################################################*/
"""This module provides classes to handle a perspective projection in 3D."""

__authors__ = ["T. Vincent"]
__license__ = "MIT"
__date__ = "25/07/2016"


import numpy

from . import transform


# CameraExtrinsic #############################################################


[docs] class CameraExtrinsic(transform.Transform): """Transform matrix to handle camera position and orientation. :param position: Coordinates of the point of view. :type position: numpy.ndarray-like of 3 float32. :param direction: Sight direction vector. :type direction: numpy.ndarray-like of 3 float32. :param up: Vector pointing upward in the image plane. :type up: numpy.ndarray-like of 3 float32. """ def __init__( self, position=(0.0, 0.0, 0.0), direction=(0.0, 0.0, -1.0), up=(0.0, 1.0, 0.0) ): super(CameraExtrinsic, self).__init__() self._position = None self.position = position # set _position self._side = 1.0, 0.0, 0.0 self._up = 0.0, 1.0, 0.0 self._direction = 0.0, 0.0, -1.0 self.setOrientation(direction=direction, up=up) # set _direction, _up def _makeMatrix(self): return transform.mat4LookAtDir(self._position, self._direction, self._up)
[docs] def copy(self): """Return an independent copy""" return CameraExtrinsic(self.position, self.direction, self.up)
[docs] def setOrientation(self, direction=None, up=None): """Set the rotation of the point of view. :param direction: Sight direction vector or None to keep the current one. :type direction: numpy.ndarray-like of 3 float32 or None. :param up: Vector pointing upward in the image plane or None to keep the current one. :type up: numpy.ndarray-like of 3 float32 or None. :raises RuntimeError: if the direction and up are parallel. """ if direction is None: # Use current direction direction = self.direction else: assert len(direction) == 3 direction = numpy.array(direction, copy=True, dtype=numpy.float32) direction /= numpy.linalg.norm(direction) if up is None: # Use current up up = self.up else: assert len(up) == 3 up = numpy.array(up, copy=True, dtype=numpy.float32) # Update side and up to make sure they are perpendicular and normalized side = numpy.cross(direction, up) sidenormal = numpy.linalg.norm(side) if sidenormal == 0.0: raise RuntimeError("direction and up vectors are parallel.") # Alternative: when one of the input parameter is None, it is # possible to guess correct vectors using previous direction and up side /= sidenormal up = numpy.cross(side, direction) up /= numpy.linalg.norm(up) self._side = side self._up = up self._direction = direction self.notify()
@property def position(self): """Coordinates of the point of view as a numpy.ndarray of 3 float32.""" return self._position.copy() @position.setter def position(self, position): assert len(position) == 3 self._position = numpy.array(position, copy=True, dtype=numpy.float32) self.notify() @property def direction(self): """Sight direction (ndarray of 3 float32).""" return self._direction.copy() @direction.setter def direction(self, direction): self.setOrientation(direction=direction) @property def up(self): """Vector pointing upward in the image plane (ndarray of 3 float32).""" return self._up.copy() @up.setter def up(self, up): self.setOrientation(up=up) @property def side(self): """Vector pointing towards the side of the image plane. ndarray of 3 float32""" return self._side.copy()
[docs] def move(self, direction, step=1.0): """Move the camera relative to the image plane. :param str direction: Direction relative to image plane. One of: 'up', 'down', 'left', 'right', 'forward', 'backward'. :param float step: The step of the pan to perform in the coordinate in which the camera position is defined. """ if direction in ("up", "down"): vector = self.up * (1.0 if direction == "up" else -1.0) elif direction in ("left", "right"): vector = self.side * (1.0 if direction == "right" else -1.0) elif direction in ("forward", "backward"): vector = self.direction * (1.0 if direction == "forward" else -1.0) else: raise ValueError("Unsupported direction: %s" % direction) self.position += step * vector
[docs] def rotate(self, direction, angle=1.0): """First-person rotation of the camera towards the direction. :param str direction: Direction of movement relative to image plane. In: 'up', 'down', 'left', 'right'. :param float angle: The angle in degrees of the rotation. """ if direction in ("up", "down"): axis = self.side * (1.0 if direction == "up" else -1.0) elif direction in ("left", "right"): axis = self.up * (1.0 if direction == "left" else -1.0) else: raise ValueError("Unsupported direction: %s" % direction) matrix = transform.mat4RotateFromAngleAxis(numpy.radians(angle), *axis) newdir = numpy.dot(matrix[:3, :3], self.direction) if direction in ("up", "down"): # Rotate up to avoid up and new direction to be (almost) co-linear newup = numpy.dot(matrix[:3, :3], self.up) self.setOrientation(newdir, newup) else: # No need to rotate up here as it is the rotation axis self.direction = newdir
[docs] def orbit(self, direction, center=(0.0, 0.0, 0.0), angle=1.0): """Rotate the camera around a point. :param str direction: Direction of movement relative to image plane. In: 'up', 'down', 'left', 'right'. :param center: Position around which to rotate the point of view. :type center: numpy.ndarray-like of 3 float32. :param float angle: he angle in degrees of the rotation. """ if direction in ("up", "down"): axis = self.side * (1.0 if direction == "down" else -1.0) elif direction in ("left", "right"): axis = self.up * (1.0 if direction == "right" else -1.0) else: raise ValueError("Unsupported direction: %s" % direction) # Rotate viewing direction rotmatrix = transform.mat4RotateFromAngleAxis(numpy.radians(angle), *axis) self.direction = numpy.dot(rotmatrix[:3, :3], self.direction) # Rotate position around center center = numpy.asarray(center, dtype=numpy.float32) matrix = numpy.dot(transform.mat4Translate(*center), rotmatrix) matrix = numpy.dot(matrix, transform.mat4Translate(*(-center))) position = numpy.append(self.position, 1.0) self.position = numpy.dot(matrix, position)[:3]
_RESET_CAMERA_ORIENTATIONS = { "side": ((-1.0, -1.0, -1.0), (0.0, 1.0, 0.0)), "front": ((0.0, 0.0, -1.0), (0.0, 1.0, 0.0)), "back": ((0.0, 0.0, 1.0), (0.0, 1.0, 0.0)), "top": ((0.0, -1.0, 0.0), (0.0, 0.0, -1.0)), "bottom": ((0.0, 1.0, 0.0), (0.0, 0.0, 1.0)), "right": ((-1.0, 0.0, 0.0), (0.0, 1.0, 0.0)), "left": ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0)), }
[docs] def reset(self, face=None): """Reset the camera position to pre-defined orientations. :param str face: The direction of the camera in: side, front, back, top, bottom, right, left. """ if face not in self._RESET_CAMERA_ORIENTATIONS: raise ValueError("Unsupported face: %s" % face) distance = numpy.linalg.norm(self.position) direction, up = self._RESET_CAMERA_ORIENTATIONS[face] self.setOrientation(direction, up) self.position = -self.direction * distance
[docs] class Camera(transform.Transform): """Combination of camera projection and position. See :class:`Perspective` and :class:`CameraExtrinsic`. :param float fovy: Vertical field-of-view in degrees. :param float near: The near clipping plane Z coord (strictly positive). :param float far: The far clipping plane Z coord (> near). :param size: Viewport's size used to compute the aspect ratio (width, height). :type size: 2-tuple of float :param position: Coordinates of the point of view. :type position: numpy.ndarray-like of 3 float32. :param direction: Sight direction vector. :type direction: numpy.ndarray-like of 3 float32. :param up: Vector pointing upward in the image plane. :type up: numpy.ndarray-like of 3 float32. """ def __init__( self, fovy=30.0, near=0.1, far=1.0, size=(1.0, 1.0), position=(0.0, 0.0, 0.0), direction=(0.0, 0.0, -1.0), up=(0.0, 1.0, 0.0), ): super(Camera, self).__init__() self._intrinsic = transform.Perspective(fovy, near, far, size) self._intrinsic.addListener(self._transformChanged) self._extrinsic = CameraExtrinsic(position, direction, up) self._extrinsic.addListener(self._transformChanged) def _makeMatrix(self): return numpy.dot(self.intrinsic.matrix, self.extrinsic.matrix) def _transformChanged(self, source): """Listener of intrinsic and extrinsic camera parameters instances.""" if source is not self: self.notify()
[docs] def resetCamera(self, bounds): """Change camera to have the bounds in the viewing frustum. It updates the camera position and depth extent. Camera sight direction and up are not affected. :param bounds: The axes-aligned bounds to include. :type bounds: numpy.ndarray: ((xMin, yMin, zMin), (xMax, yMax, zMax)) """ center = 0.5 * (bounds[0] + bounds[1]) radius = numpy.linalg.norm(0.5 * (bounds[1] - bounds[0])) if radius == 0.0: # bounds are all collapsed radius = 1.0 if isinstance(self.intrinsic, transform.Perspective): # Get the viewpoint distance from the bounds center minfov = numpy.radians(self.intrinsic.fovy) width, height = self.intrinsic.size if width < height: minfov *= width / height offset = radius / numpy.sin(0.5 * minfov) # Update camera self.extrinsic.position = center - offset * self.extrinsic.direction self.intrinsic.setDepthExtent(offset - radius, offset + radius) elif isinstance(self.intrinsic, transform.Orthographic): # Y goes up self.intrinsic.setClipping( left=center[0] - radius, right=center[0] + radius, bottom=center[1] - radius, top=center[1] + radius, ) # Update camera self.extrinsic.position = 0, 0, 0 self.intrinsic.setDepthExtent(center[2] - radius, center[2] + radius) else: raise RuntimeError("Unsupported camera: %s" % self.intrinsic)
@property def intrinsic(self): """Intrinsic camera parameters, i.e., projection matrix.""" return self._intrinsic @intrinsic.setter def intrinsic(self, intrinsic): self._intrinsic.removeListener(self._transformChanged) self._intrinsic = intrinsic self._intrinsic.addListener(self._transformChanged) @property def extrinsic(self): """Extrinsic camera parameters, i.e., position and orientation.""" return self._extrinsic
[docs] def move(self, *args, **kwargs): """See :meth:`CameraExtrinsic.move`.""" self.extrinsic.move(*args, **kwargs)
[docs] def rotate(self, *args, **kwargs): """See :meth:`CameraExtrinsic.rotate`.""" self.extrinsic.rotate(*args, **kwargs)
[docs] def orbit(self, *args, **kwargs): """See :meth:`CameraExtrinsic.orbit`.""" self.extrinsic.orbit(*args, **kwargs)