Skip to main content

Colorist: Simple, Safe, and Overlooked — Reclaiming Sustainable Domain Generalization with Statistical Color Matching @ MICCAI DEMI 2026

[Preprint] [Publication] [PyPI] [Pretrained Weights] [Citation]

Overview

Hardware shifts, color variations, and changing patient characteristics between development and deployment routinely break trained medical image classifiers. Existing remedies fall short: standard color jittering provides insufficient diversity, while deep generative style transfer algorithms hallucinate features, destroy clinically relevant structures, and waste massive compute resources. To address this, we revisit classical statistical color matching and repurpose it as Colorist, a highly efficient data augmentation strategy that applies global mean-standard deviation matching directly in the RGB color space. We demonstrate that this training-free, fully interpretable approach safely generates structurally intact domain variations, outperforming deep generative models in structural fidelity and color alignment. Across out-of-distribution histopathology, peripheral blood, dermatology, and retinal datasets, it improves balanced accuracy by up to +9% over state-of-the-art domain generalization regularizers and by +13% over an unaugmented baseline.

Triplets of content, style, and stylized output across six medical modalities. Triplets of content, style, and stylized output images created by Colorist across six distinct medical modalities: simple global RGB matching transfers photometric shifts without corrupting clinical anatomy.

For each color channel independently, the content image's first two moments are matched to those of a randomly drawn style image:

x' = (x - mu_c) * sigma_s / (sigma_c + eps) + mu_s,    eps = 1e-8

Because this is a per-pixel affine map, it preserves the spatial anatomical layout exactly: pixel rank order within a channel is unchanged, so no structure can be invented or rearranged. It is training-free, runs in O(N), needs no GPU, and drops into a standard dataloader.

On novelty. The transform itself is Reinhard et al. (2001). The contribution of this paper is empirical: that applying it in native RGB is competitive with decorrelated color spaces, and that using it as an online augmentation is effective across nineteen medical datasets.

Key Contributions

  • Statistical color matching repurposed as an efficient, interpretable training-time augmentation for out-of-distribution generalization.
  • A demonstration that it outperforms compute-heavy deep generative models on both color transfer quality and structural preservation, at a fraction of the energy cost.
  • A systematic evaluation of thirteen color spaces and three matching methods, showing that global mean-standard deviation matching in native RGB performs competitively with decorrelated spaces while avoiding dataloader conversion bottlenecks.
  • Empirical validation across 12 in-distribution and 7 covariate-shifted OOD datasets.

Installation

From PyPI

pip install colorist-aug

The distribution is named colorist-aug because colorist on PyPI is an unrelated terminal-color library. The import name is colorist. Installing both packages into one environment is not supported.

From source

git clone https://github.com/sdoerrich97/colorist.git
cd colorist
pip install -e .                 # library only
pip install -e ".[examples]"     # + matplotlib, jupyter for examples/
pip install -e ".[weights]"      # + timm, huggingface-hub for the Model Zoo
pip install -e ".[dev]"          # + ruff, mypy, pytest

Requires Python >= 3.10.

Reproducing the paper's environment

requirements.lock.txt is fully pinned and hash-verified, and every version in it equals the one in the image the published numbers were computed with. Install it in two steps, in a clean Python 3.13 environment:

# 1. Build dependencies, with the pythran bound that actually binds.
pip install "numpy>=2.2.0,<2.3.0" "pythran<0.18.1" "Cython>=3.0.4" \
            "meson-python>=0.15" ninja "setuptools>=67" wheel "packaging>=21"

# 2. The lock itself, without build isolation.
pip install --require-hashes --no-build-isolation -r requirements.lock.txt
pip install --no-deps .

Step 1 is not optional and a single pip install -r will not work. scikit-image is held at 0.23.2 for medmnistc compatibility, that release predates Python 3.13 so it is always compiled from source, and its pyproject declares pythran with no upper bound. pythran >= 0.18.1 needs C++17 while scikit-image 0.23.2 compiles its pythran extensions with a hardcoded -std=c++14, so every translation unit dies with 'is_integral_v' is not a member of 'std'. Build isolation would fetch the newest pythran regardless of what you installed, which is why it has to be turned off. PIP_CONSTRAINT does not fix this: pip ignores it when resolving build dependencies.

