Examples¶
The scripts are in the examples/ folder. Short examples of the common use cases are also at the
top of the API reference.
Writing¶
Surfaces and profiles, with metadata and a trailer, in different revisions, data types and representations.
"""Write surfaces and profiles: python examples/write.py [folder]."""
import sys
import tempfile
from datetime import UTC, datetime
from pathlib import Path
import numpy as np
import sdfio
folder = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(tempfile.mkdtemp())
folder.mkdir(parents=True, exist_ok=True)
# Heights and intervals are in metres; NaN marks a point that was not measured.
surface = 1e-6 * np.random.default_rng(0).normal(size=(480, 640))
surface[100:110, 200:210] = np.nan
# A profile is a surface with one row; its y interval is meaningless but still needs a value.
x = np.arange(500) * 2e-6
profile = 1e-6 * np.sin(2.0 * np.pi * x / 100e-6)
# Defaults: ISO-2.0, binary, 64-bit floats.
sdfio.write(folder / "surface.sdf", surface, x_scale=1e-6, y_scale=1e-6)
sdfio.write(folder / "profile.sdf", profile.reshape(1, -1), x_scale=2e-6, y_scale=2e-6)
# The metadata is a template for the header. Dates are UTC. ISO-2.0 wants the trailer as
# Name = Value fields; the older revisions take any 7-bit text.
metadata = sdfio.Metadata(
revision=sdfio.Revision.ISO_2_0,
manufacturer_id="Acme",
create_date=datetime(2026, 3, 1, 9, 30, tzinfo=UTC),
z_resolution=1e-9,
data_type=sdfio.DataType.INT16,
)
trailer = sdfio.format_tagged_fields({"OperatorName": "Jane Doe", "PartName": "Gauge 7"})
# Integer types store multiples of z_scale. The default, "auto", is the smallest that fits the data.
sdfio.write(
folder / "surface_int16.sdf",
surface,
x_scale=1e-6,
y_scale=1e-6,
metadata=metadata,
trailer=trailer,
)
# Other revisions, data types and representations; each revision allows only some data types.
variants = [
("surface_iso1_ascii", sdfio.Revision.ISO_1_0, sdfio.DataType.INT32, sdfio.FileFormat.ASCII),
("surface_bcr_int16", sdfio.Revision.BCR_1_0, sdfio.DataType.INT16, sdfio.FileFormat.BINARY),
("surface_float32", sdfio.Revision.ISO_2_0, sdfio.DataType.BINARY32, sdfio.FileFormat.BINARY),
]
for name, revision, data_type, file_format in variants:
sdfio.write(
folder / f"{name}.sdf",
surface,
x_scale=1e-6,
y_scale=1e-6,
format=file_format,
metadata=sdfio.Metadata(revision=revision, data_type=data_type),
trailer=trailer,
)
print("written to", folder)
for path in sorted(folder.glob("*.sdf")):
print(f"{path.name:22s}{path.stat().st_size:>10} bytes")
Reading¶
The heights, the points, the header and the trailer of a file, from a path, a stream or bytes.
"""Read a file and look at its heights, header and trailer: python examples/read.py."""
import io
from pathlib import Path
import sdfio
# The sample files of the repository; use the paths of your own files instead.
SAMPLES = Path(__file__).resolve().parent.parent / "tests" / "samples" / "iso25178-71-2026"
path = SAMPLES / "iso2_surface_binary.sdf"
sdf = sdfio.read(path)
print(
sdf.header.revision, sdf.header.format.name.lower(), sdf.header.data_type.name, sdf.data.shape
)
# Heights and axes are in metres; points that were not measured are NaN.
print("x runs to", sdf.x_axis[-1], "m, y to", sdf.y_axis[-1], "m")
# Joins the axes and the heights; without the missing points it is an (N, 3) point cloud.
points = sdf.points(drop_invalid=True)
print("measured points:", len(points), "of", sdf.data.size)
header = sdf.header
print(header.manufacturer_id, header.create_date, "z scale:", header.z_scale)
# ``trailer`` is the raw text; ``trailer_fields`` parses Name = Value lines, as ISO-2.0 requires.
print("trailer:", dict(sdf.trailer_fields))
profile = sdfio.read(SAMPLES / "iso2_profile_binary.sdf")
print("profile:", profile.is_profile, profile.data.shape)
# Streams and bytes work as well as paths.
with open(path, "rb") as stream:
from_stream = sdfio.read(stream)
from_bytes = sdfio.SdfFile.loads(path.read_bytes())
print("same data from a stream and from bytes:", from_stream == from_bytes == sdf)
# All errors of sdfio are SdfError.
try:
sdfio.read(io.BytesIO(b"not an SDF file at all"))
except sdfio.SdfError as error:
print("not read:", error)
Modifying¶
Changing the values, making files of parts of a file, and converting it to another revision, data type or representation.
"""Change a file, make files of parts of it, and convert it: python examples/modify.py."""
import io
import numpy as np
import sdfio
# ``SdfFile.from_array`` takes the arguments of ``write`` and gives the file without writing it.
heights = 1e-6 * (np.random.default_rng(5).normal(size=(200, 300)) + 5.0)
sdf = sdfio.SdfFile.from_array(heights, x_scale=1e-6, y_scale=1e-6)
# The data is the file's own NumPy array.
sdf.data -= sdf.data.mean()
# The part keeps the header and trailer, and shares the heights with sdf.
part = sdf.with_data(sdf.data[50:150, 100:250])
# Every second point, with the matching scale.
coarse = sdf.with_data(sdf.data[:, ::2], x_scale=2 * sdf.header.x_scale)
# A slice keeps the row axis, so a row is a profile.
row = sdf.with_data(sdf.data[40:41])
print("crop:", part.data.shape, "coarse:", coarse.data.shape, "row is a profile:", row.is_profile)
# A z scale that does not fit the new type is kept by default and the file can't be written;
# ``refit_z_scale`` keeps it when the data fits and otherwise takes the smallest that fits.
older = coarse.with_revision(
sdfio.Revision.ISO_1_0, data_type=sdfio.DataType.INT32, refit_z_scale=True
)
print("converted to", older.header.revision, older.header.data_type.name)
for file_format in sdfio.FileFormat:
buffer = io.BytesIO()
older.save(buffer, format=file_format)
print(f"{file_format.name.lower():>6}: {len(buffer.getvalue())} bytes")
# A data type that only ISO-2.0 has is refused by ISO-1.0.
try:
coarse.with_revision(sdfio.Revision.ISO_1_0, data_type=sdfio.DataType.INT8)
except sdfio.SdfFormatError as error:
print("refused:", error)
Plotting¶
"""Plot the data of SDF files with matplotlib: python examples/plot.py [figure.png]."""
import sys
from pathlib import Path
import matplotlib.pyplot as plt
import sdfio
# The sample files of the repository; use the path of your own files instead.
SAMPLES = Path(__file__).resolve().parent.parent / "tests" / "samples" / "iso25178-71-2026"
surface = sdfio.read(SAMPLES / "iso2_surface_binary.sdf")
profile = sdfio.read(SAMPLES / "iso2_profile_binary.sdf")
# Shape (rows, columns, 3): x, y and the height of each point.
points = surface.points()
# Metres are scaled to micrometres for the labels; NaN stays blank.
figure = plt.figure(figsize=(14, 4.5), layout="constrained")
# y grows with the row, so the origin is in the lower left corner.
x, y = surface.x_axis * 1e6, surface.y_axis * 1e6
step_x, step_y = surface.header.x_scale * 1e6, surface.header.y_scale * 1e6
extent = (x[0] - step_x / 2, x[-1] + step_x / 2, y[0] - step_y / 2, y[-1] + step_y / 2)
axes = figure.add_subplot(1, 3, 1)
image = axes.imshow(surface.data * 1e6, origin="lower", extent=extent)
figure.colorbar(image, ax=axes, label="z / um")
axes.set(xlabel="x / um", ylabel="y / um", title="Surface: Height Map")
# The missing region is a hole.
scaled = points * 1e6
axes = figure.add_subplot(1, 3, 2, projection="3d")
axes.plot_surface(
scaled[..., 0], scaled[..., 1], scaled[..., 2], rstride=1, cstride=1, cmap="viridis"
)
axes.set(xlabel="x / um", ylabel="y / um", title="Surface: 3-D View")
axes.set_zlabel("z / um", labelpad=8)
axes.set_box_aspect((3, 2, 1)) # the heights are smaller than the lateral size
# Missing points break the line.
axes = figure.add_subplot(1, 3, 3)
axes.plot(profile.x_axis * 1e6, profile.data[0] * 1e6)
axes.set(xlabel="x / um", ylabel="z / um", title="Profile")
if len(sys.argv) > 1:
figure.savefig(sys.argv[1])
print("figure written:", sys.argv[1])
else:
plt.show()