marcomusy / vedo

A python module for scientific analysis of 3D data based on VTK and Numpy
https://vedo.embl.es
MIT License
2.03k stars 264 forks source link

How to increase caption leader line length #428

Closed XushanLu closed 3 years ago

XushanLu commented 3 years ago

Hi @marcomusy,

I am trying to add a caption to certain elements in my plot and I could not find a way to increase the size of the leader line that points the caption to the object it is associated with.

Code used:

#!/usr/bin/env python3

import numpy as np
from vedo import TetMesh, show, screenshot, settings, Picture, \
    Plotter, Axes, tetralize, delaunay3D, UGrid, Rectangle, Cone, Ellipsoid, \
    LegendBox, Spheres, Sphere

import time as tm
import os

os.environ['KMP_DUPLICATE_LIB_OK']='True'

# Do some settings
settings.useDepthPeeling=False  # Useful to show the axes grid
font_name = 'Theemim'
settings.defaultFont = font_name
settings.multiSamples=8
# settings.useParallelProjection = True # avoid perspective parallax

# Create a TetMesh object form the vtk file
tet = TetMesh('final_mesh.vtk')
# The input model
inp_cond = UGrid('dynamic.vtu').alpha(0.5).c('lightgray')
inp_cond.lineWidth(2).lc('lightgray')
inp_pts = inp_cond.points()
inp_sphere = Spheres(inp_pts, r=2, c='lightgray')
inps = Sphere(inp_pts[0], r=2, c='lightgray')
inps.legend('True model')
# inp_cond.legend('True model')
# The recovered model
recovered = UGrid('test_best.vtu')
recovered.c('g').alpha(0.5).wireframe(True).lineWidth(3).lineColor('g')
# recovered.legend('Initial model')
rec_pts = recovered.points()
rec_sphere = Spheres(rec_pts, r=2, c='g')
recs = Sphere(rec_pts[0], r=2, c='g')
recs.legend('Initial model')
# The mean model
mean_cond = UGrid('test_mean.vtu')
mean_cond.c('r').alpha(0.3)
# mena_cond.lengend('Mean model')
mean_pts = mean_cond.points()
# Get the standard deviation and visualize it using ellipsoids
mean_x = mean_cond.getPointArray('stdevX')
mean_y = mean_cond.getPointArray('stdevY')
mean_z = mean_cond.getPointArray('stdevZ')
mcmc_ellipsoid = []
ipts = 0
for pts in mean_pts:
    axis1 = (mean_x[ipts], 0., 0.)
    axis2 = (0., mean_y[ipts], 0.)
    axis3 = (0., 0., mean_z[ipts])
    ellipsoid = Ellipsoid(pts, axis1, axis2, axis3, c='r')
    if ipts == 3:
        # Add a caption for the last ellipsoid
        # evig = ellipsoid.vignette('Uncertainty', pts, s=6, c='r')
        # evig.rotateZ(90, locally=True)
        ellipsoid.caption('Stdev', pts, [0.05, 0.025], justify='center', pad=0)

    mcmc_ellipsoid.append(ellipsoid)
    ipts += 1

ellipsoid.legend('Mean model')

# Clone the TetMesh object and threshold it to get the conducotrs
conductor = tet.clone().threshold(name='cell_scalars', above=3, below=3)
# This will get rid of the background Earth unit and air unit in the model
# which leaves us with the central part of the model
tet.threshold(name='cell_scalars', above=1, below=1)

# First build a Box object with its centers and dimensions
cent = [0, 0, -250]
# We can also cut msh directly rather than cutting tet, but that gives us
# something uglier, like what you would get without click the 'crinkle clip'
# option in Paraview and much worse because it would not keep mesh cells intact
bounds = [-110, 110, -200, -50, -75, 0]
msh = tet.tomesh().lineWidth(2).lineColor('w')
msh.c('lightgray')
msh.crop(bounds=bounds)

# Convert the TetMesh object to a mesh object and set the edge line width and
# color
cond = conductor.tomesh().alpha(0.5)