numpy is pinned in step 1 for a second reason. Without build isolation scikit-image compiles against whatever numpy is ambient, and running an extension against an older numpy than it was built for is not covered by numpy 2's forward-compatibility guarantee. Installing the runtime numpy first makes build-time and run-time numpy equal.

The Dockerfile does exactly this, and docker build --target production is the path that needs no manual steps. Published results used Python 3.13, PyTorch 2.9.1, CUDA 12.8. See requirements-image.txt for the environment of record and the command that regenerates the lock from it.

Quick Start

import colorist

# The published method: global mean/std matching in RGB.
out = colorist.transfer_rgb(content, style)

# Any of the thirteen evaluated color spaces, and the three matching methods.
out = colorist.transfer_lab(content, style, method="histogram")

print(colorist.list_color_spaces())
# ['hed', 'hsd', 'hsi', 'hsv', 'lab', 'lch', 'luv', 'rgb',
#  'ycbcr', 'ydbdr', 'yiq', 'ypbpr', 'yuv']

Inputs may be PIL images, numpy arrays or torch tensors, single or batched; a single image broadcasts against a batch. The output matches the input format.

As a torchvision v2 augmentation, applied with probability 0.3 as in the paper:

from torchvision.transforms import v2
from colorist import RandomColorTransfer

transform = v2.Compose([
    v2.Resize((224, 224)),
    RandomColorTransfer(color_space="rgb", p=0.3),
])

See examples/ for runnable notebooks covering the API, the color spaces, dataloader integration, and loading a published classifier.

Model Zoo

Colorist itself is training-free and has no parameters, so there are no weights for the transform. What we publish are the DenseNet121 classifiers trained with it: 57 checkpoints, one repository per dataset and seed, grouped in the Colorist collection on Hugging Face. All were trained in the published configuration (rgb, mean_std, p = 0.3) with the three canonical seeds 71397589, 133560673, 265017005.

Each repository is sdoerrich97/colorist_densenet121_<dataset>_s<seed> and holds model.safetensors, a config.json describing the architecture, class count and normalization, and a model card carrying that dataset's per-seed results.

from colorist import load_pretrained_classifier

model = load_pretrained_classifier("camelyon17wilds", seed=71397589)

Test balanced accuracy, mean ± sd over the three seeds. These are the runs behind Table 3 of the paper; the per-dataset means reproduce its columns. Each row is three repositories, one per seed; the identifier is what you pass to load_pretrained_classifier.

Domain Dataset Classes Colorist No augmentation Identifier
Histopathology Camelyon17-WILDS 2 93.04 ± 3.90 62.63 camelyon17wilds
Epithelium-Stroma 2 86.78 ± 4.98 60.96 epistr
PathMNIST 9 92.69 ± 0.65 88.96 pathmnist
Dermatology Fitzpatrick17k 3 39.81 ± 2.21 36.45 fitzpatrick17k
DDI 2 50.03 ± 0.36 48.27 ddi
DermaMNIST 7 51.80 ± 0.54 51.86 dermamnist
Haematology Bone Marrow 13 45.94 ± 1.76 16.16 bone_marrow_smears_and_peripheral_blood
Peripheral Blood 13 44.22 ± 1.95 30.52 peripheral_blood
BloodMNIST 8 98.48 ± 0.23 97.93 bloodmnist
TissueMNIST 8 61.19 ± 1.54 61.60 tissuemnist
Ophthalmology Retina 5 20.85 ± 4.24 21.73 retina
RetinaMNIST 5 37.71 ± 1.45 36.06 retinamnist
OCTMNIST 4 89.47 ± 2.80 88.63 octmnist
Radiology ChestMNIST 14 94.76 ± 0.04 94.78 chestmnist
PneumoniaMNIST 2 85.37 ± 2.00 82.59 pneumoniamnist
BreastMNIST 2 80.01 ± 3.00 81.64 breastmnist
OrganAMNIST 11 93.35 ± 0.67 95.64 organamnist
OrganCMNIST 11 91.59 ± 1.42 90.83 organcmnist
OrganSMNIST 11 77.56 ± 0.37 73.93 organsmnist

Bold marks the datasets where the augmentation helps. It is not uniformly positive, and the paper does not claim it is: the gains concentrate in the covariate-shifted benchmarks, which is where photometric shift is the dominant failure mode.

