Examples¶
The scripts are in the examples/ folder.
Writing¶
Surfaces, profiles, irregular surfaces and point clouds, with metadata, placement, vendor extensions, different storage types and revisions.
"""Write surfaces, profiles and point clouds: python examples/write.py [folder]."""
import sys
import tempfile
from datetime import UTC, datetime
from pathlib import Path
import numpy as np
import x3pio
from x3pio import CoordinateSystem, DataStorage, DataType, Placement, Revision
folder = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(tempfile.mkdtemp())
folder.mkdir(parents=True, exist_ok=True)
# The standard asks for a creation date, which a new file sets to now, and for the kind of probing.
metadata = x3pio.Metadata(
creator="Jane Doe",
instrument=x3pio.Instrument("Acme Metrology", "Scanner 3000", "12345", "firmware 2.1"),
probing_system=x3pio.ProbingSystem(x3pio.ProbingType.NON_CONTACTING, "confocal, 50x objective"),
calibration_date=datetime(2026, 1, 15, 9, 30, tzinfo=UTC),
)
# Heights and increments are in metres; NaN marks a point that was not measured.
heights = 1e-6 * np.random.default_rng(0).normal(size=(480, 640))
heights[100:110, 200:210] = np.nan
x3pio.write(folder / "surface.x3p", heights, x_scale=1e-6, y_scale=1e-6, metadata=metadata)
# Repeated measurements are the layers of one file.
repeats = [heights, heights + 1e-7, heights + 2e-7]
x3pio.Surface.from_layers(repeats, x_scale=1e-6, y_scale=1e-6, metadata=metadata).save(
folder / "surface_layers.x3p"
)
# A profile is a line of heights. One that does not lie on a line is given by its points.
position = np.linspace(0.0, 4.0, 200)
x3pio.write(folder / "profile.x3p", 1e-6 * np.sin(position), x_scale=20e-6, metadata=metadata)
turn = np.linspace(0.0, np.pi, 50)
path = np.column_stack([np.cos(turn), np.sin(turn), 1e-3 * np.cos(4 * turn)]) * 1e-3
x3pio.Profile.from_points(path, metadata=metadata).save(folder / "profile_curved.x3p")
# A surface keeps its rows and columns, but its points need not lie on a grid: each stores its own
# x and y.
u, v = np.meshgrid(np.linspace(0, 1, 5), np.linspace(0, 1, 4))
bent = np.stack([(u + 0.1 * np.sin(3 * v)) * 1e-3, v * 2e-3, 1e-6 * np.cos(4 * u + v)], axis=-1)
x3pio.Surface.from_points(bent, metadata=metadata).save(folder / "surface_irregular.x3p")
# A point cloud has no order and no neighbours: an (N, 3) array of x, y and z.
cloud = np.random.default_rng(1).uniform(0.0, 1e-3, (5000, 3))
x3pio.write_points(folder / "point_cloud.x3p", cloud, metadata=metadata)
# The storage type sets the precision and the size. The default z_scale, "auto", is the smallest
# that fits the heights into an integer type.
compact = x3pio.Surface.from_array(
heights, x_scale=1e-6, y_scale=1e-6, z_type=DataType.INT16, metadata=metadata
)
for storage in DataStorage:
compact.save(folder / f"surface_int16_{storage.name.lower()}.x3p", storage=storage)
# A placement says where the stored view coordinate system lies in the global one.
placement = Placement.translation(0.10, 0.02, 0.0) @ Placement.from_axis_angle([0, 0, 1], np.pi / 6)
x3pio.write(
folder / "surface_placed.x3p",
1e-6 * np.ones((3, 4)),
x_scale=1e-3,
y_scale=1e-3,
placement=placement,
coordinate_system=CoordinateSystem.VIEW,
metadata=metadata,
)
# A vendor adds files to the container under a URI that it owns. Amd 1:2020, the default, gives
# each file an ID; ISO 25178-72:2017 has one ID per vendor.
extended = x3pio.Surface.from_array(np.zeros((4, 4)), x_scale=1e-6, y_scale=1e-6, metadata=metadata)
extended.extensions.add("http://www.acme.example", "notes/sample.txt", b"Measured on the bench.\n")
extended.save(folder / "surface_extensions.x3p")
x3pio.write(
folder / "surface_iso5436.x3p",
np.zeros((4, 4)),
x_scale=1e-6,
y_scale=1e-6,
revision=Revision.ISO5436_2000,
metadata=metadata,
)
print("written to", folder)
for file in sorted(folder.glob("*.x3p")):
print(f"{file.name:28s}{file.stat().st_size:>10} bytes")
Reading¶
The layers, the metadata and the checksums of a file, from a path, a stream or bytes.
"""Read a file and look at its layers, metadata and checksums: python examples/read.py."""
import io
import zipfile
from pathlib import Path
import x3pio
# The sample files of the repository, one of each kind; use the paths of your own files instead.
SAMPLES = Path(__file__).resolve().parent.parent / "tests" / "samples" / "iso25178-72-fdam1"
# ``read`` takes a path or an open file and returns what the file holds: a Surface, a Profile or
# a PointCloud. They share the layers, the placement, the metadata and the revision.
for name in ("surface_binary", "profile_layers_binary", "point_cloud_binary"):
x3p = x3pio.read(SAMPLES / f"{name}.x3p")
shapes = [layer.points().shape for layer in x3p.layers]
print(f"{type(x3p).__name__:10}", x3p.revision.name, shapes)
# Heights and coordinates are in metres, and NaN marks a point that was not measured. The axes of a
# regular grid follow from the increments.
surface = x3pio.read(SAMPLES / "surface_binary.x3p")
layer = surface.layers[0]
print("measured points:", int(layer.valid.sum()), "of", layer.z.size)
print("x runs to", layer.x_values[-1], "m, y to", layer.y_values[-1], "m")
# The metadata is optional, and a field may be empty.
if surface.metadata is not None:
print(surface.metadata.instrument.manufacturer, surface.metadata.creator)
# ``info`` reads the description of a file and leaves its data alone.
summary = x3pio.info(SAMPLES / "profile_layers_binary.x3p")
print(summary.feature_type.name, summary.layer_count, "layers of", summary.shape)
# Streams and bytes work as well as paths.
blob = (SAMPLES / "surface_binary.x3p").read_bytes()
from_stream = x3pio.read(io.BytesIO(blob))
from_bytes = x3pio.X3pFile.loads(blob)
print("same data from a stream and from bytes:", from_stream == from_bytes == surface)
# A file that was changed after it was written fails its MD5 checksum, unless verify is False.
source = zipfile.ZipFile(io.BytesIO(blob))
changed = io.BytesIO()
with zipfile.ZipFile(changed, "w") as target:
for member in source.namelist():
content = source.read(member)
target.writestr(member, content + b"\n" if member == "main.xml" else content)
try:
x3pio.read(io.BytesIO(changed.getvalue()))
except x3pio.X3pChecksumError as error:
print("changed after it was written:", error)
print("without the check:", x3pio.read(io.BytesIO(changed.getvalue()), verify=False).revision.name)
# All errors of x3pio are X3pError, and a bad file is a ValueError.
try:
x3pio.read(io.BytesIO(b"not an x3p file at all"))
except x3pio.X3pError as error:
print("not read:", error)
Modifying¶
Changing the values, making files of parts of a file, converting it to another revision or storage type, and moving it in the global coordinate system.
"""Change a file, make files of parts of it, and convert it: python examples/modify.py."""
import numpy as np
import x3pio
from x3pio import DataType, Placement, Revision
heights = np.random.default_rng(1).normal(0.0, 1e-7, (64, 64)) + np.linspace(0.0, 2e-6, 64)
surface = x3pio.Surface.from_array(heights, x_scale=1e-6, y_scale=1e-6)
layer = surface.layers[0]
# The arrays of a layer belong to the file, so changing them changes it. Assigning fills the
# array: it needs the shape of the layer, or a single number.
layer.z -= np.polyval(np.polyfit(layer.x_values, layer.z.mean(axis=0), 1), layer.x_values)
layer.z[np.abs(layer.z) > 2.5e-7] = np.nan
print(int((~layer.valid).sum()), "of", layer.z.size, "points are not measured")
# The ``with_`` methods make a copy that shares the arrays read-only; ``copy`` makes one to write.
levelled = surface.with_metadata(comment="levelled")
editable = levelled.copy()
editable.layers[0].z[0, 0] = 0.0
# The result of an analysis often has another shape. ``with_layers`` keeps the metadata, the
# vendor extensions, the revision and the storage form of the file.
window = x3pio.SurfaceLayer.from_array(layer.z[16:48, 16:48], x_scale=1e-6, y_scale=1e-6)
cropped = levelled.with_layers([window])
print("crop:", cropped.layers[0].z.shape, cropped.metadata.comment)
# The layers of several files, in any order, make a new file.
stack = x3pio.Surface.from_layers([layer, editable.layers[0]], metadata=surface.metadata)
print("stack:", len(stack.layers), "layers")
# A file in another revision or with another storage type of the heights. The vendor extensions
# are converted to Amd 1:2020 but not to ISO 25178-72:2017; ``extensions_fit`` tells which.
surface.extensions.add("http://www.acme.example", "notes/sample.txt", b"Measured on the bench.\n")
print("extensions fit ISO 25178-72:2017:", surface.extensions_fit(Revision.ISO5436_2000))
older = surface.with_revision(Revision.ISO5436_2000, drop_extensions=True)
compact = older.with_z_type(DataType.INT16)
print(older.revision.name, compact.header.z.data_type.name, compact.header.z.increment)
# A file stores its coordinates in the view coordinate system, and a placement says where that lies
# in the global one. ``transformed`` moves the points relative to where they are now.
placement = Placement.translation(0.10, 0.02, 0.0) @ Placement.from_axis_angle([0, 0, 1], np.pi / 6)
placed = surface.with_placement(placement)
lifted = placed.transformed(Placement.translation(0.0, 0.0, 1e-3))
rise = lifted.layers[0].points()[0, 0, 2] - placed.layers[0].points()[0, 0, 2]
print(f"lifted by {rise * 1e3:.3g} mm")
# ``globalized`` stores the global coordinates and, once there is a rotation, an identity placement.
# ``centered`` moves the stored coordinates of absolute axes to the origin and the offset by as
# much; the points stay.
print("identity after globalized:", placed.globalized().placement.is_identity)
cloud = x3pio.PointCloud.from_points(np.random.default_rng(2).uniform(0.0, 1e-3, (100, 3)) + 100.0)
print(
"points unchanged by centered:",
bool(np.allclose(cloud.centered().layer.points(), cloud.layer.points())),
)
Plotting¶
"""Plot the data of files with matplotlib: python examples/plot.py [figure.png]."""
import sys
from pathlib import Path
import matplotlib.pyplot as plt
import x3pio
# The sample files of the repository, one of each kind; use the paths of your own files instead.
SAMPLES = Path(__file__).resolve().parent.parent / "tests" / "samples" / "iso25178-72-fdam1"
surface = x3pio.read(SAMPLES / "surface_binary.x3p")
irregular = x3pio.read(SAMPLES / "surface_irregular_binary.x3p")
profile = x3pio.read(SAMPLES / "profile_layers_binary.x3p")
cloud = x3pio.read(SAMPLES / "point_cloud_binary.x3p")
# Metres are scaled for the labels. Points that were not measured are NaN and stay blank.
figure = plt.figure(figsize=(14, 8), layout="constrained")
# ``extent`` gives the edges of the grid cells that image plots ask for. y grows with the row, so
# the origin is in the lower left corner.
layer = surface.layers[0]
left, right, bottom, top = (value * 1e3 for value in layer.extent)
axes = figure.add_subplot(2, 3, 1)
image = axes.imshow(layer.z * 1e6, origin="lower", extent=(left, right, bottom, top))
figure.colorbar(image, ax=axes, label="z / um")
axes.set(xlabel="x / mm", ylabel="y / mm", title="Surface: Height Map")
# The same surface in 3-D, from the points of the layer.
grid = layer.points() * [1e3, 1e3, 1e6]
axes = figure.add_subplot(2, 3, 2, projection="3d")
axes.plot_surface(grid[..., 0], grid[..., 1], grid[..., 2], rstride=2, cstride=2, cmap="viridis")
axes.set(xlabel="x / mm", ylabel="y / mm", title="Surface: 3-D View")
axes.set_zlabel("z / um", labelpad=8)
axes.set_box_aspect((4, 3, 1)) # the heights are much smaller than the lateral size
# A row of the surface is a profile along x.
axes = figure.add_subplot(2, 3, 3)
axes.plot(layer.x_values * 1e3, layer.z[75] * 1e6)
axes.set(xlabel="x / mm", ylabel="z / um", title="Surface: Row 75 as a Profile")
# An irregular surface stores the position of every point; ``points()`` has the shape of the grid,
# which ``pcolormesh`` takes.
points = irregular.layers[0].points() * [1e3, 1e3, 1e6]
axes = figure.add_subplot(2, 3, 4)
mesh = axes.pcolormesh(points[..., 0], points[..., 1], points[..., 2], shading="nearest")
figure.colorbar(mesh, ax=axes, label="z / um")
axes.set(xlabel="x / mm", ylabel="y / mm", title="Irregular Surface: Height Map")
# The layers of a profile share the positions along the line.
axes = figure.add_subplot(2, 3, 5)
for number, scan in enumerate(profile.layers, start=1):
axes.plot(scan.x_values * 1e3, scan.z * 1e6, label=f"Layer {number}")
axes.set(xlabel="x / mm", ylabel="z / um", title="Profile: Three Layers")
axes.legend()
# A point cloud is an (N, 3) array of x, y and z.
cloud_points = cloud.layer.points() * [1e3, 1e3, 1e6]
axes = figure.add_subplot(2, 3, 6, projection="3d")
axes.scatter(*cloud_points.T, c=cloud_points[:, 2], s=4)
axes.set(xlabel="x / mm", ylabel="y / mm", title="PointCloud: 3-D Points")
axes.set_zlabel("z / um", labelpad=8)
if len(sys.argv) > 1:
figure.savefig(sys.argv[1])
print("figure written:", sys.argv[1])
else:
plt.show()