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

781 lines
24 KiB
Python

"""Module containing useful plotting tools."""
from __future__ import annotations
from enum import Enum
import os
import platform
import subprocess
from subprocess import PIPE
from subprocess import Popen
from subprocess import TimeoutExpired
import sys
import numpy as np
import pyvista
from pyvista._deprecate_positional_args import _deprecate_positional_args
from . import _vtk
from .colors import Color
class FONTS(Enum):
"""Font families available to PyVista."""
arial = _vtk.VTK_ARIAL
courier = _vtk.VTK_COURIER
times = _vtk.VTK_TIMES
# Track render window support and plotting
SUPPORTS_OPENGL = None
SUPPORTS_PLOTTING = None
def supports_open_gl():
"""Return if the system supports OpenGL.
This function checks if the system supports OpenGL by creating a VTK render
window and querying its OpenGL support.
Returns
-------
bool
``True`` if the system supports OpenGL, ``False`` otherwise.
"""
global SUPPORTS_OPENGL # noqa: PLW0603
if SUPPORTS_OPENGL is None:
ren_win = _vtk.vtkRenderWindow()
SUPPORTS_OPENGL = bool(ren_win.SupportsOpenGL())
return SUPPORTS_OPENGL
def _system_supports_plotting(): # noqa: PLR0911
"""Check if the environment supports plotting on Windows, Linux, or Mac OS.
Returns
-------
system_supports_plotting : bool
``True`` when system supports plotting.
"""
if os.environ.get('ALLOW_PLOTTING', '').lower() == 'true':
return True
# Windows case
if os.name == 'nt':
# actually have to check here. Somewhat expensive.
return supports_open_gl()
# mac case
if platform.system() == 'Darwin':
# check if finder available
proc = Popen(['pgrep', '-qx', 'Finder'], stdout=PIPE, stderr=PIPE, encoding='utf8')
try:
proc.communicate(timeout=10)
except TimeoutExpired:
return False
if proc.returncode == 0:
return True
# display variable set, likely available
return 'DISPLAY' in os.environ
# Linux case
try:
proc = Popen(['xset', '-q'], stdout=PIPE, stderr=PIPE, encoding='utf8')
proc.communicate(timeout=10)
except (OSError, TimeoutExpired):
return False
else: # pragma: no cover
return proc.returncode == 0
def system_supports_plotting():
"""Check if the environment supports plotting.
Returns
-------
bool
``True`` when system supports plotting.
"""
global SUPPORTS_PLOTTING # noqa: PLW0603
if SUPPORTS_PLOTTING is None:
SUPPORTS_PLOTTING = _system_supports_plotting()
# always use the cached response
return SUPPORTS_PLOTTING
def _update_axes_label_color(axes_actor, color=None):
"""Set the axes label color (internal helper)."""
color = Color(color, default_color=pyvista.global_theme.font.color)
if isinstance(axes_actor, _vtk.vtkAxesActor):
prop_x = axes_actor.GetXAxisCaptionActor2D().GetCaptionTextProperty()
prop_y = axes_actor.GetYAxisCaptionActor2D().GetCaptionTextProperty()
prop_z = axes_actor.GetZAxisCaptionActor2D().GetCaptionTextProperty()
for prop in [prop_x, prop_y, prop_z]:
prop.SetColor(color.float_rgb)
prop.SetShadow(False)
elif isinstance(axes_actor, _vtk.vtkAnnotatedCubeActor):
axes_actor.GetTextEdgesProperty().SetColor(color.float_rgb)
@_deprecate_positional_args
def create_axes_marker( # noqa: PLR0917
label_color=None,
x_color=None,
y_color=None,
z_color=None,
xlabel='X',
ylabel='Y',
zlabel='Z',
labels_off: bool = False, # noqa: FBT001, FBT002
line_width=2,
cone_radius=0.4,
shaft_length=0.8,
tip_length=0.2,
ambient=0.5,
label_size=(0.25, 0.1),
) -> _vtk.vtkAxesActor:
"""Create an axis actor.
Parameters
----------
label_color : ColorLike, optional
Color of the label text.
x_color : ColorLike, optional
Color of the x-axis text.
y_color : ColorLike, optional
Color of the y-axis text.
z_color : ColorLike, optional
Color of the z-axis text.
xlabel : str, default: "X"
Text used for the x-axis.
ylabel : str, default: "Y"
Text used for the y-axis.
zlabel : str, default: "Z"
Text used for the z-axis.
labels_off : bool, default: False
Enable or disable the text labels for the axes.
line_width : float, default: 2
The width of the marker lines.
cone_radius : float, default: 0.4
The radius of the axes arrow tips.
shaft_length : float, default: 0.8
The length of the axes arrow shafts.
tip_length : float, default: 0.2
Length of the tip.
ambient : float, default: 0.5
The ambient of the axes arrows. Value should be between 0 and 1.
label_size : sequence[float], default: (0.25, 0.1)
The width and height of the axes label actors. Values should be between
0 and 1. For example ``(0.2, 0.1)``.
Returns
-------
:vtk:`vtkAxesActor`
Axes actor.
Examples
--------
Create the default axes marker.
>>> import pyvista as pv
>>> marker = pv.create_axes_marker()
>>> pl = pv.Plotter()
>>> _ = pl.add_actor(marker)
>>> pl.show()
Create an axes marker at the origin with custom colors and axis labels.
>>> import pyvista as pv
>>> marker = pv.create_axes_marker(
... line_width=4,
... ambient=0.0,
... x_color='#378df0',
... y_color='#ab2e5d',
... z_color='#f7fb9a',
... xlabel='X Axis',
... ylabel='Y Axis',
... zlabel='Z Axis',
... label_size=(0.1, 0.1),
... )
>>> pl = pv.Plotter()
>>> _ = pl.add_actor(marker)
>>> pl.show()
"""
x_color = Color(x_color, default_color=pyvista.global_theme.axes.x_color)
y_color = Color(y_color, default_color=pyvista.global_theme.axes.y_color)
z_color = Color(z_color, default_color=pyvista.global_theme.axes.z_color)
axes_actor = _vtk.vtkAxesActor()
axes_actor.GetXAxisShaftProperty().SetColor(x_color.float_rgb)
axes_actor.GetXAxisTipProperty().SetColor(x_color.float_rgb)
axes_actor.GetYAxisShaftProperty().SetColor(y_color.float_rgb)
axes_actor.GetYAxisTipProperty().SetColor(y_color.float_rgb)
axes_actor.GetZAxisShaftProperty().SetColor(z_color.float_rgb)
axes_actor.GetZAxisTipProperty().SetColor(z_color.float_rgb)
# Set labels
axes_actor.SetXAxisLabelText(xlabel)
axes_actor.SetYAxisLabelText(ylabel)
axes_actor.SetZAxisLabelText(zlabel)
if labels_off:
axes_actor.AxisLabelsOff()
# Set Line width
axes_actor.GetXAxisShaftProperty().SetLineWidth(line_width)
axes_actor.GetYAxisShaftProperty().SetLineWidth(line_width)
axes_actor.GetZAxisShaftProperty().SetLineWidth(line_width)
axes_actor.SetConeRadius(cone_radius)
axes_actor.SetNormalizedShaftLength([shaft_length] * 3)
axes_actor.SetNormalizedTipLength([tip_length] * 3)
axes_actor.GetXAxisShaftProperty().SetAmbient(ambient)
axes_actor.GetYAxisShaftProperty().SetAmbient(ambient)
axes_actor.GetZAxisShaftProperty().SetAmbient(ambient)
axes_actor.GetXAxisTipProperty().SetAmbient(ambient)
axes_actor.GetYAxisTipProperty().SetAmbient(ambient)
axes_actor.GetZAxisTipProperty().SetAmbient(ambient)
for label_actor in [
axes_actor.GetXAxisCaptionActor2D(),
axes_actor.GetYAxisCaptionActor2D(),
axes_actor.GetZAxisCaptionActor2D(),
]:
label_actor.SetWidth(label_size[0])
label_actor.SetHeight(label_size[1])
_update_axes_label_color(axes_actor, label_color)
return axes_actor
@_deprecate_positional_args
def create_axes_orientation_box( # noqa: PLR0917
line_width=1,
text_scale=0.366667,
edge_color='black',
x_color=None,
y_color=None,
z_color=None,
xlabel='X',
ylabel='Y',
zlabel='Z',
x_face_color='red',
y_face_color='green',
z_face_color='blue',
color_box: bool = False, # noqa: FBT001, FBT002
label_color=None,
labels_off: bool = False, # noqa: FBT001, FBT002
opacity=0.5,
show_text_edges: bool = False, # noqa: FBT001, FBT002
):
"""Create a Box axes orientation widget with labels.
Parameters
----------
line_width : float, optional
The width of the marker lines.
text_scale : float, optional
Size of the text relative to the faces.
edge_color : ColorLike, optional
Color of the edges.
x_color : ColorLike, optional
Color of the x-axis text.
y_color : ColorLike, optional
Color of the y-axis text.
z_color : ColorLike, optional
Color of the z-axis text.
xlabel : str, optional
Text used for the x-axis.
ylabel : str, optional
Text used for the y-axis.
zlabel : str, optional
Text used for the z-axis.
x_face_color : ColorLike, optional
Color used for the x-axis arrow. Defaults to theme axes
parameters.
y_face_color : ColorLike, optional
Color used for the y-axis arrow. Defaults to theme axes
parameters.
z_face_color : ColorLike, optional
Color used for the z-axis arrow. Defaults to theme axes
parameters.
color_box : bool, optional
Enable or disable the face colors. Otherwise, box is white.
label_color : ColorLike, optional
Color of the labels.
labels_off : bool, optional
Enable or disable the text labels for the axes.
opacity : float, optional
Opacity in the range of ``[0, 1]`` of the orientation box.
show_text_edges : bool, optional
Enable or disable drawing the vector text edges.
Returns
-------
:vtk:`vtkAnnotatedCubeActor`
Annotated cube actor.
Examples
--------
Create and plot an orientation box
>>> import pyvista as pv
>>> actor = pv.create_axes_orientation_box(
... line_width=1,
... text_scale=0.53,
... edge_color='black',
... x_color='k',
... y_color=None,
... z_color=None,
... xlabel='X',
... ylabel='Y',
... zlabel='Z',
... color_box=False,
... labels_off=False,
... opacity=1.0,
... )
>>> pl = pv.Plotter()
>>> _ = pl.add_actor(actor)
>>> pl.show()
"""
x_color = Color(x_color, default_color=pyvista.global_theme.axes.x_color)
y_color = Color(y_color, default_color=pyvista.global_theme.axes.y_color)
z_color = Color(z_color, default_color=pyvista.global_theme.axes.z_color)
edge_color = Color(edge_color, default_color=pyvista.global_theme.edge_color)
x_face_color = Color(x_face_color)
y_face_color = Color(y_face_color)
z_face_color = Color(z_face_color)
axes_actor = _vtk.vtkAnnotatedCubeActor()
axes_actor.SetFaceTextScale(text_scale)
if xlabel is not None:
axes_actor.SetXPlusFaceText(f'+{xlabel}')
axes_actor.SetXMinusFaceText(f'-{xlabel}')
if ylabel is not None:
axes_actor.SetYPlusFaceText(f'+{ylabel}')
axes_actor.SetYMinusFaceText(f'-{ylabel}')
if zlabel is not None:
axes_actor.SetZPlusFaceText(f'+{zlabel}')
axes_actor.SetZMinusFaceText(f'-{zlabel}')
axes_actor.SetFaceTextVisibility(not labels_off)
axes_actor.SetTextEdgesVisibility(show_text_edges)
# https://github.com/pyvista/pyvista/pull/5382
# axes_actor.GetTextEdgesProperty().SetColor(edge_color.float_rgb)
axes_actor.GetTextEdgesProperty().SetLineWidth(line_width)
axes_actor.GetXPlusFaceProperty().SetColor(x_color.float_rgb)
axes_actor.GetXMinusFaceProperty().SetColor(x_color.float_rgb)
axes_actor.GetYPlusFaceProperty().SetColor(y_color.float_rgb)
axes_actor.GetYMinusFaceProperty().SetColor(y_color.float_rgb)
axes_actor.GetZPlusFaceProperty().SetColor(z_color.float_rgb)
axes_actor.GetZMinusFaceProperty().SetColor(z_color.float_rgb)
axes_actor.GetCubeProperty().SetOpacity(opacity)
axes_actor.GetCubeProperty().SetEdgeColor(edge_color.float_rgb)
axes_actor.GetCubeProperty().SetEdgeVisibility(True)
axes_actor.GetCubeProperty().BackfaceCullingOn()
if opacity < 1.0:
# Hide the text edges
axes_actor.GetTextEdgesProperty().SetOpacity(0)
if color_box:
# Hide the cube so we can color each face
axes_actor.GetCubeProperty().SetOpacity(0)
axes_actor.GetCubeProperty().SetEdgeVisibility(False)
cube = pyvista.Cube()
cube.clear_data() # remove normals
face_colors = np.array(
[
x_face_color.int_rgb,
x_face_color.int_rgb,
y_face_color.int_rgb,
y_face_color.int_rgb,
z_face_color.int_rgb,
z_face_color.int_rgb,
],
np.uint8,
)
cube.cell_data['face_colors'] = face_colors
cube_mapper = _vtk.vtkPolyDataMapper()
cube_mapper.SetInputData(cube)
cube_mapper.SetColorModeToDirectScalars()
cube_mapper.Update()
cube_actor = pyvista.Actor(mapper=cube_mapper)
cube_actor.prop.culling = 'back'
cube_actor.prop.opacity = opacity
prop_assembly = _vtk.vtkPropAssembly()
prop_assembly.AddPart(axes_actor)
prop_assembly.AddPart(cube_actor)
actor = prop_assembly
else:
actor = axes_actor # type: ignore[assignment]
_update_axes_label_color(actor, label_color)
return actor
def create_north_arrow():
"""Create a north arrow mesh.
.. versionadded:: 0.44.0
Returns
-------
pyvista.PolyData
North arrow mesh.
"""
points = np.array(
[
[0.0, 5.0, 0.0],
[-2.0, 0.0, 0.0],
[0.0, 1.5, 0.0],
[2.0, 0.0, 0.0],
[0.0, 5.0, 1.0],
[-2.0, 0.0, 1.0],
[0.0, 1.5, 1.0],
[2.0, 0.0, 1.0],
],
)
faces = np.array(
[
4,
3,
7,
4,
0,
4,
2,
6,
7,
3,
4,
1,
5,
6,
2,
4,
0,
4,
5,
1,
4,
0,
1,
2,
3,
4,
4,
7,
6,
5,
],
)
return pyvista.PolyData(points, faces)
def normalize(x, minimum=None, maximum=None):
"""Normalize the given value between [minimum, maximum].
Parameters
----------
x : numpy.ndarray
The array of values to normalize.
minimum : float, optional
The minimum value to which ``x`` should be normalized. If not specified,
the minimum value in ``x`` will be used.
maximum : float, optional
The maximum value to which ``x`` should be normalized. If not specified,
the maximum value in ``x`` will be used.
Returns
-------
numpy.ndarray
The normalized array of values, where the values are scaled to the
range ``[minimum, maximum]``.
"""
if minimum is None:
minimum = np.nanmin(x)
if maximum is None:
maximum = np.nanmax(x)
return (x - minimum) / (maximum - minimum)
@_deprecate_positional_args(allowed=['mapping', 'n_colors'])
def opacity_transfer_function( # noqa: PLR0917
mapping,
n_colors,
interpolate: bool = True, # noqa: FBT001, FBT002
kind='linear',
):
"""Get the opacity transfer function for a mapping.
These values will map on to a scalar bar range and thus the number of
colors (``n_colors``) must correspond to the number of colors in the color
mapping that these opacities are associated to.
If interpolating, ``scipy.interpolate.interp1d`` is used if available,
otherwise ``np.interp`` is used. The ``kind`` argument controls the kind of
interpolation for ``interp1d``.
This returns the opacity range from 0 to 255, where 0 is totally
transparent and 255 is totally opaque.
The equation to create the sigmoid mapping is: ``1 / (1 + exp(-x))`` where
``x`` is the range from ``-a`` to ``+a`` and ``a`` is the value given in
the ``mapping`` string. Default is ``a=10`` for 'sigmoid' mapping.
Parameters
----------
mapping : list[float] | str
The opacity mapping to use. Can be a ``str`` name of a predefined
mapping including ``'linear'``, ``'geom'``, ``'sigmoid'``,
``'sigmoid_1-10,15,20'``, and ``foreground``. Append an ``'_r'`` to any
of those names (except ``foreground``) to reverse that mapping.
The mapping can also be a custom user-defined array/list of values
that will be interpolated across the ``n_color`` range.
n_colors : int
The number of colors that the opacities must be mapped to.
interpolate : bool
Flag on whether or not to interpolate the opacity mapping for all
colors.
kind : str
The interpolation kind if ``interpolate`` is ``True`` and ``scipy``
is available. If ``scipy`` is not available, linear interpolation
is always used. Options are:
- ``'linear'``
- ``'nearest'``
- ``'zero'``
- ``'slinear'``
- ``'quadratic'``
- ``'cubic'``
- ``'previous'``
- ``'next'``
.. versionchanged:: 0.46
Linear interpolation is now always used by default. Previously,
quadratic interpolation was used if ``scipy`` was installed.
Returns
-------
numpy.ndarray
Array of ``numpy.uint8`` values ``n_colors`` long containing the
[0-255] opacity mapping values.
Examples
--------
>>> import pyvista as pv
>>> # Fetch the `sigmoid` mapping between 0 and 255
>>> tf = pv.opacity_transfer_function('sigmoid', 256)
>>> # Fetch the `geom_r` mapping between 0 and 1
>>> tf = pv.opacity_transfer_function('geom_r', 256).astype(float) / 255.0
>>> # Interpolate a user defined opacity mapping
>>> opacity = [0, 0.2, 0.9, 0.6, 0.3]
>>> tf = pv.opacity_transfer_function(opacity, 256)
"""
sigmoid = lambda x: np.array(1 / (1 + np.exp(-x)) * 255, dtype=np.uint8)
transfer_func = {
'linear': np.linspace(0, 255, n_colors, dtype=np.uint8),
'geom': np.geomspace(1e-6, 255, n_colors, dtype=np.uint8),
'geom_r': np.geomspace(255, 1e-6, n_colors, dtype=np.uint8),
'sigmoid': sigmoid(np.linspace(-10.0, 10.0, n_colors)),
'sigmoid_1': sigmoid(np.linspace(-1.0, 1.0, n_colors)),
'sigmoid_2': sigmoid(np.linspace(-2.0, 2.0, n_colors)),
'sigmoid_3': sigmoid(np.linspace(-3.0, 3.0, n_colors)),
'sigmoid_4': sigmoid(np.linspace(-4.0, 4.0, n_colors)),
'sigmoid_5': sigmoid(np.linspace(-5.0, 5.0, n_colors)),
'sigmoid_6': sigmoid(np.linspace(-6.0, 6.0, n_colors)),
'sigmoid_7': sigmoid(np.linspace(-7.0, 7.0, n_colors)),
'sigmoid_8': sigmoid(np.linspace(-8.0, 8.0, n_colors)),
'sigmoid_9': sigmoid(np.linspace(-9.0, 9.0, n_colors)),
'sigmoid_10': sigmoid(np.linspace(-10.0, 10.0, n_colors)),
'sigmoid_15': sigmoid(np.linspace(-15.0, 15.0, n_colors)),
'sigmoid_20': sigmoid(np.linspace(-20.0, 20.0, n_colors)),
'foreground': np.hstack((0, [255] * (n_colors - 1))).astype(np.uint8),
}
transfer_func['linear_r'] = transfer_func['linear'][::-1]
transfer_func['sigmoid_r'] = transfer_func['sigmoid'][::-1]
for i in range(3, 11):
k = f'sigmoid_{i}'
rk = f'{k}_r'
transfer_func[rk] = transfer_func[k][::-1]
if isinstance(mapping, str):
try:
return transfer_func[mapping]
except KeyError:
msg = (
f'Opacity transfer function ({mapping}) unknown. '
f'Valid options: {list(transfer_func.keys())}'
)
raise ValueError(msg) from None
elif isinstance(mapping, (np.ndarray, list, tuple)):
mapping = np.array(mapping)
if mapping.size == n_colors:
# User could pass transfer function ready for lookup table
pass
elif mapping.size < n_colors:
# User pass custom transfer function to be linearly interpolated
if np.max(mapping) > 1.0 or np.min(mapping) < 0.0:
mapping = normalize(mapping)
# Interpolate transfer function to match lookup table
xo = np.linspace(0, n_colors, len(mapping), dtype=np.int_)
xx = np.linspace(0, n_colors, n_colors, dtype=np.int_)
try:
if not interpolate:
msg = 'No interpolation.'
raise ValueError(msg)
from scipy.interpolate import interp1d # noqa: PLC0415
f = interp1d(xo, mapping, kind=kind)
vals = f(xx)
vals[vals < 0] = 0.0
vals[vals > 1.0] = 1.0
mapping = (vals * 255.0).astype(np.uint8)
except (ImportError, ValueError):
# Otherwise use simple linear interp
mapping = (np.interp(xx, xo, mapping) * 255).astype(np.uint8)
else:
msg = (
f'Transfer function cannot have more values than `n_colors`. '
f'This has {mapping.size} elements'
)
raise RuntimeError(msg)
return mapping
msg = f'Transfer function type ({type(mapping)}) not understood'
raise TypeError(msg)
def parse_font_family(font_family: str) -> int:
"""Check and validate the given font family name.
Parameters
----------
font_family : str
Font family name to validate. Must be one of the font names defined in
the ``FONTS`` enum class.
Returns
-------
int
Corresponding integer value of the valid font family name in the
``FONTS`` enum class.
Raises
------
ValueError
If the font_family is not one of the defined font names in the ``FONTS``
enum class.
"""
font_family = font_family.lower()
fonts = [font.name for font in FONTS]
if font_family not in fonts:
msg = f'Font must one of the following:\n{", ".join(fonts)}'
raise ValueError(msg)
return FONTS[font_family].value
def check_matplotlib_vtk_compatibility():
"""Check if VTK and Matplotlib versions are compatible for MathText rendering.
This function is primarily geared towards checking if MathText rendering is
supported with the given versions of VTK and Matplotlib. It follows the
version constraints:
* VTK <= 9.2.2 requires Matplotlib < 3.6
* VTK > 9.2.2 requires Matplotlib >= 3.6
Other version combinations of VTK and Matplotlib will work without
errors, but some features (like MathText/LaTeX rendering) may
silently fail.
Returns
-------
bool
True if the versions of VTK and Matplotlib are compatible for MathText
rendering, False otherwise.
Raises
------
RuntimeError
If the versions of VTK and Matplotlib cannot be checked.
"""
import matplotlib as mpl # noqa: PLC0415
mpl_vers = tuple(map(int, mpl.__version__.split('.')[:2]))
if pyvista.vtk_version_info <= (9, 2, 2):
return not mpl_vers >= (3, 6)
elif pyvista.vtk_version_info > (9, 2, 2):
return mpl_vers >= (3, 6)
msg = 'Uncheckable versions.' # pragma: no cover
raise RuntimeError(msg) # pragma: no cover
def check_math_text_support():
"""Check if MathText and LaTeX symbols are supported.
Returns
-------
bool
``True`` if both MathText and LaTeX symbols are supported, ``False``
otherwise.
"""
# Something seriously sketchy is happening with this VTK code
# It seems to hijack stdout and stderr?
# See https://github.com/pyvista/pyvista/issues/4732
# This is a hack to get around that by executing the code in a subprocess
# and capturing the output:
# _vtk.vtkMathTextFreeTypeTextRenderer().MathTextIsSupported()
_cmd = 'import vtk;print(vtk.vtkMathTextFreeTypeTextRenderer().MathTextIsSupported());'
proc = subprocess.run([sys.executable, '-c', _cmd], check=False, capture_output=True)
math_text_support = False if proc.returncode else proc.stdout.decode().strip() == 'True'
return math_text_support and check_matplotlib_vtk_compatibility()