2. Transformations Tutorial #2: The Coordinate System Manager#

2.1. Introduction#

This tutorial is about the CoordinateSystemManager (CSM) class of the weldx.transformations package. The purpose of the CSM is to define and manage the relationships of different coordinate systems and their associated data in a tree-like data structure. It provides methods to transform a LocalCoordinateSystem (LCS) or its data to an arbitrary other coordinate system.

This tutorial builds upon the tutorial about coordinate systems, but its content can still be understood without reading the other one first.

2.2. Imports#

from copy import deepcopy

import matplotlib.pyplot as plt
import numpy as np
import xarray as xr
from IPython.display import display

from weldx import Q_, CoordinateSystemManager, LocalCoordinateSystem, WXRotation

2.3. Create a CSM and add coordinate systems#

The creation of CoordinateSystemManager requires the name of the root coordinate system as parameter. As optional second parameter, we can give the CoordinateSystemManager instance a name. If no name is provided, it will get a default name.

csm = CoordinateSystemManager("root", "My CSM")

Further coordinate systems can be added with the add_cs method of the CoordinateSystemManager. It expects four parameters with the fourth being optional.

  1. The name of the new coordinate system.

  2. The name of an already existing coordinate system which serves as reference for the new one. The position and orientation of the new coordinate system that will be passed in form of a LocalCoordinateSystem refer to this reference system

  3. A LocalCoordinateSystem that describes the position and orientation of the new coordinate system in its reference system.

In case you only have the inverse data, meaning the position and orientation of the reference system inside the new coordinate system, you can set the fourth parameter (lsc_child_in_parent) to False and use the corresponding data instead.

lcs_specimen_in_root = LocalCoordinateSystem(coordinates=Q_([0, 1, -0.5], "cm"))
lcs_specimen_in_thermocouple = LocalCoordinateSystem(coordinates=Q_([0, -0.5, 0], "cm"))

csm.add_cs("specimen", "root", lcs_specimen_in_root)
csm.add_cs(
    "thermocouple", "specimen", lcs_specimen_in_thermocouple, lsc_child_in_parent=False
)

Additionally, the CoordinateSystemManager provides some functions that create the LocalCoordinateSystem internally. The method create_cs takes the name of the new coordinate system and its reference system as first two parameters. The remaining parameters and their default values are identical to the ones from the LocalCoordinateSystem.__init__ method. Similarly, there are functions for each of the LocalCoordinateSystems construction methods (from_euler, from_xyz, etc.). The naming is simply create_cs_ plus the name of the corresponding function of the LoocalCoordinateSystem. For example construct_cs_from_euler (link tofunction documentation). As with add_cs, the last parameter of all those methods is lsc_child_in_parent which can be set to False if the provided values represent the orientation and coordinates of the reference system in the new child system.

csm.create_cs_from_euler(
    "flange",
    "root",
    sequence="x",
    angles=20,
    degrees=True,
    coordinates=Q_([-1.5, 1, 1], "cm"),
)
csm.create_cs("torch", "flange", coordinates=Q_([0, 0, -0.25], "cm"))

Please consult the API documentation of the CSM to get an overview over the different LCS creation methods and their parameters

2.4. Visualizing the coordinate system managers structure#

The internal structure of the CoordinateSystemManager is a tree. We can visualize it using the plot_graph function:

csm.plot_graph();
../_images/4974a2512bc8904114ca13ffb8dca3d5d1bd85611ac268801919c3fba7568bce.png

Inside a Jupyter notebook it is sufficient to place the name of a CoordinateSystemManager instance at the end of a code cell to plot its graph:

csm
../_images/4974a2512bc8904114ca13ffb8dca3d5d1bd85611ac268801919c3fba7568bce.png

2.5. Getting coordinate systems and transformations to another reference system#

Getting a coordinate system in relation to any other reference system in form of a LocalCoordinateSystem class is quite easy. Simply call get_cs with two parameters:

  1. The name of the system that should be transformed.

  2. The name of the target reference system.

Let’s try this for some coordinate systems:

display(csm.get_cs("specimen", "root") == lcs_specimen_in_root)
True

