diff --git a/README.md b/README.md index 785d3b5..0841548 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,10 @@ for use with [TopoStats](https://github.com/AFM-SPM/TopoStats). Supported file formats -| File format | Description | -|-------------|------------------| -| `.asd` | High-speed AFM | +| File format | Description | +|-------------|----------------| +| `.asd` | High-speed AFM | +| `.ibw` | [WaveMetrics](https://www.wavemetrics.com/) | | `.spm` | [Bruker's Format](https://www.bruker.com/) | | `.jpk` | [Bruker](https://www.bruker.com/) | @@ -30,7 +31,6 @@ awaiting refactoring to move their functionality into topofileformats these are | File format | Description | Status | |-------------|---------------------------------------------------------|--------------------------------------------| -| `.ibw` | [WaveMetrics](https://www.wavemetrics.com/) | TopoStats supported, to be migrated (#17). | | `.gwy` | [Gwyddion](http://gwyddion.net/) | TopoStats supported, to be migrated (#1). | | `.nhf` | [Nanosurf](https://www.nanosurf.com/en/) | To Be Implemented. | | `.aris` | [Imaris Oxford Instruments](https://imaris.oxinst.com/) | To Be Implemented. | @@ -69,6 +69,18 @@ from topofileformats.asd import load_asd frames, pixel_to_nanometre_scaling_factor, metadata = load_asd(file_path="./my_asd_file.asd", channel="TP") ``` +### .ibw + +You can open `.ibw` files using the `load_ibw` function. Just pass in the path to the file +and the channel name that you want to use. (If in doubt, use `HeightTracee` (yes, with the +extra 'e'), `ZSensorTrace`, or `ZSensor`). + +```python +from topofileformats.ibw import load_ibw + +image, pixel_to_nanometre_scaling_factor = load_ibw(file_path="./my_ibw_file.ibw", channel="HeightTracee") +``` + ### .jpk You can open `.jpk` files using the `load_jpk` function. Just pass in the path diff --git a/examples/example_01.ipynb b/examples/example_01.ipynb index 50f40bd..bb6cef7 100644 --- a/examples/example_01.ipynb +++ b/examples/example_01.ipynb @@ -129,6 +129,88 @@ "image, pixel_to_nm_scaling = load_jpk(file_path=FILE, channel=\"height_trac\")" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Plot the image\n", + "import matplotlib.pyplot as plt\n", + "\n", + "plt.imshow(image, cmap=\"afmhot\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# IBW Files" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Import the load_ibw function from topofileformats\n", + "from topofileformats.ibw import load_ibw" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Load the IBW file as an image and pixel to nm scaling factor\n", + "FILE = \"../tests/resources/sample_0.ibw\"\n", + "image, pixel_to_nm_scaling = load_ibw(file_path=FILE, channel=\"HeightTracee\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Plot the image\n", + "import matplotlib.pyplot as plt\n", + "\n", + "plt.imshow(image, cmap=\"afmhot\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# IBW Files" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Import the load_ibw function from topofileformats\n", + "from topofileformats.ibw import load_ibw" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Load the IBW file as an image and pixel to nm scaling factor\n", + "FILE = \"../tests/resources/sample_0.ibw\"\n", + "image, pixel_to_nm_scaling = load_ibw(file_path=FILE, channel=\"HeightTracee\")" + ] + }, { "cell_type": "code", "execution_count": null, diff --git a/pyproject.toml b/pyproject.toml index 9e36cf6..d696f16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,9 +31,9 @@ keywords = [ dependencies = [ "matplotlib", + "igor2", "tifffile", "pySPM", - "pySPM", "loguru", ] diff --git a/tests/resources/sample_0.ibw b/tests/resources/sample_0.ibw new file mode 100644 index 0000000..a002694 Binary files /dev/null and b/tests/resources/sample_0.ibw differ diff --git a/tests/test_ibw.py b/tests/test_ibw.py new file mode 100644 index 0000000..f80afc9 --- /dev/null +++ b/tests/test_ibw.py @@ -0,0 +1,37 @@ +"""Test the loading of ibw files.""" + +from pathlib import Path +import pytest + +import numpy as np + +from topofileformats.ibw import load_ibw + +BASE_DIR = Path.cwd() +RESOURCES = BASE_DIR / "tests" / "resources" + + +@pytest.mark.parametrize( + ("file_name", "channel", "pixel_to_nm_scaling", "image_shape", "image_dtype", "image_sum"), + [pytest.param("sample_0.ibw", "HeightTracee", 1.5625, (512, 512), "f4", -218091520.0, id="test image 0")], +) +def test_load_ibw( + file_name: str, + channel: str, + pixel_to_nm_scaling: float, + image_shape: tuple[int, int], + image_dtype: type, + image_sum: float, +) -> None: + """Test the normal operation of loading an .ibw file.""" + result_image = np.ndarray + result_pixel_to_nm_scaling = float + + file_path = RESOURCES / file_name + result_image, result_pixel_to_nm_scaling = load_ibw(file_path, channel) + + assert result_pixel_to_nm_scaling == pixel_to_nm_scaling + assert isinstance(result_image, np.ndarray) + assert result_image.shape == image_shape + assert result_image.dtype == image_dtype + assert result_image.sum() == image_sum diff --git a/topofileformats/asd.py b/topofileformats/asd.py index 6bd2edb..36c0da7 100644 --- a/topofileformats/asd.py +++ b/topofileformats/asd.py @@ -173,7 +173,7 @@ def load_asd(file_path: Path, channel: str): # Ensure the file path is a Path object file_path = Path(file_path) # Open the file in binary mode - with Path.open(file_path, "rb", encoding=None) as open_file: # pylint: disable=W1514 + with Path.open(file_path, "rb", encoding=None) as open_file: # pylint: disable=unspecified-encoding file_version = read_file_version(open_file) if file_version == 0: diff --git a/topofileformats/ibw.py b/topofileformats/ibw.py new file mode 100644 index 0000000..8fceed9 --- /dev/null +++ b/topofileformats/ibw.py @@ -0,0 +1,96 @@ +"""For decoding and loading .ibw AFM file format into Python Numpy arrays.""" + +from __future__ import annotations +from pathlib import Path + +import numpy as np +from igor2 import binarywave + +from topofileformats.logging import logger + +logger.enable(__package__) + + +def _ibw_pixel_to_nm_scaling(scan: dict) -> float: + """ + Extract pixel to nm scaling from the IBW image metadata. + + Parameters + ---------- + scan : dict + The loaded binary wave object. + + Returns + ------- + float + A value corresponding to the real length of a single pixel. + """ + # Get metadata + notes = {} + for line in str(scan["wave"]["note"]).split("\\r"): + if line.count(":"): + key, val = line.split(":", 1) + notes[key] = val.strip() + # Has potential for non-square pixels but not yet implemented + return ( + float(notes["SlowScanSize"]) / scan["wave"]["wData"].shape[0] * 1e9, # as in m + float(notes["FastScanSize"]) / scan["wave"]["wData"].shape[1] * 1e9, # as in m + )[0] + + +def load_ibw(file_path: Path | str, channel: str) -> tuple[np.ndarray, float]: + """ + Load image from Asylum Research (Igor) .ibw files. + + Parameters + ---------- + file_path: Path | str + Path to the .ibw file. + channel: str + The channel to extract from the .ibw file. + + Returns + ------- + tuple[np.ndarray, float] + A tuple containing the image and its pixel to nanometre scaling value. + + Raises + ------ + FileNotFoundError + If the file is not found. + ValueError + If the channel is not found in the .ibw file. + + Examples + -------- + Load the image and pixel to nanometre scaling factor - 'HeightTracee' is the default channel name (the extra 'e' is + not a typo!). + + >>> from topofileformats.ibw import load_ibw + >>> image, pixel_to_nanometre_scaling_factor = load_ibw(file_path="./my_ibw_file.ibw", channel="HeightTracee") + """ + logger.info(f"Loading image from : {file_path}") + file_path = Path(file_path) + filename = file_path.stem + try: + scan = binarywave.load(file_path) + logger.info(f"[{filename}] : Loaded image from : {file_path}") + labels = [] + for label_list in scan["wave"]["labels"]: + for label in label_list: + if label: + labels.append(label.decode()) + channel_idx = labels.index(channel) + image = scan["wave"]["wData"][:, :, channel_idx].T * 1e9 # Looks to be in m + image = np.flipud(image) + logger.info(f"[{filename}] : Extracted channel {channel}") + except FileNotFoundError: + logger.error(f"[{filename}] File not found : {file_path}") + except ValueError: + logger.error(f"[{filename}] : {channel} not in {file_path.suffix} channel list: {labels}") + raise + except Exception as e: + logger.error(f"[{filename}] : {e}") + raise e + + return (image, _ibw_pixel_to_nm_scaling(scan))