Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
d25abaf
Added jpk-qi-data loading functionality. Two possible channels: by tr…
ahobbs7 Feb 12, 2026
9d28dec
Adding get available channels function to each file format as well as…
ahobbs7 Feb 16, 2026
4bd6bb3
Making load jpk qi data function use zipfile instead of afmformats
ahobbs7 Feb 23, 2026
28f7633
Fixing scaling
ahobbs7 Feb 23, 2026
a67b11f
Updating general_loader and dependencies
ahobbs7 Feb 23, 2026
37e1ae2
Adjusting h5_jpk so it works for different shaped images
ahobbs7 Feb 23, 2026
16a827c
Making the jpk-qi-data processing save the h5 jpk file in the correct…
ahobbs7 Feb 23, 2026
c780de5
Add the ability to save the metadata to h5 from jpk qi. Additionally …
ahobbs7 Feb 25, 2026
a764ca6
Adding ability to load force curves from h5 file
ahobbs7 Feb 27, 2026
405a95d
Improving speed of loading qi curve data by loading all the curves at…
ahobbs7 Feb 27, 2026
3198567
Making loading jpk-qi-data return all the curves
ahobbs7 Feb 27, 2026
c25fb75
Adding .bin files support
ahobbs7 Mar 9, 2026
085b252
Adding returning of metadata
ahobbs7 Mar 9, 2026
06ec0de
Refactoring jpk-qi-data reader to use a loader class for greater modu…
ahobbs7 Mar 12, 2026
78c54a7
Starting to implement lazy loading
ahobbs7 Mar 16, 2026
3c327e9
Made force curves lazy loaded for h5-jpk and jpk-qi-data
ahobbs7 Mar 16, 2026
e776c66
Implementing caching of heavy data objects/ references to large open …
ahobbs7 Mar 18, 2026
3e0b8b1
Adjusting curve data access method to work more like a 2D array for m…
ahobbs7 Mar 23, 2026
b8ec43a
Separating saving functionality for jpk-qi-data and adding function t…
ahobbs7 Mar 25, 2026
10c1050
Fixing duplicated converting to nm bug
ahobbs7 Mar 26, 2026
b510141
Minor changes to fix double scaling on current channel as well
ahobbs7 Mar 26, 2026
afc65cc
Fixing minor error
ahobbs7 Mar 27, 2026
b1ec527
Added timing for testing and started converting to more memory and ti…
ahobbs7 Mar 30, 2026
1fd1eed
Making jpk-qi-data loading stream data into h5 file rather than savin…
ahobbs7 Mar 30, 2026
4e90188
Changing metdata data saving so 'changing keys' are assumed based on …
ahobbs7 Apr 1, 2026
66f388a
Pre-sizing the curve data to make loading faster (using a best guess)
ahobbs7 Apr 1, 2026
f286e57
Improving performance by removing javaproperties reliance in loop
ahobbs7 Apr 1, 2026
5391179
Making the saving to h5 save in sections using a buffer
ahobbs7 Apr 2, 2026
f0235ad
Removing redundant functions and fixing minor index bugs
ahobbs7 Apr 2, 2026
62481da
removing possibility of size 1 image stack
ahobbs7 Apr 3, 2026
588c725
adding comments and formatting
ahobbs7 Apr 3, 2026
4c7ea88
Ensuring that the h5-jpk copy of the jpk-qi-data doesn't get overwritten
ahobbs7 Apr 24, 2026
62637c2
Improving __iter__ function so loading of all curves for analysis is …
ahobbs7 Apr 24, 2026
b9104cc
Fixing errors with tests caused by logging problems
ahobbs7 Apr 24, 2026
0a1f459
Updating tests for jpk-qi-data and h5-jpk with curve data
ahobbs7 Apr 24, 2026
58a3a0f
Updating documentation
ahobbs7 Apr 24, 2026
c0a2924
Reformatting to match pre-commit conditions and make more robust
ahobbs7 May 2, 2026
e646205
Minor formatting changes on tests
ahobbs7 May 2, 2026
a6388fd
Indices spelling correction
ahobbs7 May 2, 2026
b88ee26
Skipping tests requiring large test files which cannot be added to repo
ahobbs7 May 3, 2026
be2840e
[pre-commit.ci] Fixing issues with pre-commit
pre-commit-ci[bot] May 3, 2026
a1764fe
Fixing pre-commit problems
ahobbs7 May 4, 2026
603d7bf
chore: removing print statements
ahobbs7 May 22, 2026
43640ac
tests: adding tests for get channel functions
ahobbs7 May 23, 2026
0146542
fix: stopped unnecessary redefining of nested row proxy classes
ahobbs7 May 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ __pycache__/
*.py[cod]
*$py.class

