stain-normalisation-qupath
A QuPath menu that hands slides to TIAToolbox for Macenko, Reinhard or Vahadane normalisation — architecturally the right choice, but as shipped it cannot open an SVS at all, and on the formats it can open it normalises every image to itself.
Purpose
Stain normalisation is the software half of the colour problem described in Colour Calibration: rather than fixing the scanner, map images onto a common appearance afterwards. This project puts that inside QuPath, where the group actually works, instead of requiring a separate Python session.
The design decision is a good one and worth recording as a positive. It does not reimplement Macenko or Vahadane; the Groovy menu collects parameters and shells out to python/stain_normalize.py, which is a thin wrapper over TIAToolbox’s stainnorm normalizers, with PathML as an optional alternative backend. Given that QuPath Tooling already notes how often this cluster reimplements what TIAToolbox provides, delegating is exactly right. The problems below are all in the wrapper, not in the approach.
Not a separate repository — a plain directory inside Digital Pathology Drafts. One commit, 2025-06-11.
Data used
No cohort. It takes whatever images the QuPath project holds, plus a reference target image that every method except Reinhard needs in order to have something to normalise towards.
The intended target is revealing: the search paths point at EXAONE-Path/macenko_target/target_TCGA-55-A48X_...png — a tile from TCGA, carried along with a public foundation model. So the intended behaviour is to pull local slides toward the colour of a TCGA reference tile, which is a defensible choice (it matches what the pretrained model saw) but is a decision nobody has written down, and it is not what the tool actually does. See below.
Methods
scripts/stain_normalization_menu.groovy builds a QuPath parameter dialog — method, scope (current image or all), output format, overwrite — then invokes the Python script by subprocess and checks its exit code. python/stain_normalize.py loads the image, loads a reference target, fits a normalizer and transforms.
The backend selection is correct, which is worth stating because it looked wrong on a first read: an explicit --use_pathml wins, otherwise TIAToolbox is preferred, and PathML is used only as a fallback when TIAToolbox is absent. The one caveat is that on a machine without TIAToolbox the same command silently produces a different implementation’s output, logged only as “(fallback)” — a reproducibility wrinkle rather than a bug.
Current state / open questions
Paused, and it needs two specific fixes before it can be used at all. Both were found by reading the code and then confirmed by running the tool, and the second reverses what a static read first suggested.
0. The QuPath menu cannot invoke the script at all — this is the failure a user hits first. The menu’s format dropdown offers ["TIFF only", "PNG only", "Both TIFF and PNG"], defaulting to the third. It is passed straight through as --format outputFormat.toLowerCase(), producing both tiff and png. The Python side declares choices=["tiff", "png", "both"]. No dropdown value can ever match, so argparse rejects the argument and exits 2 before the script does anything at all — for every image, on every run, including the default selection. Verified by reading both sides: the three literal dropdown strings and the three literal choices.
This reorders the two findings below into things a user would never actually reach from the menu. It also means the tool has almost certainly never been run successfully from inside QuPath, which is consistent with everything else here. The fix is one line on either side.
1. Run directly, it cannot open a whole-slide image. The tool exits 1 and writes nothing. load_image() tries PIL first; on a real Aperio SVS that raises a decompression-bomb error (billions of pixels), which is caught, and execution falls through to TIAToolbox:
img_array = wsi.slide_thumbnail(max_dimensions=(2048, 2048))max_dimensions is not a parameter of TIAToolbox’s slide_thumbnail. In the version the project itself pins, the signature is slide_thumbnail(resolution=1.25, units="power"), and the keyword appears nowhere in the package. The call raises TypeError, the broad except catches it, load_image() raises, and the process exits 1 with an empty output directory. Confirmed end-to-end against a real SVS: PIL fails, the TypeError is logged, nothing is saved. So the primary documented use case — point it at a slide — is currently dead on arrival, and no amount of correct normalisation logic downstream is reachable.
This also corrects a plausible-sounding but wrong reading. It is tempting to conclude that the tool silently normalises a 2048-pixel thumbnail and saves it under a slide-like name; it does not, because it never gets an array at all. That would be the behaviour after the API call is fixed, and it is worth deciding deliberately at that point, because a thumbnail is a reasonable thing to normalise for preview and a useless thing to normalise for analysis. The README mentions thumbnails only in a buried optimisation section, never in Quick Start or Usage.
2. On images it can open, every image is normalised to itself. When no reference target is found:
fit_image = target_img if target_img is not None else img_array
normalizer.fit(fit_image)
normalized = normalizer.transform(img_array)Fitting on the input and transforming the input reconstructs the image from its own stain decomposition — the source and target stain matrices are identical and the rescale ratio is 1, so each slide keeps its own colour. Across a batch that means zero harmonisation, which is the entire purpose of the exercise. It is logged as a warning, the process exits 0, and the output file is still named *_Macenko_normalized.png. Confirmed by running it: the warning appears, the exit code is 0, and the file is written.
And the target can essentially never be found, which is why this is the default path rather than an edge case. Two independent reasons:
- The three hardcoded fallback paths are relative to the script directory and none of them exists in the tree. The real target PNG lives on a different drive entirely.
local_models.txt, the file the Groovy menu passes as--models_file, is atree-style directory listing, not a list of paths. The parser requires a single line containing bothmacenko_targetand.png; in tree output the directory name and the file name are on separate lines, so no line ever matches. Verified: called against the bundled file, the loader returnsNone.
The Groovy caller checks only that local_models.txt exists and that the Python exit code is 0 — neither of which detects either failure.
- The documentation describes a different program in four places.
docs/SETUP.mddocuments a--verboseflag that does not exist in the argument parser; it anddocs/CLAUDE.mdboth tell the reader to openscripts/draff_script1.groovy, a file that is not in the repository;docs/CLAUDE.mdclaims output files are automatically renumbered when overwrite is off, where the code logs an error and aborts that image; and it claims CONCH, SPIDER and SAM integration points that appear nowhere in the code.config.yamlis never read by anything — no YAML parser is imported — so its thumbnail size, worker count and metrics settings are decorative. Treat thedocs/folder here as a design sketch rather than documentation. - The fix order is: the
--formatstring, then theslide_thumbnailcall, then make a missing target fatal. The second is more important than it sounds. Self-reference should be an error, not a warning, because the current design produces output that looks successful, is named “normalized”, and has not been normalised — the worst possible failure mode for something that feeds downstream analysis. - Then decide what the reference target should be, and record it. Pulling Memorial slides toward a TCGA tile is a real methodological choice with consequences for Scanner and Stain Variability, not a configuration detail.
RegionRequestis imported in the Groovy script and never used — a dead import, and a hint that a tile-based path was intended and never built. There is no region or tiling path anywhere, which is the deeper reason this cannot handle a WSI properly.[unverified]whether a tiled path was ever started.
Related: Stain Normalisation — the method this implements; that page’s point that the simplest normaliser usually wins is the reason delegating to TIAToolbox rather than reimplementing was right. Related: Colour Calibration — the physical alternative; this project is the post-hoc half, and the two are not interchangeable. Related: Stain Quality — the sibling that measures stain rather than changing it; running that before and after this one is how the effect would be demonstrated. Related: TCGA — the source of the intended reference tile, and the reason the choice of target is a methodological decision. Related: Digital Pathology Drafts — the umbrella this is a plain directory in.
Derived from: repository source read 2026-07-27 — python/stain_normalize.py (read in full), scripts/stain_normalization_menu.groovy, README.md, local_models.txt, docs/CLAUDE.md, docs/project-knowledge.md, requirements.txt; installed tiatoolbox 1.6.0 API checked directly. Both findings independently re-verified by a second reading instructed to refute them, including live execution of the CLI against a real SVS and against a PNG; that pass overturned an earlier, wrong reading of the thumbnail behaviour.