Files
geMoldInsight/venv/lib/python3.11/site-packages/pyvista/plotting/camera.py
T
2026-02-12 23:27:48 +08:00

913 lines
27 KiB
Python

"""Module containing pyvista implementation of :vtk:`vtkCamera`."""
from __future__ import annotations
from pathlib import Path
from weakref import proxy
import xml.dom.minidom as md
from xml.etree import ElementTree as ET
import numpy as np
import pyvista
from pyvista._deprecate_positional_args import _deprecate_positional_args
from pyvista.core.utilities.misc import _NoNewAttrMixin
from . import _vtk
from .helpers import view_vectors
class Camera(_NoNewAttrMixin, _vtk.DisableVtkSnakeCase, _vtk.vtkCamera):
"""PyVista wrapper for the VTK Camera class.
Parameters
----------
renderer : pyvista.Renderer, optional
Renderer to attach the camera to.
Examples
--------
Create a camera at the pyvista module level.
>>> import pyvista as pv
>>> camera = pv.Camera()
Access the active camera of a plotter and get the position of the
camera.
>>> pl = pv.Plotter()
>>> pl.camera.position
(1.0, 1.0, 1.0)
"""
def __init__(self, renderer=None):
"""Initialize a new camera descriptor."""
self._parallel_projection = False
self._elevation = 0.0
self._azimuth = 0.0
self._is_set = False
self._focus = None # Used by BackgroundRenderer
if renderer:
if not isinstance(renderer, pyvista.Renderer):
msg = 'Camera only accepts a pyvista.Renderer or None as the ``renderer`` argument'
raise TypeError(msg)
self._renderer = proxy(renderer)
else:
self._renderer = None # type: ignore[assignment]
def __eq__(self, other) -> bool:
"""Compare whether the relevant attributes of two cameras are equal."""
# attributes which are native python types and thus implement __eq__
native_attrs = [
'position',
'focal_point',
'parallel_projection',
'distance',
'thickness',
'parallel_scale',
'clipping_range',
'view_angle',
'roll',
]
for attr in native_attrs:
if getattr(self, attr) != getattr(other, attr):
return False
this_trans = self.model_transform_matrix
that_trans = other.model_transform_matrix
trans_count = sum(1 for trans in [this_trans, that_trans] if trans is not None)
if trans_count == 1:
# either but not both are None
return False
return not (trans_count == 2 and not np.array_equal(this_trans, that_trans))
__hash__ = None # type: ignore[assignment] # https://github.com/pyvista/pyvista/pull/7671
def __repr__(self):
"""Print a repr specifying the id of the camera and its camera type."""
repr_str = f'{self.__class__.__name__} ({hex(id(self))})'
repr_str += f'\n Position: {self.position}'
repr_str += f'\n Focal Point: {self.focal_point}'
repr_str += f'\n Parallel Projection: {self.parallel_projection}'
repr_str += f'\n Distance: {self.distance}'
repr_str += f'\n Thickness: {self.thickness}'
repr_str += f'\n Parallel Scale: {self.parallel_scale}'
repr_str += f'\n Clipping Range: {self.clipping_range}'
repr_str += f'\n View Angle: {self.view_angle}'
repr_str += f'\n Roll: {self.roll}'
return repr_str
def __str__(self):
"""Return the object string representation."""
return self.__repr__()
def __del__(self):
"""Delete the camera."""
self.RemoveAllObservers()
@property
def is_set(self) -> bool: # numpydoc ignore=RT01
"""Get or set whether this camera has been configured."""
return self._is_set
@is_set.setter
def is_set(self, value: bool):
self._is_set = bool(value)
@classmethod
def from_paraview_pvcc(cls, filename: str | Path) -> Camera:
"""Load a Paraview camera file (.pvcc extension).
Returns a pyvista.Camera object for which attributes has been read
from the filename argument.
Parameters
----------
filename : str or pathlib.Path
Path to Paraview camera file (.pvcc).
Returns
-------
pyvista.Camera
Camera from the camera file.
Examples
--------
>>> import pyvista as pv
>>> pl = pv.Plotter()
>>> pl.camera = pv.Camera.from_paraview_pvcc('camera.pvcc') # doctest:+SKIP
>>> pl.camera.position
(1.0, 1.0, 1.0)
"""
to_find = {
'CameraPosition': ('position', float),
'CameraFocalPoint': ('focal_point', float),
'CameraViewAngle': ('view_angle', float),
'CameraViewUp': ('up', float),
'CameraParallelProjection': ('parallel_projection', int),
'CameraParallelScale': ('parallel_scale', float),
}
camera = cls()
tree = ET.parse(filename)
root = tree.getroot()[0]
for element in root:
attrib = element.attrib
attrib_name = attrib['name']
if attrib_name in to_find:
name, typ = to_find[attrib_name]
nelems = int(attrib['number_of_elements'])
# Set the camera attributes
if nelems == 3:
values = [typ(e.attrib['value']) for e in element]
setattr(camera, name, values)
elif nelems == 1:
# Special case for bool since bool("0") returns True.
# So first convert to int from `to_find` and then apply bool
if 'name' in element[-1].attrib and element[-1].attrib['name'] == 'bool':
val = bool(typ(element[0].attrib['value']))
else:
val = typ(element[0].attrib['value'])
setattr(camera, name, val)
camera.is_set = True
return camera
def to_paraview_pvcc(self, filename: str | Path):
"""Write the camera parameters to a Paraview camera file (.pvcc extension).
Parameters
----------
filename : str or pathlib.Path
Path to Paraview camera file (.pvcc).
Examples
--------
>>> import pyvista as pv
>>> pl = pv.Plotter()
>>> pl.camera.to_paraview_pvcc('camera.pvcc') # doctest:+SKIP
"""
root = ET.Element('PVCameraConfiguration')
root.attrib['description'] = 'ParaView camera configuration'
root.attrib['version'] = '1.0'
dico = dict(group='views', type='RenderView', id='0', servers='21')
proxy = ET.SubElement(root, 'Proxy', dico)
# Add tuples
to_find = {
'CameraPosition': 'position',
'CameraFocalPoint': 'focal_point',
'CameraViewUp': 'up',
}
for name, attr in to_find.items():
e = ET.SubElement(
proxy,
'Property',
dict(name=name, id=f'0.{name}', number_of_elements='3'),
)
for i in range(3):
tmp = ET.Element('Element')
tmp.attrib['index'] = str(i)
tmp.attrib['value'] = str(getattr(self, attr)[i])
e.append(tmp)
# Add single values
to_find = {
'CameraViewAngle': 'view_angle',
'CameraParallelScale': 'parallel_scale',
'CameraParallelProjection': 'parallel_projection',
}
for name, attr in to_find.items():
e = ET.SubElement(
proxy,
'Property',
dict(name=name, id=f'0.{name}', number_of_elements='1'),
)
tmp = ET.Element('Element')
tmp.attrib['index'] = '0'
val = getattr(self, attr)
if not isinstance(val, bool):
tmp.attrib['value'] = str(val)
e.append(tmp)
else:
tmp.attrib['value'] = '1' if val else '0'
e.append(tmp)
e.append(ET.Element('Domain', dict(name='bool', id=f'0.{name}.bool')))
xmlstr = ET.tostring(root).decode()
newxml = md.parseString(xmlstr)
with Path(filename).open('w') as outfile:
outfile.write(newxml.toprettyxml(indent='\t', newl='\n'))
@property
def position(self): # numpydoc ignore=RT01
"""Return or set the position of the camera in world coordinates.
Examples
--------
>>> import pyvista as pv
>>> pl = pv.Plotter()
>>> pl.camera.position
(1.0, 1.0, 1.0)
>>> pl.camera.position = (2.0, 1.0, 1.0)
>>> pl.camera.position
(2.0, 1.0, 1.0)
"""
return self.GetPosition()
@position.setter
def position(self, value):
self.SetPosition(value)
self._elevation = 0.0
self._azimuth = 0.0
if self._renderer: # type: ignore[truthy-bool]
self.reset_clipping_range()
self.is_set = True
def reset_clipping_range(self):
"""Reset the camera clipping range based on the bounds of the visible actors.
Examples
--------
>>> import pyvista as pv
>>> pl = pv.Plotter()
>>> _ = pl.add_mesh(pv.Sphere())
>>> pl.camera.clipping_range = (1, 2)
>>> pl.camera.reset_clipping_range() # doctest:+SKIP
(0.0039213485598532955, 3.9213485598532953)
"""
if self._renderer is None:
msg = 'Camera is must be associated with a renderer to reset its clipping range.' # type: ignore[unreachable]
raise AttributeError(msg)
self._renderer.reset_camera_clipping_range()
@property
def focal_point(self): # numpydoc ignore=RT01
"""Location of the camera's focus in world coordinates.
Examples
--------
>>> import pyvista as pv
>>> pl = pv.Plotter()
>>> pl.camera.focal_point
(0.0, 0.0, 0.0)
>>> pl.camera.focal_point = (2.0, 0.0, 0.0)
>>> pl.camera.focal_point
(2.0, 0.0, 0.0)
"""
return self.GetFocalPoint()
@focal_point.setter
def focal_point(self, point):
self.SetFocalPoint(point)
self.is_set = True
@property
def model_transform_matrix(self): # numpydoc ignore=RT01
"""Return or set the camera's model transformation matrix.
Examples
--------
>>> import pyvista as pv
>>> import numpy as np
>>> pl = pv.Plotter()
>>> pl.camera.model_transform_matrix
array([[1., 0., 0., 0.],
[0., 1., 0., 0.],
[0., 0., 1., 0.],
[0., 0., 0., 1.]])
>>> pl.camera.model_transform_matrix = np.array(
... [
... [1.0, 0.0, 0.0, 0.0],
... [0.0, 1.0, 0.0, 0.0],
... [0.0, 0.0, 1.0, 0.0],
... [0.0, 0.0, 0.0, 0.5],
... ]
... )
>>>
array([[1., 0., 0., 0.],
[0., 1., 0., 0.],
[0., 0., 1., 0.],
[0., 0., 0., 0.5]])
"""
vtk_matrix = self.GetModelTransformMatrix()
matrix = np.empty((4, 4))
vtk_matrix.DeepCopy(matrix.ravel(), vtk_matrix)
return matrix
@model_transform_matrix.setter
def model_transform_matrix(self, matrix):
vtk_matrix = _vtk.vtkMatrix4x4()
vtk_matrix.DeepCopy(matrix.ravel())
self.SetModelTransformMatrix(vtk_matrix)
@property
def distance(self): # numpydoc ignore=RT01
"""Return or set the distance of the focal point from the camera.
Notes
-----
Setting the distance keeps the camera fixed and moves the focal point.
Examples
--------
>>> import pyvista as pv
>>> pl = pv.Plotter()
>>> pl.camera.distance
1.73205
>>> pl.camera.distance = 2.0
>>> pl.camera.distance
2.0
"""
return self.GetDistance()
@distance.setter
def distance(self, distance):
self.SetDistance(distance)
self.is_set = True
@property
def thickness(self): # numpydoc ignore=RT01
"""Return or set the distance between clipping planes.
Examples
--------
>>> import pyvista as pv
>>> pl = pv.Plotter()
>>> pl.camera.thickness
1000.0
>>> pl.camera.thickness = 100
>>> pl.camera.thickness
100.0
"""
return self.GetThickness()
@thickness.setter
def thickness(self, length):
self.SetThickness(length)
@property
def parallel_scale(self): # numpydoc ignore=RT01
"""Return or set the scaling used for a parallel projection.
Examples
--------
>>> import pyvista as pv
>>> pl = pv.Plotter()
>>> pl.camera.parallel_scale
1.0
>>> pl.camera.parallel_scale = 2.0
>>> pl.camera.parallel_scale
2.0
"""
return self.GetParallelScale()
@parallel_scale.setter
def parallel_scale(self, scale):
self.SetParallelScale(scale)
def zoom(self, value):
"""Set the zoom of the camera.
In perspective mode, decrease the view angle by the specified
factor.
In parallel mode, decrease the parallel scale by the specified
factor. A value greater than 1 is a zoom-in, a value less than
1 is a zoom-out.
Parameters
----------
value : float or str
Zoom of the camera. If a float, must be greater than 0. Otherwise,
if a string, must be ``"tight"``. If tight, the plot will be zoomed
such that the actors fill the entire viewport.
Examples
--------
Show the Default zoom.
>>> import pyvista as pv
>>> pl = pv.Plotter()
>>> _ = pl.add_mesh(pv.Sphere())
>>> pl.camera.zoom(1.0)
>>> pl.show()
Show 2x zoom.
>>> pl = pv.Plotter()
>>> _ = pl.add_mesh(pv.Sphere())
>>> pl.camera.zoom(2.0)
>>> pl.show()
Zoom so the actor fills the entire render window.
>>> pl = pv.Plotter()
>>> _ = pl.add_mesh(pv.Sphere())
>>> pl.camera.zoom('tight')
>>> pl.show()
"""
if isinstance(value, str):
if value != 'tight':
msg = 'If a string, ``zoom`` can only be "tight"'
raise ValueError(msg)
self.tight()
return
self.Zoom(value)
self.is_set = True
@property
def up(self): # numpydoc ignore=RT01
"""Return or set the "up" of the camera.
Examples
--------
>>> import pyvista as pv
>>> pl = pv.Plotter()
>>> pl.camera.up
(0.0, 0.0, 1.0)
>>> pl.camera.up = (0.410018, 0.217989, 0.885644)
>>> pl.camera.up
(0.410018, 0.217989, 0.885644)
"""
return self.GetViewUp()
@up.setter
def up(self, vector):
self.SetViewUp(vector)
self.is_set = True
def enable_parallel_projection(self):
"""Enable parallel projection.
The camera will have a parallel projection. Parallel
projection is often useful when viewing images or 2D datasets,
but will look odd when viewing 3D datasets.
Examples
--------
>>> import pyvista as pv
>>> from pyvista import demos
>>> pl = pv.demos.orientation_plotter()
>>> pl.enable_parallel_projection()
>>> pl.show()
"""
self._parallel_projection = True
self.SetParallelProjection(True)
def disable_parallel_projection(self):
"""Disable the use of parallel projection.
This is default behavior.
Examples
--------
>>> import pyvista as pv
>>> from pyvista import demos
>>> pl = pv.demos.orientation_plotter()
>>> pl.disable_parallel_projection()
>>> pl.show()
"""
self._parallel_projection = False
self.SetParallelProjection(False)
@property
def parallel_projection(self): # numpydoc ignore=RT01
"""Return the state of the parallel projection.
Examples
--------
>>> import pyvista as pv
>>> from pyvista import demos
>>> pl = pv.Plotter()
>>> pl.disable_parallel_projection()
>>> pl.parallel_projection
False
"""
return self._parallel_projection
@parallel_projection.setter
def parallel_projection(self, state):
if state:
self.enable_parallel_projection()
else:
self.disable_parallel_projection()
@property
def clipping_range(self): # numpydoc ignore=RT01
"""Return or set the location of the clipping planes.
Clipping planes are the near and far clipping planes along
the direction of projection.
Examples
--------
>>> import pyvista as pv
>>> pl = pv.Plotter()
>>> pl.camera.clipping_range
(0.01, 1000.01)
>>> pl.camera.clipping_range = (1, 10)
>>> pl.camera.clipping_range
(1.0, 10.0)
"""
return self.GetClippingRange()
@clipping_range.setter
def clipping_range(self, points):
if points[0] > points[1]:
msg = 'Near point must be lower than the far point.'
raise ValueError(msg)
self.SetClippingRange(points[0], points[1])
@property
def view_angle(self): # numpydoc ignore=RT01
"""Return or set the camera view angle.
Examples
--------
>>> import pyvista as pv
>>> plotter = pv.Plotter()
>>> plotter.camera.view_angle
30.0
>>> plotter.camera.view_angle = 60.0
>>> plotter.camera.view_angle
60.0
"""
return self.GetViewAngle()
@view_angle.setter
def view_angle(self, value):
self.SetViewAngle(value)
@property
def direction(self): # numpydoc ignore=RT01
"""Vector from the camera position to the focal point.
Examples
--------
>>> import pyvista as pv
>>> pl = pv.Plotter()
>>> pl.camera.direction # doctest:+SKIP
(-0.5773502691896257, -0.5773502691896257, -0.5773502691896257)
"""
return self.GetDirectionOfProjection()
def view_frustum(self, aspect=1.0):
"""Get the view frustum.
Parameters
----------
aspect : float, default: 1.0
The aspect of the viewport to compute the planes.
Returns
-------
pyvista.PolyData
View frustum.
Examples
--------
>>> import pyvista as pv
>>> plotter = pv.Plotter()
>>> frustum = plotter.camera.view_frustum(1.0)
>>> frustum.n_points
8
>>> frustum.n_cells
6
"""
frustum_planes = [0] * 24
self.GetFrustumPlanes(aspect, frustum_planes) # type: ignore[arg-type]
planes = _vtk.vtkPlanes()
planes.SetFrustumPlanes(frustum_planes) # type: ignore[arg-type]
frustum_source = _vtk.vtkFrustumSource()
frustum_source.ShowLinesOff()
frustum_source.SetPlanes(planes)
frustum_source.Update()
return pyvista.wrap(frustum_source.GetOutput())
@property
def roll(self): # numpydoc ignore=RT01
"""Return or set the roll of the camera about the direction of projection.
This will spin the camera about its axis.
Examples
--------
>>> import pyvista as pv
>>> pl = pv.Plotter()
>>> pl.camera.roll
-120.00000000000001
>>> pl.camera.roll = 45.0
>>> pl.camera.roll
45.0
"""
return self.GetRoll()
@roll.setter
def roll(self, angle):
self.SetRoll(angle)
self.is_set = True
@property
def elevation(self): # numpydoc ignore=RT01
"""Return or set the vertical rotation of the scene.
Rotate the camera about the cross product of the negative of
the direction of projection and the view up vector, using the
focal point as the center of rotation.
Examples
--------
>>> import pyvista as pv
>>> pl = pv.Plotter()
>>> pl.camera.elevation
0.0
>>> pl.camera.elevation = 45.0
>>> pl.camera.elevation
45.0
"""
return self._elevation
@elevation.setter
def elevation(self, angle):
if self._elevation:
self.Elevation(-self._elevation)
self._elevation = angle
self.Elevation(angle)
self.is_set = True
@property
def azimuth(self): # numpydoc ignore=RT01
"""Return or set the azimuth of the camera.
Rotate the camera about the view up vector centered at the
focal point. Note that the view up vector is whatever was set
via SetViewUp, and is not necessarily perpendicular to the
direction of projection.
Examples
--------
>>> import pyvista as pv
>>> pl = pv.Plotter()
>>> pl.camera.azimuth
0.0
>>> pl.camera.azimuth = 45.0
>>> pl.camera.azimuth
45.0
"""
return self._azimuth
@azimuth.setter
def azimuth(self, angle):
if self._azimuth:
self.Azimuth(-self._azimuth)
self._azimuth = angle
self.Azimuth(angle)
self.is_set = True
def copy(self):
"""Return a deep copy of the camera.
Returns
-------
pyvista.Camera
Deep copy of the camera.
Examples
--------
Create a camera and check that it shares a transformation
matrix with its shallow copy.
>>> import pyvista as pv
>>> import numpy as np
>>> camera = pv.Camera()
>>> camera.model_transform_matrix = np.array(
... [
... [1.0, 0.0, 0.0, 0.0],
... [0.0, 1.0, 0.0, 0.0],
... [0.0, 0.0, 1.0, 0.0],
... [0.0, 0.0, 0.0, 1.0],
... ]
... )
>>> copied_camera = camera.copy()
>>> copied_camera == camera
True
>>> camera.model_transform_matrix = np.array(
... [
... [1.0, 0.0, 0.0, 0.0],
... [0.0, 1.0, 0.0, 0.0],
... [0.0, 0.0, 1.0, 0.0],
... [0.0, 0.0, 0.0, 0.5],
... ]
... )
>>> copied_camera == camera
False
"""
immutable_attrs = [
'position',
'focal_point',
'model_transform_matrix',
'distance',
'thickness',
'parallel_scale',
'up',
'clipping_range',
'view_angle',
'roll',
'parallel_projection',
'is_set',
]
new_camera = Camera()
for attr in immutable_attrs:
value = getattr(self, attr)
setattr(new_camera, attr, value)
return new_camera
@_deprecate_positional_args
def tight( # noqa: PLR0917
self,
padding=0.0,
adjust_render_window: bool = True, # noqa: FBT001, FBT002
view='xy',
negative: bool = False, # noqa: FBT001, FBT002
):
"""Adjust the camera position so that the actors fill the entire renderer.
The camera view direction is reoriented to be normal to the ``view``
plane. When ``negative=False``, The first letter of ``view`` refers
to the axis that points to the right. The second letter of ``view``
refers to axis that points up. When ``negative=True``, the first
letter refers to the axis that points left. The up direction is
unchanged.
Parallel projection is enabled when using this function.
Parameters
----------
padding : float, default: 0.0
Additional padding around the actor(s). This is effectively a zoom,
where a value of 0.01 results in a zoom out of 1%.
adjust_render_window : bool, default: True
Adjust the size of the render window as to match the dimensions of
the visible actors.
view : {'xy', 'yx', 'xz', 'zx', 'yz', 'zy'}, default: 'xy'
Plane to which the view is oriented.
negative : bool, default: False
Whether to view in opposite direction.
Notes
-----
This resets the view direction to look at a plane with parallel projection.
Examples
--------
Display the puppy image with a tight view.
>>> import pyvista as pv
>>> from pyvista import examples
>>> puppy = examples.download_puppy()
>>> pl = pv.Plotter(border=True, border_width=5)
>>> _ = pl.add_mesh(puppy, rgb=True)
>>> pl.camera.tight()
>>> pl.show()
Set the background to blue use a 5% padding around the image.
>>> pl = pv.Plotter()
>>> _ = pl.add_mesh(puppy, rgb=True)
>>> pl.background_color = 'b'
>>> pl.camera.tight(padding=0.05)
>>> pl.show()
"""
# Inspired by vedo resetCamera. Thanks @marcomusy.
x0, x1, y0, y1, z0, z1 = self._renderer.bounds
self.enable_parallel_projection()
self._renderer.ComputeAspect()
aspect = self._renderer.GetAspect()
position0 = np.array([x0, y0, z0])
position1 = np.array([x1, y1, z1])
objects_size = position1 - position0
position = position0 + objects_size / 2
direction, viewup = view_vectors(view, negative=negative)
horizontal = np.cross(direction, viewup)
vert_dist = abs(objects_size @ viewup)
horiz_dist = abs(objects_size @ horizontal)
# set focal point to objects' center
# offset camera position from objects center by dist in opposite of viewing direction
# (actual distance doesn't matter due to parallel projection)
dist = 1
camera_position = position + dist * direction
self.SetViewUp(*viewup)
self.SetPosition(*camera_position)
self.SetFocalPoint(*position)
ps = max(horiz_dist / aspect[0], vert_dist) / 2
self.parallel_scale = ps * (1 + padding)
self._renderer.ResetCameraClippingRange(x0, x1, y0, y1, z0, z1)
if adjust_render_window:
ren_win = self._renderer.GetRenderWindow()
size = list(ren_win.GetSize())
size_ratio = size[0] / size[1]
tight_ratio = horiz_dist / vert_dist
resize_ratio = tight_ratio / size_ratio
if resize_ratio < 1:
size[0] = round(size[0] * resize_ratio)
else:
size[1] = round(size[1] / resize_ratio)
ren_win.SetSize(size)
# simply call tight again to reset the parallel scale due to the
# resized window
self.tight(padding=padding, adjust_render_window=False, view=view, negative=negative)
self.is_set = True