Do not average the three seeds into one checkpoint. They are trained from scratch with different initialisations, so they occupy different loss basins and averaging their weights cancels their features instead of combining them. Measured on BloodMNIST: the seeds score 0.9844 / 0.9886 / 0.9918 and their weight-average scores 0.1250, which is exactly chance for 8 classes. Weight averaging needs a shared initialisation (Model Soups) or a single trajectory (SWA); neither holds here. Average the predictions or the metrics, not the weights.

Results

Qualitative comparison across six imaging modalities. Deep generative style transfer corrupts clinical geometry; Colorist maintains structural integrity while transferring the photometric style.

Repository layout

colorist/               installable library (the method)
  transfer_<space>.py     one module per color space
  _matching.py            mean_std (published), histogram, ehm
  _factory.py             color-space registry, pretrained-classifier loader
  transforms.py           torchvision v2 transforms

experiments/            reproduction code; NOT shipped in the wheel
  data/                   one module per dataset
  metrics/                SSIM, LPIPS, FID, Wasserstein, ArtFID
  reference_methods/      the 13 Table 2 baselines, one folder per method
  train_orig.py           Colorist classifier training
  train.py                the above plus transfer-strategy options
  color_transfer.py       color-space and fidelity evaluation

scripts/                docker drivers and table generators
examples/               runnable notebooks
assets/                 figures used by this README

Reproducing the paper

All evaluations use three seeds: 71397589, 133560673, 265017005.

Result Run Then build the table with
Table 1 — color spaces and matching methods scripts/color_transfer/color_spaces/<space>_docker.sh (drives experiments/color_transfer.py) scripts/create_latex_tables/create_latex_tables_color_space.sh
Table 2 — structural fidelity vs. 13 generative baselines experiments/reference_methods/pretrain.py per method, then scripts/color_transfer/<method>_docker.sh scripts/create_latex_tables/create_latex_tables_color_transfer.sh
Table 3 — downstream classification experiments/train.py (or train_orig.py) per dataset, strategy and seed experiments/create_latex_table_classifier.py
Supplement Fig. D1 — augmentation-probability sweep experiments/train.py across the 21 probabilities experiments/create_supplement_figure_probability.py
Supplement Table F1 — runtime, throughput, energy scripts/inference_reporting/supplement_j_runtime.sh and reference_methods.sh scripts/create_latex_tables/create_latex_table_runtime.py --all

Select the matching method with --color_transfer_method {mean_std,histogram,ehm}.

Table 2 needs each baseline's own pretrained checkpoint. We do not redistribute those; obtain them from the upstream projects listed in NOTICE, or train the training-required methods from a VGG-19 initialisation with experiments/reference_methods/pretrain.py.

Two things about the supplement artifacts are easy to get wrong, so both generators enforce them rather than leaving them to the caller:

  • The baselines do not share one input resolution. Each declares its own native size and the harness honours it: 256 for eight methods, 224 for Contrimix, SGViTs and StylizingViT, and 512 for Modflows and WCT2. create_latex_table_runtime.py prints it per row, because the two 512 methods are also the two largest energy ratios and a reader who assumes a common resolution reads that gap as method cost.
  • Both devices are reported, in two blocks. GPU is how the baselines are deployed; CPU is the like-for-like comparison, since Colorist has no GPU implementation and appears once, at the foot of the CPU block. The generator emits both from one pass and prints numbers at a precision that scales with magnitude, so a 21-second CPU measurement is not printed to 10 us and a 0.047 it/s throughput is not rounded to 0.0.
  • The probability figure is drawn at final print size (figsize=(4.8, 3.9), the LLNCS text block). Drawn at 11in and included at \textwidth, its 8pt legend rendered at ~3.5pt.

Running a full sweep

Two wrappers run a whole section, one per sweep, and are the intended entry point for a campaign rather than the per-space and per-method drivers:

screen -dmS colorist-runs   bash scripts/color_transfer/sweep_color_spaces.sh
screen -dmS colorist-runs-E bash scripts/color_transfer/sweep_reference_methods.sh

Every path and image tag is overridable from the environment. Run them concurrently in two screens: the 22 min per invocation in scan_completion.py was measured under exactly that load, so it already includes the contention, and a sequential run is not faster per invocation.

Resuming an interrupted sweep

The color-space and reference-method sweeps run for days, so they get cut short. Both write into a merged metrics.json per dataset (one per $COLORIST_RESULTS/<section>/<dataset>-<dataset>_pairwise/), which makes that tree the record of what actually completed, and makes the drivers re-runnable: each one skips any invocation already recorded and runs only the rest.

