npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

Readme

stain-normalization-js

CI / CD npm version License: MIT TypeScript WebGPU

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-js
import { 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_concentrations calls spams.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 build

Regenerating 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.py

License

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).