# Get an axes object for both msh and cond
group = msh + inp_cond + mean_cond              # First combine the two into one
axes = Axes(group,
            xtitle='x (m)',
            ytitle='y (m)',
            ztitle='z (m)',
            xLabelSize=0.02,
            xTitlePosition=0.55,
            xTitleRotation=180,
            xLabelRotation=180,
            xLabelOffset=-1.75,
            xTitleOffset=-1.15,
            yTitlePosition=0.60,
            yTitleOffset=-1.15,
            yLabelRotation=90,
            yLabelOffset=-1.5,
            yLabelSize=0.02,
            zAxisRotation=90,
            zLabelRotation=90,
            zTitlePosition=0.65,
            zValuesAndLabels=[(-50, '-50'), (0, '0')],
            zTitleOffset=0.04,
            zLabelSize=0.02,
            # zLabelOffset=-1.5,
            axesLineWidth=3,
            yrange=group.ybounds(),
            zrange=[-60, 10],
            yzShift=1,
            tipSize=0.,
            # yzGrid=True,
            zxGrid=True,
            xyGrid=True,
            gridLineWidth=5,
            )
# manually move the Z axis back in place
for a in axes.unpack():
    # print(a.name)
    if "ztitle" in a.name:
        xb = group.xbounds()
        a.shift((xb[1]-xb[0]), 0, 0)
    if ("xAxis" in a.name or "xM" in a.name or "xN" in a.name):
        yb = group.ybounds()
        a.shift(0, (yb[1]-yb[0]), 0)

# Now we need plot the profiles (sources and receivers)
prof_cent = np.array([
    [-30., -100., 0.],
    [0., -100., 0.],
    [30., -100., 0.],
])

tx_list = []
prof_color = ['royalblue', 'indianred', 'forestgreen']
dv = [50., 50., 0]
nprofile = 3
for iprof in range(nprofile):
    irow = iprof
    cent = prof_cent[irow, :]
    pt1 = cent - dv
    pt2 = cent + dv
    tx = Rectangle(p1=pt1, p2=pt2, c=prof_color[iprof])
    tx.lineWidth(6).wireframe(True)
    tx_list.append(tx)

# Now we need to plot the receivers
nobs = 11
src2obs_vec = [0., 100., 0.]
stn_spacing = 20
rx_list = []
for iprof in range(nprofile):
    for iobs in range(nobs):
        obs = prof_cent[iprof, :] + src2obs_vec + [0, iobs * stn_spacing, 0.]
        rx = Cone(obs, r=1, height=3, axis=(0, 0, 1), c=prof_color[iprof])
        rx_list.append(rx)
        if iobs == 0:
            # obs = obs + [20.0, .0, 0.]
            rx.caption('Profile {:d}'.format(iprof+1), obs, [0.05, 0.025],
                       justify='center')

# Set the camera position
plt = Plotter()
plt.camera.SetPosition( [303.727, 411.256, 336.311] )
plt.camera.SetFocalPoint( [7.918, 1.833, -13.003] )
plt.camera.SetViewUp( [-0.342, -0.455, 0.822] )
plt.camera.SetDistance( 614.126 )
plt.camera.SetClippingRange( [202.328, 1169.409] )

size = [3940, 2160]
rx.caption('Rx', obs, [0.025, 0.025], pad=1, justify='center')
# tx.flag('Tx')
vig = tx.vignette('Tx', pt2, s=6, c='forestgreen')
vig.followCamera()

# Get a legend box
lbox = LegendBox([inps, recs, ellipsoid], width=0.15, posx=0.8, posy=0.7)
spheres = inp_sphere + rec_sphere + mcmc_ellipsoid

plt.show(msh, inp_cond, mean_cond, mcmc_ellipsoid, tx_list, rx_list, axes, vig,
         recovered, spheres, lbox, size=size, interactive=True,
         resetcam=0, zoom=1.2)
fig_file = 'model_mesh.png'
screenshot(fig_file)

from wand import image

with image.Image(filename=fig_file) as imag:
    imag.trim(color=None, fuzz=0)
    imag.save(filename=fig_file)

And all the data are in the zip file: data_files.zip

Here is the final plot I got: model_mesh As you can see, the line that points to the object from the caption is extremely small. I did not find anything that can be changed in the function caption, and I also don't think there is anything in the lower-level vtk code either (vtk.vtkCaptionActor2D). Do you have any idea how to make that line more visible?

PS: I have made some changes to the function caption to switch off the caption box and LegendBox to allow a more flexible positioning of the legend box:

