stain-normalization-js
v0.2.0
Published
H&E stain colour normalization in the browser — Macenko, Vahadane, Reinhard and L-histogram, with an optional WebGPU path.
Downloads
381
Maintainers
Readme
stain-normalization-js
H&E stain colour normalization in the browser — Macenko, Vahadane, Reinhard and L-histogram, in TypeScript with no runtime dependencies and an optional WebGPU path.
A port of StainTools (archived) and of the
normalization in JAX's stain-adapter-worker. As far as we can tell it is the first
browser implementation: GitHub's stain-normalization
topic is entirely Python and Jupyter, and there was no npm package.
npm install stain-normalization-jsimport { createNormalizer } from 'stain-normalization-js';
const norm = createNormalizer('macenko');
norm.fit(referenceImage); // an RGBA {data, width, height}
const evened = norm.transform(tile);npm run demo opens a page that runs all four methods side by side, on a synthetic H&E pair
or on your own images.
Which method
Two families, and they are genuinely different operations rather than degrees of the same one.
| method | family | what it does | cost |
| ------------- | ----------------- | ------------------------------------------------------------------- | ------- |
| l_histogram | colour statistics | histogram-matches L only — corrects lightness, never steers hue | lowest |
| reinhard | colour statistics | transfers per-channel Lab mean and standard deviation | low |
| macenko | stain separation | stain matrix from the OD covariance eigenvectors | medium |
| vahadane | stain separation | stain matrix by sparse non-negative dictionary learning | highest |
Stain separation deconvolves the image into hematoxylin and eosin concentrations and rebuilds it through the reference's stain vectors, so a slide stained with a different hematoxylin comes back with the reference's hematoxylin colour. Colour statistics can only shift what is already there.
The trade is robustness. Separation assumes two stains and needs both present to estimate them; on a field that is essentially all hematoxylin the second vector is underspecified. The statistical methods have no such failure mode — they are weaker, and they always work.
Faithfulness to the reference
Every method is checked against the Python it was ported from — staintools with the real
spams, and the worker's color_normalization.py — on three fixtures. Regenerate with
scripts/gen-truth.py; they are committed so CI needs no Python.
Stain-vector error is reported as the angle between unit vectors, which is the quantity that matters; the transform is compared per pixel.
| fixture | Macenko | Vahadane | worst pixel | | -------------------------- | ------- | -------- | ----------- | | clean two-stain mixture | 0.0023° | 0.0012° | 1 level | | with OD measurement noise | 0.0036° | 0.0008° | 1 level | | ~single stain (degenerate) | 0.1247° | 0.0232° | 2 levels |
Reinhard and L-histogram match the worker to within one 8-bit level.
The three fixtures exist because clean synthetic data flatters a factorization — with two exact stain vectors and no noise almost anything recovers them. The third is the one that matters: with the second stain nearly absent its dictionary atom is underspecified, and that is where a local optimum is most likely to diverge.
Vahadane without SPAMS
This is the interesting part of the port, and the reason the method is usually called unportable.
VahadaneStainExtractor calls spams.trainDL(K=2, λ=0.1, mode=2, modeD=0, posAlpha=True,
posD=True) — online sparse dictionary learning from a C++ library with no browser equivalent.
Compiling SPAMS to WASM drags in BLAS/LAPACK; the binary would dwarf the library.
But look at the problem SPAMS is being handed: optical density has three dimensions and there are two stains, so the dictionary is 3×2 — six free parameters. At that size the general machinery is unnecessary:
- Sparse coding is a two-variable non-negative lasso. Two variables means three possible
active sets, so it is solved in closed form, exactly, with no iteration. This is also
what makes Macenko's transform portable —
get_concentrationscallsspams.lasso, which looks like a second blocker and is the same three-case solve. - Dictionary update is Mairal's block-coordinate step on two atoms, warm-started from the Macenko estimate so it is deterministic (SPAMS seeds randomly) and never lands in the degenerate optimum where both atoms collapse onto the dominant stain.
Batch descent from a fixed start and online learning from a random one are not guaranteed to find the same local optimum of a non-convex objective. That they agree to 0.03° even on the degenerate fixture is a measured result, not a proof. If you need bit-identical agreement with a server, run the server.
WebGPU
Optional, and it accelerates one specific thing.
A separation transform has two halves. Estimating the stain matrix touches a subsample and produces six numbers — microseconds, and dominated by launch overhead if dispatched. Applying it runs the closed-form solve on every pixel, and that is essentially all of the wall clock. So only the second half is dispatched, and the kernel is a direct transcription of the same three active sets — no loops, no iteration, one pass over memory.
Measured in Chrome on an M1 Max, Macenko, output verified bit-identical to the CPU path:
| pixels | CPU (full transform) | GPU kernel | | ----------- | -------------------- | ---------- | | 65k (256²) | 56 ms | 6 ms | | 262k (512²) | 212 ms | 3 ms | | 1M (1024²) | 715 ms | 8 ms |
The kernel is essentially flat — at these sizes it is dispatch-bound, not compute-bound. Note the CPU column is the whole transform including estimation, so a like-for-like end-to-end speedup is smaller; estimation stays on the CPU either way.
import {
GpuStainSeparation,
estimateStainMatrix,
concentrations,
maxConcentrations,
} from 'stain-normalization-js';
const gpu = await GpuStainSeparation.create(); // null when WebGPU is unavailable
const targetStain = estimateStainMatrix(target, 'macenko');
const sourceStain = estimateStainMatrix(source, 'macenko');
// …compute the concentration scale, then:
const out = await gpu.transform(source, sourceStain, targetStain, scale);The CPU path is always the reference implementation and the fallback.
Tissue masks — there are two, and they disagree
Not interchangeable, and using the wrong one is silent:
- Luminosity threshold (
L/100 < 0.8) picks the pixels a stain matrix is estimated from. It keeps dark pixels, because in optical-density space dark is signal. - White/black threshold (220 / 30) decides which pixels a result is composited over, so the slide's white does not drift toward the reference's paper colour. It drops near-black as background.
A pixel can be tissue by one and background by the other. That is expected — they answer
different questions. StainTools uses only the first and rewrites every pixel; the JAX worker
wraps it in the second. compositeOverTissue (default true) selects which behaviour you get.
On OpenCV
StainTools converts colour with cv.cvtColor(…, COLOR_RGB2LAB) on uint8, so an obvious option
was to lean on opencv.js. Measured instead: against cv2 over random colours, L differs by at
most 0.4 (on 0–100) and a/b by at most 1.6 — 8-bit quantization.
It matters less than it sounds, because Macenko and Vahadane use Lab only for the tissue
mask, and the two masks disagree on 0.27% of pixels, all sitting exactly on the threshold.
Against a matrix estimated from tens of thousands of pixels that is far below the noise floor.
So this ships with no dependency. labFromOpenCv is exported for a host that already has
opencv.js loaded and wants exactness.
Development
npm install
npm test # vitest, includes the parity fixtures
npm run demo # the example page
npm run typecheck
npm run lint
npm run buildRegenerating the fixtures needs Python and a working spams:
uv venv --python 3.11 .venv-truth
uv pip install --python .venv-truth/bin/python "numpy<2" spams-bin \
opencv-python-headless "scikit-image<0.23" matplotlib
PYTHONPATH=/path/to/StainTools .venv-truth/bin/python scripts/gen-truth.pyLicense
MIT © The Jackson Laboratory.
Ports StainTools by Peter Byfield (MIT), and the
normalization strategies in JAX's stain-adapter-worker. Methods are due to
Macenko et al. (2009), Vahadane et al. (2016) and Reinhard et al. (2001).
