Skip to content

Commit aa595b8

Browse files
authored
feat!: MATLAB‑aligned masks & auto routines; require Python 3.11+
## Summary This merge brings the Python NanoLocz library into algorithmic alignment with the MATLAB reference, with a strong focus on mask semantics, numerical parity, and automated routines. It clarifies contracts across modules, reduces hidden numerical drift, and makes validation and future extension easier. ### Key changes 1) MATLAB‑aligned mask semantics (across all leveling/thresholding) All thresholding methods now return **boolean *exclusion* masks** `(True = excluded / masked, False = valid)`. Leveling functions convert public exclusion masks to internal validity via a shared helper and use **NaN‑outside semantics** for fitting (excluded pixels are ignored in fits but preserved in outputs). Mask contract and behaviour are documented in `level.py`, `level_weighted.py`, `level_auto.py`, and `thresholder.py`. 2) Numerical parity in core levelling (`level.py`) Polynomial fits use 1‑based indices and population standard deviation (ddof=0) to mirror MATLAB polyfit(..., mu). Reworked methods: `plane`, `line`, `med_line`, `med_line_y`, `smed_line`, `mean_plane`. Fit only over valid pixels; explicit low‑sample fallbacks; preserve excluded pixels. Document MATLAB‑specific gates (e.g., column stage in line applies only when `polyy > 0`). `get_background(...)` guarantees background = input − levelled under the same semantics. 3) Automated routines completed & aligned (`level_auto.py`) `multi-plane-edges` and `multi-plane-otsu` fully implemented (replacing placeholders) and missing plane→histogram passes restored. Adds anisotropy‑gated med_line preconditioning (post plane(1,1) triggers) to match MATLAB’s adaptive behaviour. Implements MATLAB‑style Gaussian histogram fitting (gauss1) for adaptive threshold bounds (`gauss_fi`t, `gauss_peaks`, `gauss_holes`), with a documented frame‑wise vs stack‑wise difference. 4) API clean-up & consistency New public entry point: `apply_thresholder(...)` replaces the ambiguous `thresholder()` within the `thresholder` module. Internal callers updated. Consistent function contracts across `level`, `level_weighted`, `thresholder`, and `level_auto`. `thresholder.py` now returns boolean masks (no NaN masks) and clarifies/expands methods: `selection`, `histogram`, `otsu`, `auto edges`, `hist edges`, `otsu edges`, `hist skel`, `otsu skel`, `line_step`, `adaptive`. 5) Region‑weighted leveling improvements (`level_weighted.py`) Regions derived from validity masks using 8‑connectivity with `min area = floor(1% of H×W)` (MATLAB rule). Weighted aggregation uses a 2% weight threshold (weights ≤2% zeroed; not renormalized). Evaluation uses 1‑based coordinates; detailed docstrings describe behavior and edge cases. 6) Documentation & examples Adds `CITATION.cff` for software citation. README updates: requirements (Python 3.11+), quickstart with apply_thresholder and apply_level_weighted, routine summaries, and citations/links. Tutorial notebook refreshed to demonstrate the new API and pipeline narrative. 7) Build, tooling & CI **Minimum Python is now 3.11**; CI matrix tests 3.11/3.12/3.13; classifiers and tool targets updated (Black/Ruff/Mypy → py311). Dependency pin: scikit-image >= 0.26, < 0.27. Pre‑commit: --show-diff-on-failure, refined excludes (docs, notebooks, tests/resources, etc.). 8) Tests & resources Adds tests/conftest.py fixture for loading NPZ test data and AFM resource file(s) under tests/resources/. **Breaking changes** - **Python version** Minimum supported Python is 3.11 (3.10 dropped). - Thresholding & mask polarity Use `apply_thresholder(...)`. It returns a boolean exclusion mask `(True = excluded)`. If you previously expected NaN masks or “True = valid” logic, invert with ~mask where a validity mask is needed. - Leveling inputs Public leveling methods now expect exclusion masks. If you were passing validity masks, invert them first. - Behavioral alignment tweaks `level_line`: column stage runs only when polyy > 0. `med_line`: interprets polyx as a gain on the row‑median when polyx > 0 (else 1.0). Region‑weighted methods implement MATLAB’s min‑area and weight thresholds.
2 parents 1658c90 + 43109f7 commit aa595b8