# What is left, per color space or per reference method
python3 scripts/color_transfer/scan_completion.py color_space
python3 scripts/color_transfer/scan_completion.py reference --missing

# Resume: re-run the same driver, it picks up where it stopped
export COLORIST_RESULTS=/path/to/results
bash scripts/color_transfer/color_spaces/hsv_docker.sh

COLORIST_RESULTS has no default on purpose: metrics.json merges, so a run that lands in the wrong tree interleaves two eras in one file with nothing marking the boundary. COLORIST_RESULTS_MERGE lets a sweep write into a fresh tree while counting an existing tree as coverage, COLORIST_DATASETS narrows a driver to named datasets, and COLORIST_FORCE=1 recomputes recorded invocations instead of skipping them.

scrub_suspect_cells.py nulls recorded cells so the resume guard reports them as missing and the driver recomputes them in place. Use it when a run produced values you have reason to distrust: the guard treats a cell as done whenever its probe metric is non-null, so a value that was written but is wrong gets skipped, not corrected. It derives the suspect set from file mtimes narrowed to the keys that were in flight, runs as a dry run by default, and --apply additionally demands --expect N, the count the dry run printed, so a mistyped key or a wider window aborts before anything is written.

Containers

The drivers execute inside colorist:production and the repo is not mounted: experiments/ comes from the image, so a change to metric or evaluation code takes effect only once the image is rebuilt. When the change is code-only, derive from the working image rather than rebuilding the dependency set, which keeps results comparable with what is already in the tree:

docker build -f docker/Dockerfile.code-refresh \
    --build-arg BASE=colorist:production -t colorist:production-$(date +%Y%m%d) .
export COLORIST_IMAGE=colorist:production-$(date +%Y%m%d)

When the dependency set itself has to change, build the full image:

docker build --no-cache --target production -t colorist:rebuild-test .

--no-cache is not optional for a verification build. The builder cache holds thousands of entries, and a cached build reuses the pip install -r requirements.txt layer, which is where this image's only real build hazard lives. A cached build exits 0 without ever exercising it.

That layer installs its build dependencies explicitly and then runs pip with --no-build-isolation. Both halves are load-bearing and are explained at the point of use in the Dockerfile. In short: scikit-image is held at 0.23.2 for medmnistc, that release compiles its pythran extensions as C++14, and pythran >= 0.18.1 requires C++17, so pythran has to be pinned below it. PIP_CONSTRAINT cannot do that pinning, because pip ignores it when resolving build dependencies. numpy is pinned in the same step so that the numpy scikit-image compiles against is the numpy it runs against.

COLORIST_IMAGE is honoured by every driver that produces a number the paper reports: the colour-space drivers, the Table 2 method drivers, and everything under scripts/inference_reporting/ and scripts/create_latex_tables/ (the latter runs the significance tests inside the image, so a stale tag there means stale statistics).

Datasets

The twelve 2D tasks of MedMNIST+ plus seven covariate-shift benchmarks: Camelyon17-WILDS, Epithelium-Stroma, Fitzpatrick17k, DDI, peripheral blood, bone marrow, and a retina set. See the paper for splits and sources; the loaders live in experiments/data/. Datasets are not redistributed here — obtain each from its original provider, under its own terms.

Development

pip install -e ".[dev]"
ruff check colorist examples && mypy colorist && pytest -q

The suite is CPU-only and runs in a few seconds. Tests that need torch.from_numpy skip automatically when the installed torch and numpy disagree on ABI.

The library, the examples and the tests are lint- and type-clean. ruff check . across the whole tree is not: the experiments/ harness carries about 2000 pre-existing findings, overwhelmingly cosmetic (trailing whitespace on blank lines, List instead of list, long lines). They are not fixed here because that reformatting would touch the code that produced the published numbers, for no behavioural gain. The vendored baselines under experiments/reference_methods/<method>/ are excluded from lint entirely, in pyproject.toml: they are kept close to verbatim so they stay comparable with upstream.

Licensing

Code authored here is MIT; see LICENSE.

The thirteen comparison baselines under experiments/reference_methods/ are third party. NOTICE records, per method, the upstream project, its license, and where a project publishes none. Seven of the thirteen ship a license and a verbatim copy sits in that method's directory and governs it. Do not assume this repository's MIT license extends to them.

