Stain Quality
Quantitative H&E and IHC stain-quality measurement from whole-slide images — optical density, colour deconvolution, ΔE and the haematoxylin-to-eosin ratio, grouped by scanner and over time — which independently arrives at the same measurement stack as the published 247-lab EQA study, and is therefore closer to validated than it looks.
Purpose
Stain quality control in a routine laboratory is subjective: a technologist or pathologist looks at a slide and judges it. This code is the attempt to put numbers on it, from slides the laboratory has already scanned, without a calibration target or any change to the workflow.
The reason it is worth a page rather than a hub line is a coincidence that turns out to be useful. Working independently, this code implements almost exactly the measurement pipeline that Dunn 2025 — International Study of H&E Stain Variability used across 247 international laboratories in the UK NEQAS programme — colour deconvolution into haematoxylin and eosin, conversion to L*a*b*, ΔE colour difference against a reference, a directional ΔE, and the ratio between the two stains. That study is effectively an external validation of the measurement choices here, and — more usefully — it reports which of those measurements actually tracks expert judgement. See Current state for why that matters.
Not a separate repository: this is a plain directory inside Digital Pathology Drafts, so it has no independent history, no licence of its own and no issue tracker. One commit, 2025-04-11 — re-read 2026-07-31 and unchanged since, with the CIE76-under-the-name-CIE2000 finding, the two same-named classes at 127 and 54 methods, and the demo’s mock-data fallback all confirmed still present at the lines cited below.
Data used
Whole-slide images read through OpenSlide, analysed at 1/10 resolution by default (downsample_factor=10) — stain colour is a low-frequency property, so this is a reasonable economy rather than a shortcut, though nothing records that the choice was tested.
A retained invocation in scanner_and_time_based_HE_stain_analysis.py points at an internal control-H&E slide series from 2025 held on a network share, which is the kind of set this analysis needs: the same tissue, stained and scanned repeatedly, so that variation in the measurement is variation in the process. No cohort, no patient-level data, and nothing from that share is reproduced here.
Optionally, assessor scores can be loaded alongside the images (process_folder(..., load_assessor_scores=...)), which is what allows the quantitative metrics to be compared against human judgement rather than only against each other.
Methods
The measurement chain, read from enhanced_function.py and scanner_and_time_based_HE_stain_analysis.py:
- RGB → optical density,
-log10((max(I,1)+1)/256). Optical density rather than raw intensity is the correct domain because stain absorbance is what is physically additive. - Colour deconvolution using the canonical Ruifrok stain vectors — haematoxylin
[0.65, 0.70, 0.29], eosin[0.07, 0.99, 0.11], and for IHC a haematoxylin/DAB matrix with DAB at[0.27, 0.57, 0.78]plus an AEC alternative. These are correct, which is worth stating explicitly because a transposed or mis-assigned stain vector is the classic silent error here and the DAB and eosin vectors are easy to swap. They are also the constants from the Ruifrok paper sitting in the same repository’s reading collection. - L*a*b* conversion and a colour difference, plus a
directional_delta_ethat preserves the sign of the difference — so a slide can be described as too dark or too pale rather than merely distant from the reference. The colour difference is not what its name says, and that is the most consequential finding here — see below. - The haematoxylin-to-eosin ratio,
he_ratio. - Scanner and time attribution.
extract_svs_metadata()pulls scanner type, scanner ID and scan date/time from the Aperio properties (aperio.Date,aperio.Time, falling back totiff.DateTimeand the OpenSlide comment), so every measurement can be attributed to a machine and a moment. Trends are then tested with Spearman correlation.
That last step is what makes this more than a stain checker: it is the join between stain measurement and Slide Metadata Extraction, and it is what allows the question “is this scanner drifting, or did the stain change?” to be asked at all.
Current state / open questions
Paused since April 2025, and in a state that needs consolidation before it needs features.
The function called delta_e_2000 computes CIE76, not CIE2000 — and the quality thresholds it feeds are borrowed from a paper that used CIE2000. In he_analysis_tool.py the method is plain Euclidean distance in L*a*b*:
delta_e = np.sqrt(delta_L ** 2 + delta_a ** 2 + delta_b ** 2)Its own docstring says so — “a simplified implementation using Euclidean distance” — while keeping the name. That honesty in the docstring is worth crediting, but the name is what every caller and every report reads.
Why it matters rather than being pedantry: the quality buckets applied on top (≤2 equivalent, 2–5 perceptible, 5–10 easily perceptible, >10 outlier) come from Dunn 2025 — International Study of H&E Stain Variability, which computed dΔE-2000. CIE2000 exists precisely because CIE76 over-weights differences in saturated and dark regions — exactly where strongly haematoxylin-stained tissue sits. So the thresholds are being applied to a quantity they were not derived for, and slides will land in different buckets than the paper’s method would place them, with nothing in the output indicating it. One file, he_ihc_stain_quality_analysis.py, added a real CIE2000 via the colormath library — but it is behind a flag that defaults to off, and that file is not the one anything else calls.
Six overlapping implementations of the same thing. enhanced_function.py, he_analysis_tool.py, he_ihc_stain_quality_analysis.py, stain-quality.py, stain_analyzer_analysis.py and scanner_and_time_based_HE_stain_analysis.py all implement rgb_to_od, he_color_deconvolution, calculate_lab_values and delta_e_2000. Two of them define a class with the same name, H_E_StainAnalyzer — one with 127 methods, the other with 54, sharing 50. The larger has grown a GUI (menus, config load/save, tabs for scanner and time analysis); the smaller is the earlier analytical core.
The live one is he_analysis_tool.py, identifiable because it is the only module anything else imports: the newest file, he_stain_quality_demo.py, does from stain_quality.he_analysis_tool import HEStainAnalyzer and calls its analyze_scanner_differences and analyze_time_trends. Nothing imports the two GUI files or the three standalone drafts. Note the trap in the naming — the scanner-and-time logic that actually runs lives in he_analysis_tool.py, not in scanner_and_time_based_HE_stain_analysis.py, which holds its own never-imported copy. Consolidating on the live one is the prerequisite for trusting any number this produces.
The most valuable next step is not a code change. Dunn and colleagues found that absolute H&E intensity did not correlate with expert assessor score, while the intensity ratio did. This code already computes both, and already has the hook for loading assessor scores. Running the existing correlation against a local assessor-scored series would test, on Memorial material, the one finding from a 247-laboratory study that would tell the group which metric to build a QC threshold on. That is a small piece of work with an unusually high information return, and it is the open question worth closing first.
he_ratiois a ratio of lightnesses, not of stain concentrations. It is computed ash_L / e_Lfrom the L* of the reconstructed single-stain RGB images, while the deconvolved concentration array is available and unused. Because L* falls as staining gets stronger, this quantity moves inversely to stain amount and is not on the same scale as a concentration ratio. Whether it still tracks assessor score is an empirical question — it may well, monotonically — but it is[unverified]whether it is comparable to the ratio Dunn reports, and that comparability is the whole point of the exercise above.- The deconvolution’s third stain vector is
[0,0,0], so the matrix is rank-2 and inverted withpinv. This is a defensible least-squares unmixing onto two stains rather than the ImageJ convention of synthesising a third cross-product vector — but it makes the returned “background” channel a uniformly white image, a dead output that callers correctly discard. - Output goes to a hardcoded relative
output/directory, so results land relative to wherever the script was launched rather than beside the input. This is the origin of the strayoutput/folder in the umbrella repository. - The demo silently substitutes fabricated data when a real run yields nothing, and does not mark the output.
he_stain_quality_demo.pybuilds mock slides and mock assessor scores to demonstrate the interface — reasonable in itself. What is not reasonable is the fallback: if a real input folder produces zero valid results, it callscreate_mock_data_for_demo(), invents thirty slides of Gaussian-random L*a*b* values with fake scanner labels and dates, and then generates a complete-looking report, plots and CSV from them. The only signal is a line printed to the console. Nothing in the resulting artefacts says the numbers are synthetic, so a report can leave the machine indistinguishable from a real one. For a quality-control tool this is the most dangerous behaviour in the folder, and it is a four-line fix: fail loudly, or stamp every synthetic artefact. - The statistics are pairwise without correction.
analyze_scanner_differencesruns a two-sample t-test between every pair of scanner models andanalyze_time_trendsruns a Spearman correlation per metric, with no multiplicity adjustment. With a handful of scanners and two metrics, that is enough comparisons for a spurious “significant” difference to be likely; any drift claim coming out of this needs that accounted for before it is believed.
Related: Colour Calibration — the concept page this project is the applied, post-hoc half of; this code measures colour, it does not calibrate the scanner that produced it. Related: Dunn 2025 — International Study of H&E Stain Variability — the published study using the same measurement stack, and the source of the intensity-ratio finding that should drive the next step here. Related: Scanner and Stain Variability — the problem this exists to quantify, and the reason scanner ID is extracted alongside every measurement. Related: WSI Quality Control — where a validated threshold from this work would be used. Related: Slide Metadata Extraction — the Aperio property parsing this depends on, shared with the group’s other metadata tooling. Related: Digital Pathology Drafts — the umbrella repository this is a plain directory in, which is why it has no licence or history of its own.
Derived from: repository source read 2026-07-27 — enhanced_function.py (read in full for the deconvolution and OD chain), scanner_and_time_based_HE_stain_analysis.py, he_analysis_tool.py, he_stain_quality_demo.py, and structural comparison of he_ihc_stain_quality_analysis.py against stain-quality.py; git log for currency.