Loading a Multi-Channel AFM Scan (.spm)

An atomic force microscope (AFM) [5] scans a sharp tip across a surface and can record several quantities at each point, such as height, stiffness (modulus), adhesion, and current. Each quantity is a channel on the same pixel grid.

This notebook loads a Bruker .spm file, inspects its channels, and turns one of them into feature maps.

JuSPICE Modules and Classes

  • juspice.io: SPICEData, load_data, save_data

  • SPICEData.preprocess: built-in preprocessing accessor

  • SPICEData.features: built-in feature-extraction accessor (see juspice.feature_extract.FeatureExtractionAccessor)

  • juspice.tracking: Tracker

[1]:
from juspice.tracking import Tracker

tracker = Tracker(include_metadata=True, notes='AFM (.spm) io basics')

tracker.recording_start()
/Users/amir/GIT_repositories/juspice_pre_release/venvs/.venv/lib/python3.12/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
  from .autonotebook import tqdm as notebook_tqdm
[2]:
# --------- Block 0: Setup ----------

# Standard library
from pathlib import Path
import sys
import os

# Third-party
import numpy as np
import matplotlib.pyplot as plt

# JuSPICE
from juspice.io import SPICEData, load_data, save_data

# Paths
try:
    notebook_dir = Path(__file__).resolve().parent
except Exception:
    notebook_dir = Path.cwd()

cur = notebook_dir
repo_root = None
for _ in range(6):
    if (cur / 'juspice').exists() or (cur / 'pyproject.toml').exists():
        repo_root = cur
        break
    if cur.parent == cur:
        break
    cur = cur.parent
if repo_root is None:
    repo_root = notebook_dir
if str(repo_root) not in sys.path:
    sys.path.insert(0, str(repo_root))

[3]:
tracker.recording_stop()

Load the scan

load_data() stacks all channels into one (height, width, n_channels) array; their names are in spice.metadata['channel_names'].

[4]:
tracker.recording_start()
[5]:
# --------- Block 1: Load ----------

spm_path = os.path.join(repo_root, 'Sample_data', 'afm', 'example_file.spm')
print(f'AFM file path: {spm_path}')

# `spice` is just a variable name for the SPICEData instance load_data()
# returns here — any name would work; we use `spice` throughout these
# notebooks as an intuitive nod to JuSPICE / SPICEData.
spice = load_data(spm_path)
print(f'data_type:      {spice.data_type}')
print(f'data.shape:     {spice.data.shape}')
print(f'channel_names:  {spice.metadata["channel_names"]}')

AFM file path: /Users/amir/GIT_repositories/juspice_pre_release/Sample_data/afm/example_file.spm
data_type:      em_spm
data.shape:     (256, 256, 8)
channel_names:  ['Height Sensor', 'Peak Force Error', 'DMTModulus', 'Indentation', 'Adhesion', 'Deformation', 'Contact Current', 'Peak Current']
[6]:
tracker.recording_stop()

Because all channels share one grid, the same position can be compared across them.

[7]:
tracker.recording_start()
[8]:
# --------- Block 2: Visualize all channels ----------

channel_names = spice.metadata['channel_names']
n_channels = len(channel_names)

n_cols = (n_channels + 1) // 2
fig, axes = plt.subplots(2, n_cols, figsize=(4 * n_cols, 8))
for idx, (ax, name) in enumerate(zip(axes.ravel(), channel_names)):
    im = ax.imshow(spice.data[:, :, idx], cmap='afmhot')
    ax.set_title(name, fontsize=9)
    ax.axis('off')
    plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
fig.tight_layout()
plt.show()
../../_images/notebooks_io_io_afm1_10_0.png
[9]:
tracker.recording_stop()

Level the height channel

A mounted sample is rarely perfectly flat, so raw heights include an overall tilt. Subtracting a best-fit plane removes it and reveals the surface relief. This AFM-specific step uses .correct_plane() from pySPM [54] on the original channel objects kept in spice.extra['channels'].

[10]:
tracker.recording_start()
[11]:
# --------- Block 3: Level the topography channel ----------

topography = spice.extra['channels']['Height Sensor']
topography_leveled = topography.correct_plane(inline=False)

fig, ax = plt.subplots(1, 2, figsize=(8, 4))
p0 = topography.show(ax=ax[0], cmap='afmhot', title='Height Sensor (raw)')
p1 = topography_leveled.show(
    ax=ax[1], cmap='afmhot', title='Height Sensor (plane-corrected)'
)
plt.colorbar(p0, ax=ax[0], label='nm', fraction=0.046, pad=0.04)
plt.colorbar(p1, ax=ax[1], label='nm', fraction=0.046, pad=0.04)
fig.tight_layout()
plt.show()
../../_images/notebooks_io_io_afm1_14_0.png
[12]:
tracker.recording_stop()