Datasets carry their own terms (CC BY 4.0, CC BY-NC 4.0, CC BY-NC-SA 3.0, CC0, and custom research-use agreements) and are not redistributed here.

Citation

@article{doerrich2026colorist,
  title         = {Simple, Safe, and Overlooked: Reclaiming Sustainable Domain
                   Generalization with Statistical Color Matching},
  author        = {Doerrich, Sebastian and Di Salvo, Francesco and
                   Rai, Shyam Nandan and Lents, Marco and Ledig, Christian},
  year          = {2026},
  eprint        = {2608.18915},
  archivePrefix = {arXiv},
  primaryClass  = {cs.CV},
  url           = {https://arxiv.org/abs/2608.18915}
}

Changelog

v1.0.1

Model zoo restructured. The 57 checkpoints were first published as one repository with per-dataset subfolders, which the Hub indexes as a single model no matter what is inside it, and which cannot be grouped into a collection per variant. They are now one repository each, colorist_densenet121_<dataset>_s<seed>, holding model.safetensors + config.json + a model card, and grouped in the Colorist collection. load_pretrained_classifier(dataset, seed) is unchanged for callers; it resolves the new repository ids internally. The original aggregate repository is gone, so 1.0.0's loader no longer resolves and this release is required to use the zoo.

v1.0.0

Initial public release accompanying "Simple, Safe, and Overlooked: Reclaiming Sustainable Domain Generalization with Statistical Color Matching" (MICCAI DEMI 2026).

Known issue in the published results, disclosed here. compute_fid used to return a silent fallback of 100.0 when the Inception model was unavailable or the computation threw, instead of failing. 45 invocations in the tree behind the manuscript carry it, so their FID, and the ArtFID derived from it, are placeholders rather than measurements. Four rows of Table 1 are affected: HSI, YIQ, YPbPr and YUV. This code release fixes it: compute_fid now raises MetricUnavailableError (as does compute_gatys_style_loss, which had the same fallback at 1.0), the caller records None, and the invocation is re-run rather than reported as measured.

The manuscript is not revised. All of its numbers were produced with one compute setup and are internally consistent, and the fix arrived after the camera-ready was submitted in good faith. Recomputing the affected cells changes no conclusion: on the authoritative n=12 MedMNIST file, 78 significance tests, 21 p-values move and no result changes significance. The quoted p = 4.88e-04, Z = -3.06, r = 0.88 and p = 0.042, Z = -2.04, r = 0.59 are unchanged to every printed digit. The corrected MedMNIST-average ArtFIDs are HSI 49.49 → 44.67, YIQ 37.29 → 30.64, YPbPr 37.79 → 30.61, YUV 33.00 → 30.61; HED, HSV and RGB move only in the last printed digit, and CIELAB, LCH, LUV and YCbCr do not move.

scan_completion.py --fid-fallbacks lists any remaining stand-in, and --merge-with refuses to count one as coverage.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

colorist_aug-1.0.1.tar.gz (76.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

colorist_aug-1.0.1-py3-none-any.whl (46.5 kB view details)

Uploaded Python 3

File details

Details for the file colorist_aug-1.0.1.tar.gz.

File metadata

  • Download URL: colorist_aug-1.0.1.tar.gz
  • Upload date:
  • Size: 76.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.4

File hashes

Hashes for colorist_aug-1.0.1.tar.gz
Algorithm Hash digest
SHA256 127970375c3e5059a1ebda57b98849863fa7f99dba233312c69aa4ae03056490
MD5 00505bb23b0ce93cc710aaee56bca021
BLAKE2b-256 e5fb2dc39dfac9370df774d81db89143329e206fc2dce21717524dbc7e9501bb

See more details on using hashes here.

File details

Details for the file colorist_aug-1.0.1-py3-none-any.whl.

File metadata

  • Download URL: colorist_aug-1.0.1-py3-none-any.whl
  • Upload date:
  • Size: 46.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.4

File hashes

Hashes for colorist_aug-1.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 95ca10fdda112d9670ca4699ee616555c9d916be4a6cfaf82c04a708df90a9d3
MD5 46ffe9188648e2664eec97d5e8200bfd
BLAKE2b-256 2b8ffb62a3ce87dec38b45a43d82c2b9bfbcec2a25d346aad4c71ddf4d26fd08

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

1.0.1 This release

2 files

1.0.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page