23 files changed

Lines changed: 87687 additions & 1488 deletions

.github/workflows/pre-commit.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,4 @@ jobs:
2424
pip install pre-commit
2525
2626
- name: Run pre-commit hooks
27-
run: pre-commit run --all-files
27+
run: pre-commit run --all-files --show-diff-on-failure

.github/workflows/tests.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
fail-fast: false
1515
matrix:
1616
os: [ubuntu-latest, macos-latest, windows-latest]
17-
python-version: ["3.10", "3.11", "3.12"]
17+
python-version: ["3.11", "3.12", "3.13"]
1818

1919
steps:
2020
- name: Checkout repository

.pre-commit-config.yaml

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ repos:
44
rev: v4.5.0
55
hooks:
66
- id: trailing-whitespace
7-
exclude: ^(docs/|notebooks/|LICENSE)$
7+
exclude: (^docs/|^tests/resources/|^notebooks/|^LICENSE$)
88
- id: end-of-file-fixer
9-
exclude: ^(docs/|notebooks/|LICENSE)$
9+
exclude: (^docs/|^tests/resources/|^notebooks/|^LICENSE$)
1010
- id: check-yaml
11-
exclude: ^(docs/|notebooks/|LICENSE)$
11+
exclude: (^docs/|^notebooks/|^LICENSE$)
1212
- id: check-added-large-files
1313
args: ["--maxkb=500"]
14-
exclude: ^(docs/|notebooks/|LICENSE)$
14+
exclude: (^docs/|^tests/resources/|^notebooks/|^LICENSE$)
1515
- id: check-merge-conflict
1616
- id: check-toml
1717

@@ -22,23 +22,23 @@ repos:
2222
- id: isort
2323
args: ["--profile", "black"]
2424
language_version: python3
25-
exclude: ^(docs/|notebooks/|LICENSE)$
25+
exclude: (^docs/|^notebooks/|^LICENSE$)
2626

2727
# --- Ruff Lint & Format ---
2828
- repo: https://github.com/astral-sh/ruff-pre-commit
2929
rev: v0.3.7
3030
hooks:
3131
- id: ruff
3232
args: ["--fix"]
33-
exclude: ^(docs/|notebooks/|LICENSE)$
33+
exclude: (^docs/|^notebooks/|^LICENSE$)
3434

3535
# --- Black Code Formatter ---
3636
- repo: https://github.com/psf/black
3737
rev: 23.7.0
3838
hooks:
3939
- id: black
4040
language_version: python3
41-
exclude: ^(docs/|notebooks/|LICENSE)$
41+
exclude: (^docs/|^notebooks/|^LICENSE$)
4242

4343
# --- Mypy (type checking) ---
4444
- repo: https://github.com/pre-commit/mirrors-mypy
@@ -59,7 +59,7 @@ repos:
5959
hooks:
6060
- id: markdownlint-cli2
6161
args: ['--fix','--config','.markdownlint.yaml']
62-
exclude: ^(docs/|notebooks/|LICENSE)$
62+
exclude: (^docs/|^notebooks/|^LICENSE$)
6363

6464
# --- Remove Notebook Metadata ---
6565
- repo: https://github.com/kynan/nbstripout
@@ -74,4 +74,4 @@ repos:
7474
hooks:
7575
- id: codespell
7676
types: [text]
77-
exclude: ^(docs/_build/|build/|dist/|notebooks/|LICENSE)$
77+
exclude: (^docs/_build/|^tests/resources/|^build/|^dist/|^notebooks/|^LICENSE$)