Here we get a coordinate system we added directly to the CSM. Therefore, no transformation is needed and the equality check is passed. If we want to get a coordinate system inside of its parent system, we can omit the second parameter as proven below:

display(csm.get_cs("specimen", "root") == csm.get_cs("specimen"))
True

However, note that the returned coordinate system is not necessarily identical to the one that we used during the definition. If we were setting the lsc_child_in_parent during the addition of the coordinate system to False, we provided the inverse transformation:

display(csm.get_cs("thermocouple") == lcs_specimen_in_thermocouple)
False
display(csm.get_cs("specimen", "thermocouple") == lcs_specimen_in_thermocouple)
True

Let’s get the coordinate “thermocouple” coordinate system in reference to the “root” system:

csm.get_cs("thermocouple", "root")
<LocalCoordinateSystem> Size: 132B
Dimensions:      (c: 3, v: 3)
Coordinates:
  * c            (c) <U1 12B 'x' 'y' 'z'
  * v            (v) int64 24B 0 1 2
Data variables:
    orientation  (v, c) float64 72B 1.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0
    coordinates  (c) float64 24B [cm] 0.0 1.5 -0.5

Since the coordinate systems along the transformation path (“thermocouple”, “specimen”, “root”) are not rotated, the coordinates of the resulting coordinate system is simply the sum of the individual coordinates:

coords_transformed = csm.get_cs("thermocouple", "root").coordinates.data
coords_sum = (
    csm.get_cs("thermocouple").coordinates + csm.get_cs("specimen").coordinates
).data

np.all(coords_transformed == coords_sum)
True

As you can see, getting coordinate systems in reference to any of the other coordinate systems of the CSM is quite easy.

3. Visualizing the coordinate systems of the CSM#

You can visualize all coordinate systems of the CSM in relation to each other by using its plot function. The backend parameter defines which rendering engine should be used. We will use "mpl" to use matplotlib first:

HINT: Note that you need the weldx-widgets package installed to use this functionality. Either use pip install weldx-widgets or conda install weldx_widgets -c conda-forge to install it

csm.plot(backend="mpl");
../_images/5bc142e8aedec168314b970425a64ad2a63839559bb19c956eb3909bf323f056.png

By default the root coordinate system is the reference system of the plot, but we can change it using the reference_system parameter as follows:

csm.plot(reference_system="torch", backend="mpl");
../_images/7afd191485b4fe2523a5c3bf395419896d7b9f645e53d2c98422bbd9e00ad9ba.png

The plot function has many other options that can be used to adjust the appearance of the plot. Consult the function documentation for further details.

If you are running a jupyter notebook, you can use k3d as rendering backend. It has a much more powerful rendering engine suited for 3d plots that can handle a vast amount of data and provides an interactive rendering window. Furthermore, the plot function of the CSM creates some additional control surfaces if k3d is used. This gives you the opportunity to modify the plot directly using buttons, checkboxes, sliders, etc. instead of recreating it with a different set of function parameters:

csm.plot(backend="k3d")

3.1. Renaming existing coordinate systems#

Existing coordinate system nodes can be renamed using the relabel method. It expects a dictionary that maps old names to new ones as input.

Note: it is not possible to rename nodes on CSM instances that contain subsystems of any form. Subsystems are CSM instances that are attached to another CSM using the merge method. A detailed description follows further below.

csm.relabel({"thermocouple": "TC"})
csm
../_images/8582f8c9a58f04d30ab081f4f089b332cefe2613b9f0ce3a7bc7bc9b347102a6.png

3.2. Updating and deleting coordinate system transformations#

A coordinate system can be updated by simply calling add_cs again with the correct reference system and an updated LocalCoordinateSystem instance. For example, to update the torch system, one could do the following:

csm.add_cs("torch", "flange", LocalCoordinateSystem(coordinates=Q_([0, 0, -2], "mm")))

Lets check if the values have changed

csm.get_cs("torch", "flange")
<LocalCoordinateSystem> Size: 132B
Dimensions:      (c: 3, v: 3)
Coordinates:
  * c            (c) <U1 12B 'x' 'y' 'z'
  * v            (v) int64 24B 0 1 2
Data variables:
    orientation  (v, c) float64 72B 1.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0
    coordinates  (c) float64 24B [mm] 0.0 0.0 -2.0

