# /*##########################################################################
#
# 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)