AFMReader/data/*
AFMReader/notebooks/*

# C extensions
*.so

Expand Down
32 changes: 32 additions & 0 deletions AFMReader/asd.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,38 @@ def load_asd(file_path: str | Path, channel: str):
return frames, pixel_to_nanometre_scaling_factor, header_dict


def get_asd_channels(file_path: Path):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have time to check this manually - does it work? There isn't a test but frankly we don't have the dev time to move slowly. If you say it works, this is fine with me.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The channel fetching seems to work though I'm happy to quickly make some tests for them as that should be pretty quick.

"""
Get the channels available in given .asd file.

Parameters
----------
file_path : Path
Path to the .asd file.

Returns
-------
list
List of channels available in the .asd file.
"""
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:
header_dict = read_header_file_version_0(open_file)

elif file_version == 1:
header_dict = read_header_file_version_1(open_file)

elif file_version == 2:
header_dict = read_header_file_version_2(open_file)
else:
raise ValueError(
f"File version {file_version} unknown. Please add support if you know how to decode this file version."
)
return [header_dict["channel1"], header_dict["channel2"]]


def read_file_version(open_file: BinaryIO) -> int:
"""
Read the file version from an open asd file. File versions are 0, 1 and 2.
Expand Down
97 changes: 89 additions & 8 deletions AFMReader/general_loader.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
"""Switchboard for input files."""

from pathlib import Path
from typing import Any

import numpy.typing as npt

from AFMReader import asd, gwy, h5_jpk, ibw, jpk, spm, stp, top, topostats
from AFMReader import asd, gwy, h5_jpk, ibw, jpk, raw_bin, spm, stp, top, topostats, jpk_qi
from AFMReader.logging import logger

logger.enable(__package__)


# pylint: disable=too-few-public-methods
# pylint: disable=too-few-public-methods,too-many-branches,too-many-statements,fixme
class LoadFile:
"""
Class to handle the general loading of an AFM file.
Expand All @@ -21,9 +22,11 @@ class LoadFile:
Path to the AFM image.
channel : str
Channel to extract from the AFM image.
kwargs : dict, optional
Additional keyword arguments to pass to the specific loaders.
"""

def __init__(self, filepath: str | Path, channel: str):
def __init__(self, filepath: str | Path, channel: str, kwargs: dict | None = None):
"""
Initialise the general LoadFile class with a filepath and channel.

Expand All @@ -33,39 +36,82 @@ def __init__(self, filepath: str | Path, channel: str):
Path to the AFM image.
channel : str
Channel to extract from the AFM image.
kwargs : dict, optional
Additional keyword arguments to pass to the specific loaders.
"""
self.filepath = Path(filepath)
self.channel = channel
self.suffix = self.filepath.suffix
self.loaded_curves = False
self.kwargs = kwargs if kwargs else {}

def load(self) -> tuple[npt.NDArray | str, float | None]: # noqa: C901
# Store heavy loaded data in a dict to avoid having to reload it
self.cached_data: dict[str, Any] = {}

def load( # noqa: C901
self, channel: str | None = None, kwargs: dict | None = None
) -> tuple[npt.NDArray | str, float | None] | tuple[npt.NDArray | str, float | None, Any]:
"""
Generally loads a file type that can be handled by AFMReader.

Parameters
----------
channel : str, optional
Overriding channel to extract from the AFM image.
kwargs : dict, optional
Additional keyword arguments to pass to the specific loaders.

Returns
-------
tuple
The image data (stack if ''.asd'' or ''.h5-jpk'') and the pixel to nanometre scaling ratio.
If curve data is found, also return the curve data (a large dict of all the curves).

Raises
------
ValueError
Where the channel is not found, returned as a tuple of "error message" and "None" so that this can be
propagated to Napari without outright failing.
"""
if channel:
self.channel = channel
if kwargs:
self.kwargs = kwargs
try:
if self.suffix == ".asd":
image, pixel_to_nanometre_scaling_factor, _ = asd.load_asd(self.filepath, self.channel)
elif self.suffix == ".gwy":
image, pixel_to_nanometre_scaling_factor = gwy.load_gwy(self.filepath, self.channel)
elif self.suffix == ".ibw":
image, pixel_to_nanometre_scaling_factor = ibw.load_ibw(self.filepath, self.channel)
elif self.suffix == ".jpk":
elif self.suffix in [".jpk", ".jpk-qi-image"]:
image, pixel_to_nanometre_scaling_factor = jpk.load_jpk(self.filepath, self.channel)
elif self.suffix == ".spm":
image, pixel_to_nanometre_scaling_factor = spm.load_spm(self.filepath, self.channel)
elif self.suffix == ".h5-jpk":
image, pixel_to_nanometre_scaling_factor, _ = h5_jpk.load_h5jpk(self.filepath, self.channel)
h5_returned = h5_jpk.load_h5jpk(self.filepath, self.channel, load_curves=not self.loaded_curves)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this entire block be absorbed into the h5_jpk.load_h5jpk function call? It's a little messy here.

I'll have a go too.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, is definitely a bit messy. My thinking was preventing the need to make changes to topostats by not changing what each load function returns if it isn't reading the curve data I'm adding support for. Though I think h5jpk is not used in TopoStats anyway? And it might be necessary to make changes to topostats reading anyway due to the changes I have been working on for returning the z unit for the read files?

if len(h5_returned) == 3:
image, pixel_to_nanometre_scaling_factor, _ = h5_returned # type: ignore[misc]
elif len(h5_returned) == 4:
image, pixel_to_nanometre_scaling_factor, _, curve_data = h5_returned # type: ignore[misc]
self.loaded_curves = True
return image, pixel_to_nanometre_scaling_factor, curve_data
else:
logger.error(f"Loading h5-jpk file returned unexpected number of items: {len(h5_returned)}")
elif self.suffix == ".jpk-qi-data":
if "jpk_qi_loader" not in self.cached_data:
self.cached_data["jpk_qi_loader"] = jpk_qi.jpk_qi_loader(
filepath=self.filepath, channel=self.channel, **self.kwargs
)
jpk_qi_returned = self.cached_data["jpk_qi_loader"].load(channel=self.channel, **self.kwargs)
if len(jpk_qi_returned) == 2:
image, pixel_to_nanometre_scaling_factor = jpk_qi_returned
elif len(jpk_qi_returned) == 3:
image, pixel_to_nanometre_scaling_factor, curve_data = jpk_qi_returned
self.loaded_curves = True
return image, pixel_to_nanometre_scaling_factor, curve_data
else:
logger.error(f"Loading h5-jpk file returned unexpected number of items: {len(jpk_qi_returned)}")
elif self.suffix == ".stp":
image, pixel_to_nanometre_scaling_factor = stp.load_stp(self.filepath)
elif self.suffix == ".top":
Expand All @@ -82,13 +128,48 @@ def load(self) -> tuple[npt.NDArray | str, float | None]: # noqa: C901
f"'{self.channel}' not in available image keys: "
f"{[im for im in image_keys if im in topostats_keys]}"
) from exc
elif self.suffix == ".bin":
image, pixel_to_nanometre_scaling_factor = raw_bin.load_bin(self.filepath, **self.kwargs)
else:
raise ValueError(f"File type '{self.suffix}' is not currently handled by AFMReader.")

return image, pixel_to_nanometre_scaling_factor

except ValueError as e:
logger.error(f"{e}")
return (e, None) # cheeky return of an image, px2nm-like tuple object to propagate error message to Napari
raise e

# scope for a "check what channels are available" function similar to above.
def get_available_channels(self): # noqa: C901
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Guessing this is for the napari feature to list the channels?

Well done if this is all working, that's a lot of work.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes!

"""
Get the available channels for the file type.

Returns
-------
list
List of available channels.
"""
if self.suffix == ".asd":
available_channels = asd.get_asd_channels(self.filepath)
elif self.suffix == ".gwy":
available_channels = gwy.get_gwy_channels(self.filepath)
elif self.suffix == ".ibw":
available_channels = ibw.get_ibw_channels(self.filepath)
elif self.suffix in [".jpk", ".jpk-qi-image"]:
available_channels = jpk.get_jpk_channels(self.filepath)
elif self.suffix == ".spm":
available_channels = spm.get_spm_channels(self.filepath)
elif self.suffix == ".h5-jpk":
available_channels = h5_jpk.get_h5jpk_channels(self.filepath)
elif self.suffix == ".jpk-qi-data":
if "jpk_qi_loader" not in self.cached_data:
self.cached_data["jpk_qi_loader"] = jpk_qi.jpk_qi_loader(filepath=self.filepath, **self.kwargs)
available_channels = self.cached_data["jpk_qi_loader"].get_available_channels()
elif self.suffix == ".topostats":
available_channels = ["image", "image_original"]
elif self.suffix == ".bin":
available_channels = raw_bin.get_bin_channels()
elif self.suffix in [".stp", ".top"]:
return []
else:
raise ValueError(f"File type '{self.suffix}' is not currently handled by AFMReader.")
return available_channels
26 changes: 26 additions & 0 deletions AFMReader/gwy.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,32 @@
from AFMReader.io import read_char, read_double, read_null_terminated_string, read_uint32


def get_gwy_channels(file_path):
"""
Extract a list of available channels and their corresponding dictionary key ids from the `.gwy` file.

Parameters
----------
file_path : Path or str
Path to the .gwy file.

Returns
-------
list
List of available channels.
"""
image_data_dict: dict[Any, Any] = {}
with Path.open(file_path, "rb") as open_file: # pylint: disable=unspecified-encoding
# Read header
header = open_file.read(4)
logger.debug(f"Gwy file header: {header.decode}")

gwy_read_object(open_file, data_dict=image_data_dict)
channel_ids = gwy_get_channels(gwy_file_structure=image_data_dict)

return list(channel_ids)


def load_gwy(file_path: Path | str, channel: str) -> tuple[np.ndarray[Any, np.float64], float]:
"""
Extract image and pixel to nm scaling from the .gwy file.
Expand Down
Loading
Loading