As you can see, the system has been updated.

If you want to remove a coordinate system, you have to call delete_cs. The function takes two parameters.

  1. name of the coordinate system you want to delete.

  2. a bool that controls if child coordinate systems should be deleted too or not.

In case you set this parameter to False (default) and try to remove a coordinate system with children, an exception is raised. This is an extra layer of security to prevent unintentional deletion of multiple coordinate systems. Here is an example:

csm_copy = deepcopy(csm)
csm_copy
../_images/8582f8c9a58f04d30ab081f4f089b332cefe2613b9f0ce3a7bc7bc9b347102a6.png

We made a copy of the CoordinateSystemManager. Now we are going to delete the specimen system, which has no other systems attached to it.

csm_copy.delete_cs("TC")
csm_copy
../_images/cc3b5c91f9d0a493871850e509d90d93b93e3e5693a1302fc3ac531a8e818858.png

That worked. Now lets try to remove the flange system:

try:
    csm_copy.delete_cs("flange")
except Exception as e:
    print(f"Something went wrong! The following exception was raised:\n{e}")
Something went wrong! The following exception was raised:
Can not delete coordinate system "flange". It has one or more children that would be disconnected to the root after deletion. Set the delete_children option to "True" to delete the coordinate system and all its children. The attached child systems are: ['torch']

Since the torch system is a child of the flange system and we didn’t tell the function to delete child systems as well, an exception was raised. Lets retry this, but this time we will tell the function that we want child systems to be removed too:

csm_copy.delete_cs("flange", True)
csm_copy
../_images/bdc98bcc64dc564bf6077b5b654316dbc767800382d261dc6789a69cc29c82da.png

As you can see, we were successful and only the “root” and “specimen” coordinate systems are left in the CoordinateSystemManager instance.

3.3. Assignment and transformation of spatial data#

A coordinate system stored in the CoordinateSystemManager can also get spatial data assigned to it. For example, this might be a sensor positions or geometry data in form of a point clouds. In this case it becomes the data’s reference system and all values refer to its coordinate origin.

Data can be assigned to a coordinate system with the assign_data function. It expects two parameters.

  1. The actual data. This can either be an SpatialData instance or an xarray.DataArray.

  2. A name of the data and the third one the name of the coordinate system that the data belongs to.

In the following lines we define and assign some data to the CoordinateSystemManager. Let’s create some data first:

from weldx import SpatialData

points_pc = Q_([[2, (i / 50) + 1, 0] for i in range(201)], "mm")
point_cloud_in_root = xr.DataArray(
    points_pc, dims=["n", "c"], coords={"c": ["x", "y", "z"]}
)

points_geo = Q_([[-1, -1, -0.2], [1, -1, -0.2], [-1, 2, -0.2], [1, 2, -0.2]], "cm")
triangles = [[0, 1, 2], [2, 3, 1]]
specimen_geometry_in_specimen = SpatialData(points_geo, triangles)

The SpatialData class we used above is mostly a container class for 3d data. Additionally to pure point data, it can also store connectivety data in form of triangles. Consult the class documentation for further information. Now we add the data to the CSM:

csm.assign_data(point_cloud_in_root, "point cloud", "root")
csm.assign_data(specimen_geometry_in_specimen, "specimen geometry", "specimen")

With the get_data function, the unmodified data can be retrieved from the CoordinateSystemManager using its name as first parameter.

assert point_cloud_in_root.identical(csm.get_data("point cloud"))

The optional second parameter can be used to get the data transformed into any of the CoordinateSystemManagers coordinate systems. You just need to specify the desired target coordinate systems name:

csm.get_data("specimen geometry", "root")
SpatialData(coordinates=<xarray.DataArray (n: 4, c: 3)> Size: 96B
<Quantity([[-1.   0.  -0.7]
 [ 1.   0.  -0.7]
 [-1.   3.  -0.7]
 [ 1.   3.  -0.7]], 'centimeter')>
Coordinates:
  * c        (c) <U1 12B 'x' 'y' 'z'
Dimensions without coordinates: n, triangles=array([[0, 1, 2],
       [2, 3, 1]], dtype=uint64), attributes=None)