Use one channel with the standard tools

We copy the Height Sensor channel into its own SPICEData and normalize it to 0–1. Note that this copy comes from the raw channel, not the levelled image above.

[13]:
tracker.recording_start()
[14]:
# --------- Block 4: Derived single-channel SPICEData via spice.preprocess ----------

height_index = channel_names.index('Height Sensor')
height_spice = SPICEData(
    data=spice.data[:, :, height_index].copy(),
    metadata={**spice.metadata, 'channel_name': 'Height Sensor'},
    data_type=spice.data_type,
    source_path=spice.source_path,
    history=spice.history,
)

height_spice.preprocess.normalize()
print(f'Size of the normalized Height Sensor channel: {height_spice.data.shape}')
height_spice.preprocess.show_image(
    title='Height Sensor (normalized)', show_colorbar=True, show_histogram=True
)
Size of the normalized Height Sensor channel: (256, 256)
../../_images/notebooks_io_io_afm1_18_1.png
[15]:
tracker.recording_stop()

Extract features

We compute five feature maps: light smoothing (gaussian_sigma1), edge strength (sobel_magnitude), thin ridges (frangi_response, [24]), local texture (entropy_disk5), and directional order (tensor_coherence, [52]). The result is a new SPICEData with shape (H, W, 5).

[16]:
tracker.recording_start()
[17]:
# --------- Block 5: Feature extraction via spice.features ----------

feature_names = [
    'gaussian_sigma1',
    'sobel_magnitude',
    'frangi_response',
    'entropy_disk5',
    'tensor_coherence',
]
feature_spice = height_spice.features.extract(feature_names=feature_names)
print(f'feature_spice.data.shape: {feature_spice.data.shape}')
print(f'feature_names:            {feature_spice.metadata["feature_names"]}')

fig, axes = plt.subplots(1, len(feature_names), figsize=(4 * len(feature_names), 4))
for idx, (ax, name) in enumerate(zip(axes, feature_names)):
    im = ax.imshow(feature_spice.data[:, :, idx], cmap='viridis')
    ax.set_title(name, fontsize=9)
    ax.axis('off')
    plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
fig.tight_layout()
plt.show()

feature_spice.data.shape: (256, 256, 5)
feature_names:            ['gaussian_sigma1', 'sobel_magnitude', 'frangi_response', 'entropy_disk5', 'tensor_coherence']
../../_images/notebooks_io_io_afm1_22_1.png
[18]:
tracker.recording_stop()

Save and review

We save the feature stack with its metadata and history script.

[19]:
tracker.recording_start()
[20]:
# --------- Block 6: Save ----------
# This folder holds several notebooks, so save_data()'s automatic
# notebook-name detection would be ambiguous when run outside a live
# Jupyter session (e.g. via nbconvert) — pass output_stem explicitly
# to always land on this notebook's own files. See
# docs/repository_structure.rst.
stem = 'io_afm1'
save_data(
    feature_spice,
    output_stem=str(notebook_dir / stem),
)
print(f'Data:           {os.path.join(notebook_dir, stem + ".npy")}')
print(f'JSON sidecar:   {os.path.join(notebook_dir, stem + ".json")}')
print(f'History script: {os.path.join(notebook_dir, stem + "_history.py")}')

Data:           /Users/amir/GIT_repositories/juspice_pre_release/notebooks/io/io_afm1.npy
JSON sidecar:   /Users/amir/GIT_repositories/juspice_pre_release/notebooks/io/io_afm1.json
History script: /Users/amir/GIT_repositories/juspice_pre_release/notebooks/io/io_afm1_history.py
[21]:
tracker.recording_stop()

The history lists every step as runnable code.

[22]:
tracker.recording_start()
[23]:
# --------- Block 7: Inspect human-readable history ----------

readable_lines = feature_spice.history.to_lines()
print('Reconstructed pipeline for this SPICEData object:')
print('\n'.join(readable_lines))

Reconstructed pipeline for this SPICEData object:
import juspice
spice = juspice.io.load_data('/Users/amir/GIT_repositories/juspice_pre_release/Sample_data/afm/example_file.spm')
spice.preprocess.normalize()
feature_spice = spice.features.extract(feature_names=['gaussian_sigma1', 'sobel_magnitude', 'frangi_response', 'entropy_disk5', 'tensor_coherence'], n_features=5)
juspice.io.save_data(spice)
[24]:
tracker.recording_stop()