capt.SetBorder(Fasle)
# Added two optional variables posx and posy to control the location of the legend box
    def __init__( self,
                 entries=(),
                 nmax=12,
                 c=None,
                 font="",
                 width=0.18,
                 height=None,
                 pad=2,
                 bg="k8",
                 alpha=0.25,
                 pos="top-right",
                 posx=0,
                 posy=0,
        ):
        if   pos == 1 or ("top" in pos and "left" in pos):
            self.GetPositionCoordinate().SetValue(0, sy)
        elif pos == 2 or ("top" in pos and "right" in pos):
            self.GetPositionCoordinate().SetValue(sx, sy)
        elif pos == 3 or ("bottom" in pos and "left" in pos):
            self.GetPositionCoordinate().SetValue(0, 0)
        elif pos == 4 or ("bottom" in pos and "right" in pos):
            self.GetPositionCoordinate().SetValue(sx, 0)
        if posx != 0 and posy !=0:
            if posx > 1 or posx < 0:
                raise Exception("posx has to be within [0, 1]")
            if posy > 1 or posy < 0:
                raise Exception("posy has to be within [0, 1]")
            self.GetPositionCoordinate().SetValue(sx*posx, sy*posy)
marcomusy commented 3 years ago

Hi @XushanLu no surprise that you didn't find it... it was really well hidden in vtk :) Try this:

        if iobs == 0:
            rx.caption('Profile {:d}'.format(iprof+1), obs, [0.05, 0.025], justify='center')
            rx._caption.GetProperty().SetOpacity(1)
            rx._caption.GetProperty().SetLineWidth(3)

Screenshot from 2021-07-16 22-16-44

Also you may not need to modify LegendBox, just use: lbox = LegendBox([inps, recs, ellipsoid], width=0.15, pos=(0.8,0.7))

You also seem to have some overlapping lines (top-left of figure) you may want to add a small tolerance in z to make them distinguishable.

marcomusy commented 3 years ago

PS: not that you can simplify the Axes creation without the need of unpacking them


axes = Axes(group,
            xtitle='x (m)',
            xTitlePosition=0.55,
            xLabelSize=0.02,
            xLabelRotation=180,
            xLabelOffset=-1.6,
            xTitleRotation=180,
            xTitleOffset=-0.12,
            xShiftAlongY=1,
            ytitle='y (m)',
            yTitlePosition=0.55,
            yTitleOffset=-0.15,
            yLabelSize=0.02,
            yLabelRotation=90,
            yLabelOffset=-1.5,
            yShiftAlongX=1,
            ztitle='z (m)',
            zrange=[-60, 10],
            zValuesAndLabels=[(-50, '-50'), (0, '0')],
            zTitlePosition=0.65,
            zAxisRotation=90,
            zLabelSize=0.02,
            zShiftAlongX=1,
            axesLineWidth=3,
            gridLineWidth=5,
            tipSize=0,
            xyFrameLine=True,
            zxGrid=True,
            xyGrid=True,
)

### COMMENT-OUT unpack() loop
# for a in axes.unpack():
#     # print(a.name)
#     if "ztitle" in a.name:
#         xb = group.xbounds()
#         a.shift((xb[1]-xb[0]), 0, 0)
#     if ("xAxis" in a.name or "xM" in a.name or "xN" in a.name):
#         yb = group.ybounds()
#         a.shift(0, (yb[1]-yb[0]), 0)

btw, this was implemented in vedo from your input !! :)

XushanLu commented 3 years ago

Hi Marco,

Thanks very much for your comments. I am no good at c/c++ at all and it is probably not surprising that I cannot find it. Now I can finally control the size of it. Previously it was just so tiny!

I did not know I can just specify pos=(0.8, 0.7) like that. I think the documentation was not clear about this. You may want to add a bit more description there. I then quickly went through the code and thought what I did there was the only possible way to make it happen. I think I still don't quite understand the code there but I don't need to worry about it if it works!

Yes, thanks for pointing out the overlapping. I am aware it is happening but did not bother to get anything done to make them distinguishable. I will do that later for publications.

It is great to see that the axes do not need to be unpacked now. I think the current implementation is a lot neater!

Thanks very much for your help.

Xushan