By default, the plot method will also visualize attached data. Here is an interactive plot of the CSM with the newly added data. You can change the reference system using the control surfaces.

csm.plot(backend="k3d")
/home/docs/checkouts/readthedocs.org/user_builds/weldx/conda/latest/lib/python3.10/site-packages/traittypes/traittypes.py:97: UserWarning: Given trait value dtype "float64" does not match required type "float32". A coerced copy has been created.
  warnings.warn(

A nice feature of attached SpatialData instances is that they are visualized with closed surfaces if they contain triangle data. In contrast, xarray.DataArray instances are always rendered as point clouds.

It is not necessary to attach data to a coordinate system to transform it to another one. The CoordinateSystemManager also provides the transform_data function for this purpose. It expects three parameters.

  1. Actual data and must be a numpy.ndarray or an xarray.DataArray.

  2. The source system name.

  3. The target systems’ name.

csm.transform_data(points_geo, "specimen", "root")
<xarray.DataArray (n: 4, c: 3)> Size: 96B
<Quantity([[-1.   0.  -0.7]
 [ 1.   0.  -0.7]
 [-1.   3.  -0.7]
 [ 1.   3.  -0.7]], 'centimeter')>
Coordinates:
  * c        (c) <U1 12B 'x' 'y' 'z'
Dimensions without coordinates: n

If you compare the resulting coordinates to the plot of the the geometry in the root coordinate system, you will see that they are identical.

3.4. Time dependencies#

As described in the previous tutorial about the LocalCoordinateSystem class, the orientation of a coordinate system towards its reference system might vary in time. From the API side, time dependent coordinate systems are not treated any different than constant coordinate systems. However, the get_cs function needs to perform some time interpolations internally if the timestamps of all involved coordinate systems aren’t identical. You might wonder which times the resulting interpolated LocalCoordinateSystem possesses. The default behavior is that its timestamps are the time union of all coordinate systems participating in the transformation. However, you can also provide the desired timestamps using the time parameter of the get_cs function.

Let’s define a new CoordinateSystemManager to demonstrate the different approaches:

csm_tdp = CoordinateSystemManager("root")

csm_tdp.create_cs(
    "parent",
    "root",
    coordinates=Q_([[3, -3, 0], [0, -3, 0]], "mm"),
    orientation=WXRotation.from_euler("z", [0, -np.pi / 2]).as_matrix(),
    time=Q_([1, 6], "days"),
)

csm_tdp.create_cs(
    "child",
    "parent",
    coordinates=Q_([0, 6, 0], "mm"),
    orientation=WXRotation.from_euler("z", [0, np.pi / 2]).as_matrix(),
    time=Q_([2, 5], "days"),
)

csm_tdp.create_cs(
    "child child",
    "child",
    coordinates=Q_([6, 0, 0], "mm"),
    orientation=WXRotation.from_euler("z", [0, np.pi / 2]).as_matrix(),
    time=Q_([3, 4], "days"),
)

Now we plot the CSM:

csm_tdp.plot(backend="k3d")

As you can see, the traces of the time dependent coordinate systems are drawn into the plot. You can hide them by unchecking the corresponding checkbox. Use the slider or the “play” button to cycle through the different time steps. Also try switching the reference systems and see what happens.

Now we use get_cs with a specific time to transform all coordinate systems to the "root" system, create a new CSM with this data and plot it:

time = Q_([(i + 1) for i in range(144)], "hours")

csm_tdp_2 = CoordinateSystemManager("root")

system_names = ["parent", "child", "child child"]

for name in system_names:
    lcs = csm_tdp.get_cs(name, "root", time=time)
    csm_tdp_2.add_cs(name, "root", lcs)

csm_tdp_2.plot(backend="k3d")

If you take a look at the traces, you will notice that they looks much smoother now. Use the time controls and you will see that the data grid is now much denser.

We can also tell the get_cs method to interpolate all coordinate systems to match the timestamps of one of the CSMs time dependent coordinate systems. Therefore, we simply provide the target systems name as time parameter:

csm_tdp_3 = CoordinateSystemManager("root")

system_names = ["parent", "child", "child child"]

for name in system_names:
    lcs = csm_tdp.get_cs(name, "root", time="parent")
    csm_tdp_2.add_cs(name, "root", lcs)

csm_tdp_2.plot(backend="k3d")

In the previous two examples we used the get_cs method to interpolate the coordinate system data and create a new CSM from it. The purpose was to showcase how get_cs works with time dependent coordinate systems. However, if you just want to get an interpolated copy of a CSM, it is much simpler to use its interp_time method:

time = time = Q_([(i + 1) for i in range(144)], "hours")

csm_tdp_3 = csm_tdp.interp_time(time=time)

csm_tdp_3.plot(backend="k3d")

You can also limit the time interpolation to certain coordinate systems by using the affected_coordinate_systems parameter. It expects a list of coordinate system names.

time = Q_([3600, 7200], "minutes")

csm_tdp_4 = csm_tdp.interp_time(
    time=time, affected_coordinate_systems=["child", "child child"]
)

The following comparisons show that it worked:

np.all(csm_tdp_4.get_cs("parent").time == time)
False
np.all(csm_tdp_4.get_cs("child").time == time)
True
np.all(csm_tdp_4.get_cs("child child").time == time)
True

3.5. Merging and unmerging#

Sometimes a larger hierarchy of coordinate systems can be subdivided into multiple smaller subsystems. Defining them individually might be more intuitive and easier. For this reason, the CoordinateSystemManager provides the possibility to merge an instance into another one. We will introduce this functionality with a short example.

Consider an automated welding process, where the torch is moved by a robot arm. The robot provides two mount points for additional equipment. We know where the torch and the mount points are located in the moving robot head coordinate system. So we can define the following CoordinateSystemManager instance to describe the setup:

csm_robot = CoordinateSystemManager("robot head", "robot coordinate systems")
csm_robot.create_cs("torch", "robot head", coordinates=Q_([0, 0, -2], "mm"))
csm_robot.create_cs("mount point 1", "robot head", coordinates=Q_([0, 1, -1], "mm"))
csm_robot.create_cs("mount point 2", "robot head", coordinates=Q_([0, -1, -1], "mm"))
csm_robot
../_images/9d795a1b9fd0d8a409bf6c0ef6c8d8914dc09323de1c8d1ddca006a34c49299c.png

As extra measurement equipment we have a laser scanner that we want to attach to the first mounting point. It uses its own coordinate system and all the gathered data refers to it. We know the coordinates of the scanners own mount point inside this system so that we can define:

csm_scanner = CoordinateSystemManager("scanner", "scanner coordinate systems")
csm_scanner.create_cs("mount point 1", "scanner", coordinates=Q_([0, 0, 2], "mm"))
csm_scanner
../_images/b7903def264c5f520591aa383df5168289baeef03ad7022886947e71e620838f.png

Note that the coordinate system “mount point 1” was added to both CoordinateSystemManager instances. Merging requires that both instances share exactly one common coordinate system that serves as connecting node.

Now we merge the scanner coordinate systems with the robot coordinate systems using the merge function:

csm_robot.merge(csm_scanner)
csm_robot
../_images/e6c3028366d38176af782a11cdb9d5d0e7f5a5c3a1b22aace1d763d6dcea2f78.png

You can see in the output above that the scanner coordinate systems were successfully merged into the CoordinateSystemManager instance containing the robot data. Let’s define some additional CoordinateSystemManager instances:

csm_specimen = CoordinateSystemManager("specimen", "specimen coordinate systems")
csm_specimen.create_cs("thermocouple 1", "specimen", coordinates=Q_([1, 1, 0], "mm"))
csm_specimen.create_cs("thermocouple 2", "specimen", coordinates=Q_([1, 4, 0], "mm"))
csm_specimen
../_images/ecf571eaace70d5d5d779da84169562faa47c14971c8423441c79b322c07007e.png
csm_global = CoordinateSystemManager("root", "global coordinate systems")
csm_global.create_cs("specimen", "root", coordinates=Q_([1, 2, 3], "mm"))
csm_global.create_cs("robot head", "root", coordinates=Q_([4, 5, 6], "mm"))
csm_global
../_images/2de21a5a13a4f681adbb2625b5b5a68add9fb06344613c41295f0cd5b9d74ef3.png

Now we merge them all:

csm_global.merge(csm_robot)
csm_global.merge(csm_specimen)
csm_global
../_images/0684c58647ff84273963d1e2058be4e0a57184ec470ddb81f66a6f3d2c0f6fa4.png

All coordinate systems are now collected in a single CoordinateSystemManager. We can extract the previously merged subsystems with the function subsystems property. It returns a list with the CoordinateSystemManager instances:

csm_list = csm_global.subsystems
for sub_csm in csm_list:
    print(sub_csm.name)
robot coordinate systems
specimen coordinate systems

Comparing the returned CoordinateSystemManager instances with the original ones shows that they are indeed identical:

for sub_csm in csm_list:
    if sub_csm.name == "robot coordinate systems":
        print(sub_csm == csm_robot)
    else:
        print(sub_csm == csm_specimen)
True
True

You might have noticed, that there is no CoordinateSystemManager for the scanner systems. The reason for this is that it is a subsystem of the robot coordinate systems and the subsystems property does not recursively extract nested subsystems. However, we can simply use subsystems a second time to get it:

for sub_csm in csm_list:
    if sub_csm.name == "robot coordinate systems":
        print(sub_csm.subsystems[0].name)
scanner coordinate systems

Using the subsystems property does not affect the state of the current CoordinateSystemManager:

csm_global
../_images/0684c58647ff84273963d1e2058be4e0a57184ec470ddb81f66a6f3d2c0f6fa4.png

To remove all subsystems, you can call remove_subsystems:

csm_global.remove_subsystems()
csm_global
../_images/2de21a5a13a4f681adbb2625b5b5a68add9fb06344613c41295f0cd5b9d74ef3.png

Alternatively, if you want to decompose a CoordinateSystemManager instance into all its subsystems, you can use the unmerge function. It works exactly the same as the subsystems property with the difference that it also removes all subsystem data from the affected CoordinateSystemManager instance.

3.6. Time dependent spatial data#

This chapter covers an advanced topic and requires you to have a solid understanding of the previously discussed topics, especially the ones about time dependencies. Additionally, you should also be familiar with the SpatialData class.

Time dependent spatial data is simply spatial data that gets another dimension that is associated with time. A possible way to utilize this would be for example to gather the different geometry states between multiple welding passes.

However, in the context of the CoordinateSystemManager, it is used to construct a complex 3d geometry from multiple, time-dependent subsets. If you wonder what actual use-case this might have, think of a laser scanner that scans a geometry from multiple positions and angles. All the data is usually captured in the scanners own coordinate system. This means that all scans lie initially on top of each other. If we know the time-dependent coordinates and orientation of the scanner as well as the time at which each scan was taken, we can reconstruct the whole scanned object, given we know all the transformations to the scanned objects coordinate system. The CoordinateSystemManager does this automatically if you pass time-dependent SpatialData to it.

To demonstrate how this is done, we will look at a small example. First we create the data of a 2d-scanner:

num_scan_n = 20

scan_width_in_mm = 4


dx_scan = scan_width_in_mm / (num_scan_n - 1)
scan_profile = np.array(
    [
        [
            0,
            i * dx_scan - scan_width_in_mm / 2,
            np.sin(i * np.pi / (num_scan_n - 1)) - 2,
        ]
        for i in range(num_scan_n)
    ]
)

This is just a simple, single sine-wave:

plt.plot(scan_profile[:, 1], scan_profile[:, 2])
plt.xlabel("y in mm")
plt.ylabel("z in mm");
../_images/690c0b7b37f6314890d90085161d495aa2465d371823f0c6c8a7242855ad963a.png

Now lets say we scanned 40 profiles over a period of 20 seconds. For simplicity, we reuse the same profile for every scan.

num_scan_p = 40
duration_scan_in_sec = 20
dt_scan = duration_scan_in_sec / (num_scan_p - 1)
time_p = Q_([i * dt_scan for i in range(num_scan_p)], "s")

scan_time = Q_([i * dt_scan for i in range(num_scan_p)], "s")
scan_data = Q_([scan_profile for _ in range(num_scan_p)], "mm")

Note that we now have a 3 dimensional scan_data array with the outer dimension being time. The next one represents the individual points of a scan and the last one the 3 spatial coordinates:

scan_data.shape
(40, 20, 3)

The content of the next cell is purely to get a better visualization and can be omitted. We create a list of triplets. Each of those tells the SpatialData the indices of the data points that form a triangle. This enables the CoordinateSystemManager to plot the spatial data as a closed surface instead of a point cloud.

triangles = []
for j in range(num_scan_p - 1):
    for i in range(num_scan_n - 1):
        offset = j * num_scan_n + i
        triangles.append([offset, offset + num_scan_n, offset + 1])
        triangles.append([offset + 1, offset + num_scan_n, offset + num_scan_n + 1])

With all the data gathered, we can create our SpatialData class:

scans = SpatialData(coordinates=scan_data, triangles=triangles, time=time_p)

Now we need a CoordinateSystemManager where we can attach the data.

csm_scan = CoordinateSystemManager("base")

To get things a bit more interesting, let’s say our specimen is placed on a table that rotates 180 degrees during the scan process. Note that we will use some unrealistically small distances so that the final plot is more compact for demonstration purposes.

table_rotation_degrees = 180
num_timesteps_csm = 101

dt_csm = duration_scan_in_sec / (num_timesteps_csm - 1)
time_csm = Q_([i * dt_csm for i in range(num_timesteps_csm)], "s")


deg_per_step = table_rotation_degrees / (num_timesteps_csm - 1)
angles_table = [i * deg_per_step for i in range(num_timesteps_csm)]


csm_scan.create_cs_from_euler(
    "table",
    "base",
    sequence="z",
    angles=angles_table,
    degrees=True,
    coordinates=Q_([-2, -2, -2], "mm"),
    time=time_csm,
)

The specimen is placed at a certain offset to the tables center of rotation:

csm_scan.create_cs("specimen", "table", coordinates=Q_([-1, 3, 2], "mm"))

The scanner itself is mounted at a movable robot arm that has its own coordinate system which we call “tcp” (tool center point). During the scanning process, the robot arm performs a linear translation.

tcp_mm_per_step = -5 / (num_timesteps_csm - 1)
coordinates_tcp = Q_(
    [[3, i * tcp_mm_per_step + 12, 10] for i in range(num_timesteps_csm)], "mm"
)

csm_scan.create_cs("tcp", "base", coordinates=coordinates_tcp, time=time_csm)

The scanners coordinate system has a fixed offset to the robot arms coordinate system.

csm_scan.create_cs("scanner", "tcp", coordinates=Q_([0, 0, 2], "mm"))

Our complete CoordinateSystemManager has the following structure:

csm_scan
../_images/218095516e1ef6c7b2cb4ed0e6a4342692049d4d39cbb282f736c98a7af505a6.png

Remember that the coloured arrows represent time-dependent relations.

Now let’s recapitulate what we are going to do. We got a set of scan profiles of a specimen measured at different points in time in the scanner coordinate system. The object we scanned is static in the specimen coordinate system. So we tell the CoordinateSystemManager that the time-dependent data we want to add was measured in the scanner coordinate system but should be transformed into the specimen coordinate system. This is done as follows:

csm_scan.assign_data(
    scans, data_name="scans", reference_system="scanner", target_system="specimen"
)

For each time step of SpatialData, the CoordinateSystemManager now calculates the correct, time-dependent transformation from the scanner to the specimen coordinate system. Those transformations are then applied to the data before it is stored. Let’s plot the CoordinateSystemManager and check if everything worked as expected.

csm_scan.plot(backend="k3d")

To understand the plot, it is best to play the animation and see how everything moves in relation to each other.

Due to the rotating table, we get a circular shape. Because the scanner was also performing a translation towards the center of rotation, we do also get a varying radius. Since we used the same profile for each time step, the resulting geometry has the same cross-section everywhere and it looks like the scanner is just following the peak of the geometry.

Of cause, this is a very simplistic example, but it demonstrates how easy it is to reconstruct scan data with the CoordinateSystemManager, regardless of how complex the relative motions of the different involved coordinate systems are. Reconstructing real scan data wouldn’t be any different.