CITATION.cff

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# This CITATION.cff file was generated with cffinit.
2+
# Visit https://bit.ly/cffinit to generate yours today!
3+
4+
cff-version: 1.2.0
5+
title: Python-Nanolocz-Library
6+
message: >-
7+
If you use this software, please cite it using the
8+
metadata from this file.
9+
type: software
10+
authors:
11+
- given-names: Daniel E
12+
family-names: Rollins
13+
email: d.e.rollins@leed.ac.uk
14+
affiliation: University of Leeds
15+
- given-names: George R
16+
family-names: Heath
17+
affiliation: University of Leeds
18+
email: G.R.Heath@leeds.ac.uk
19+
orcid: 'https://orcid.org/0000-0001-6431-2191'
20+
repository-code: 'https://github.com/derollins/Python-Nanolocz-Library'
21+
abstract: >-
22+
A Python implementation of the NanoLocz library
23+
(https://github.com/George-R-Heath/NanoLocz-Matlab-Library).
24+
keywords:
25+
- AFM
26+
- Atomic Force MIcroscopy
27+
- High Speed AFM
28+
- Levelling
29+
- image processing
30+
- image analysis
31+
license: GPL-3.0

README.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ pip install .
3939

4040
### Requirements
4141

42-
This library requires **Python 3.10 or newer** and uses modern scientific Python packages to replace MATLAB functionality
42+
This library requires **Python 3.11 or newer** and uses modern scientific Python packages to replace MATLAB functionality
4343
from the original NanoLocz platform:
4444

4545
- **NumPy** – Core numerical operations and array handling (replaces MATLAB’s matrix operations).
@@ -61,7 +61,7 @@ easily extensible for AFM workflows.
6161
import numpy as np
6262
from pnanolocz_lib.level import apply_level
6363
from pnanolocz_lib.level_auto import apply_level_auto
64-
from pnanolocz_lib.thresholder import thresholder
64+
from pnanolocz_lib.thresholder import apply_thresholder
6565
from pnanolocz_lib.level_weighted import apply_weighted_level
6666

6767
# 1) Polynomial plane leveling
@@ -78,7 +78,7 @@ stack = np.load("stack.npy") # (N,H,W)
7878
out = apply_level_auto(stack, routine="multi-plane-otsu")
7979

8080
# 4) Otsu mask
81-
otsu_mask = thresholder(img, method="otsu", limits=None)
81+
mask = apply_thresholder(img, method="otsu", limits=None)
8282
```
8383

8484
---
@@ -124,7 +124,11 @@ otsu_mask = thresholder(img, method="otsu", limits=None)
124124
- **`pnanolocz_lib.thresholder`**
125125
Intensity / edge detection: histogram, Otsu, auto edges, skeleton, step detection.
126126

127-
Available thresholds:
127+
Typical usage involves calling the `apply_thresholder()` function with an image (2D)
128+
or image stack (3D) and specifying the desired method and polynomial orders.
129+
(see Quickstart above for an example)
130+
131+
Available thresholder functions:
128132

129133
| Method | Description |
130134
|--------------|-------------|
@@ -168,8 +172,13 @@ otsu_mask = thresholder(img, method="otsu", limits=None)
168172
## 📝 Citation
169173

170174
If you use this library, please cite:
171-
Heath, G.R. et al. *NanoLocz: Image analysis platform for AFM, high‑speed AFM and localization AFM.*
172-
Small Methods 2024, 2301766. <https://doi.org/10.1002/smtd.202301766>
175+
> Heath, G.R. et al. *NanoLocz: Image analysis platform for AFM, high‑speed AFM and localization AFM.*
176+
> Small Methods 2024, 2301766. <https://doi.org/10.1002/smtd.202301766>
177+
178+
and
179+
180+
> Rollins, D. E., & Heath, G. R. (2025). *Python-NanoLocz-Library: A Python implementation of the NanoLocz AFM leveling and
181+
> analysis tools*. University of Leeds. <https://github.com/derollins/Python-Nanolocz-Library>
173182
174183
---
175184

notebooks/Test pnanolocz-lib.ipynb

Lines changed: 28 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
"outputs": [],
1717
"source": [
1818
"data_path = r\"C:/Users/ggjh246/OneDrive - University of Leeds/Code/playNano_testdata/save-2025.05.20-12.57.06.187.h5-jpk\"\n",
19+
"# Un- comment the following line if you do not have playnano installed in your enviroment. \n",
20+
"#%pip install numpy pandas matplotlib playnano\n",
1921
"from playnano.io.loader import load_afm_stack\n",
2022
"afm_stack = load_afm_stack(data_path, channel = \"height_trace\")"
2123
]
@@ -40,24 +42,18 @@
4042
"metadata": {},
4143
"outputs": [],
4244
"source": [
43-
"from pnanolocz_lib import level_auto, level, level_weighted\n",
45+
"from pnanolocz_lib import level_auto, level, level_weighted, thresholder\n",
4446
"\n",
4547
"frames = afm_stack.n_frames\n",
46-
"frame_ind = range(0, frames)\n",
47-
"\n",
48-
"plane_levelled = level.apply_level(afm_stack.data, 2, 2, \"plane\")\n",
49-
"plt.imshow(plane_levelled[5], cmap=\"afmhot\")\n"
48+
"frame_ind = range(0, frames)"
5049
]
5150
},
5251
{
53-
"cell_type": "code",
54-
"execution_count": null,
52+
"cell_type": "markdown",
5553
"id": "4",
5654
"metadata": {},
57-
"outputs": [],
5855
"source": [
59-
"levelled = level.apply_level(plane_levelled, 1, 0, \"med_line\",)\n",
60-
"plt.imshow(levelled[5], cmap=\"afmhot\")\n"
56+
"### Apply a quadratic plane fit"
6157
]
6258
},
6359
{
@@ -67,17 +63,16 @@
6763
"metadata": {},
6864
"outputs": [],
6965
"source": [
70-
"auto_leveled = level_auto.apply_level_auto(afm_stack.data, \"multi-plane-otsu\")"
66+
"plane_levelled = level.apply_level(afm_stack.data, 2, 2, \"plane\")\n",
67+
"plt.imshow(plane_levelled[5], cmap=\"afmhot\")"
7168
]
7269
},
7370
{
74-
"cell_type": "code",
75-
"execution_count": null,
71+
"cell_type": "markdown",
7672
"id": "6",
7773
"metadata": {},
78-
"outputs": [],
7974
"source": [
80-
"plt.imshow(auto_leveled[5], cmap=\"afmhot\")"
75+
"### Mask all the pixels above 0"
8176
]
8277
},
8378
{
@@ -87,18 +82,16 @@
8782
"metadata": {},
8883
"outputs": [],
8984
"source": [
90-
"from pnanolocz_lib.thresholder import thresholder\n",
91-
"mask_hist = thresholder(plane_levelled, 'histogram', limits = (0.2, 100),invert = False)"
85+
"mask_hist = thresholder.apply_thresholder(plane_levelled, 'histogram', limits = (float('-inf'), 0.2),invert = False)\n",
86+
"plt.imshow(mask_hist[5], interpolation= 'none')"
9287
]
9388
},
9489
{
95-
"cell_type": "code",
96-
"execution_count": null,
90+
"cell_type": "markdown",
9791
"id": "8",
9892
"metadata": {},
99-
"outputs": [],
10093
"source": [
101-
"plt.imshow(mask_hist[5], interpolation= 'none')"
94+
"### Apply a line fit from the background (not masked) to the image"
10295
]
10396
},
10497
{
@@ -108,20 +101,16 @@
108101
"metadata": {},
109102
"outputs": [],
110103
"source": [
111-
"from pnanolocz_lib.level_weighted import apply_level_weighted\n",
112-
"lev_weight = apply_level_weighted(plane_levelled, 1, 1, \"smed_line\", mask=mask_hist)\n",
113-
"plt.imshow(lev_weight[5], cmap=\"afmhot\")"
104+
"masked_line_levelled = level.apply_level(plane_levelled, 1, 0, \"line\", mask_hist)\n",
105+
"plt.imshow(masked_line_levelled[5], cmap=\"afmhot\")"
114106
]
115107
},
116108
{
117-
"cell_type": "code",
118-
"execution_count": null,
109+
"cell_type": "markdown",
119110
"id": "10",
120111
"metadata": {},
121-
"outputs": [],
122112
"source": [
123-
"masked_line_levelled = level.apply_level(plane_levelled, 1, 0, \"line\", mask_hist)\n",
124-
"plt.imshow(masked_line_levelled[5], cmap=\"afmhot\")"
113+
"### Rather than manually applying steps, use the \"multi-plane-otsu\" auto level routine"
125114
]
126115
},
127116
{
@@ -130,17 +119,17 @@
130119
"id": "11",
131120
"metadata": {},
132121
"outputs": [],
133-
"source": []
122+
"source": [
123+
"auto_leveled = level_auto.apply_level_auto(afm_stack.data, \"multi-plane-otsu\")\n",
124+
"plt.imshow(auto_leveled[5], cmap=\"afmhot\")"
125+
]
134126
},
135127
{
136-
"cell_type": "code",
137-
"execution_count": null,
128+
"cell_type": "markdown",
138129
"id": "12",
139130
"metadata": {},
140-
"outputs": [],
141131
"source": [
142-
"hist2 = thresholder(masked_line_levelled, \"histogram\", limits=(0.4,100), invert=False)\n",
143-
"plt.imshow(hist2[5], interpolation= 'none')"
132+
"### Use `get_background` to see the values subtracted from a leveled image."
144133
]
145134
},
146135
{
@@ -150,8 +139,7 @@
150139
"metadata": {},
151140
"outputs": [],
152141
"source": [
153-
"maksed_linemed_levelled = level.apply_level(masked_line_levelled, 1, 0, \"med_line\", mask_hist)\n",
154-
"plt.imshow(maksed_linemed_levelled[1], cmap=\"afmhot\")"
142+
"from pnanolocz_lib.level import get_background"
155143
]
156144
},
157145
{
@@ -161,7 +149,7 @@
161149
"metadata": {},
162150
"outputs": [],
163151
"source": [
164-
"mask_hist2 =thresholder(maksed_linemed_levelled, 'histogram', limits= (0.5,100), invert=False)"
152+
"bg = get_background(afm_stack.data, 1, 1, 'plane')"
165153
]
166154
},
167155
{
@@ -171,8 +159,7 @@
171159
"metadata": {},
172160
"outputs": [],
173161
"source": [
174-
"mask_hist2 = thresholder(maksed_linemed_levelled, 'histogram', limits = (0.5,100),invert = False)\n",
175-
"plt.imshow(mask_hist2[0])"
162+
"plt.imshow(bg[5])"
176163
]
177164
},
178165
{
@@ -181,55 +168,6 @@
181168
"id": "16",
182169
"metadata": {},
183170
"outputs": [],
184-
"source": [
185-
"manual_levelled = level.apply_level(masked_plane_levelled, 1, 0, \"line\", mask_hist2)\n",
186-
"plt.imshow(manual_levelled[0], cmap=\"afmhot\")"
187-
]
188-
},
189-
{
190-
"cell_type": "code",
191-
"execution_count": null,
192-
"id": "17",
193-
"metadata": {},
194-
"outputs": [],
195-
"source": []
196-
},
197-
{
198-
"cell_type": "code",
199-
"execution_count": null,
200-
"id": "18",
201-
"metadata": {},
202-
"outputs": [],
203-
"source": [
204-
"from pnanolocz_lib.level import get_background"
205-
]
206-
},
207-
{
208-
"cell_type": "code",
209-
"execution_count": null,
210-
"id": "19",
211-
"metadata": {},
212-
"outputs": [],
213-
"source": [
214-
"bg = get_background(afm_stack.data, 1, 0, 'med_line')"
215-
]
216-
},
217-
{
218-
"cell_type": "code",
219-
"execution_count": null,
220-
"id": "20",
221-
"metadata": {},
222-
"outputs": [],
223-
"source": [
224-
"plt.imshow(bg[0])"
225-
]
226-
},
227-
{
228-
"cell_type": "code",
229-
"execution_count": null,
230-
"id": "21",
231-
"metadata": {},
232-
"outputs": [],
233171
"source": []
234172
}
235173
],
@@ -249,7 +187,7 @@
249187
"name": "python",
250188
"nbconvert_exporter": "python",
251189
"pygments_lexer": "ipython3",
252-
"version": "3.11.13"
190+
"version": "3.12.12"
253191
}
254192
},
255193
"nbformat": 4,

0 commit comments

Comments
 (0)