Metadata-Version: 2.4
Name: colorist-aug
Version: 1.0.1
Summary: Statistical color matching as a training-time data augmentation for domain generalization in medical image classification.
Author-email: Sebastian Doerrich <sebastian.doerrich@uni-bamberg.de>
Maintainer-email: Sebastian Doerrich <sebastian.doerrich@uni-bamberg.de>
License: MIT
Project-URL: Homepage, https://github.com/sdoerrich97/colorist
Project-URL: Repository, https://github.com/sdoerrich97/colorist
Project-URL: Weights, https://huggingface.co/sdoerrich97/colorist-densenet121-medical
Project-URL: Issues, https://github.com/sdoerrich97/colorist/issues
Keywords: color-transfer,data-augmentation,domain-generalization,medical-image-analysis,computer-vision,pytorch
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Scientific/Engineering :: Image Processing
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Requires-Dist: torch<3,>=2.9
Requires-Dist: torchvision<0.25,>=0.24
Requires-Dist: numpy<2.3,>=2.2
Requires-Dist: Pillow<13,>=12
Requires-Dist: scikit-image<0.24,>=0.23
Requires-Dist: scipy<2,>=1.10
Provides-Extra: examples
Requires-Dist: matplotlib<4,>=3.8; extra == "examples"
Requires-Dist: jupyter<2,>=1.1; extra == "examples"
Provides-Extra: weights
Requires-Dist: timm<2,>=1.0; extra == "weights"
Requires-Dist: huggingface-hub<1,>=0.26; extra == "weights"
Requires-Dist: safetensors<1,>=0.4; extra == "weights"
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Requires-Dist: pytest-cov>=4; extra == "dev"
Requires-Dist: ruff>=0.5; extra == "dev"
Requires-Dist: mypy>=1.0; extra == "dev"
Dynamic: description
Dynamic: description-content-type
Dynamic: license-file

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

<p align="center">
    [<a href="https://arxiv.org/abs/2608.18915">Preprint</a>]
    [<a href="">Publication</a>]
    [<a href="https://pypi.org/project/colorist-aug/">PyPI</a>]
    [<a href="https://huggingface.co/collections/sdoerrich97/colorist-6aa242c33d5b352735a4150f">Pretrained Weights</a>]
    [<a href="#citation">Citation</a>]
</p>

## 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.](https://github.com/sdoerrich97/colorist/raw/main/assets/teaser.png)
*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

```bash
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

```bash
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:

```bash
# 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](https://github.com/sdoerrich97/colorist/blob/main/requirements-image.txt) for the environment of record and
the command that regenerates the lock from it.

## Quick Start

```python
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:

```python
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/](https://github.com/sdoerrich97/colorist/tree/main/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](https://huggingface.co/collections/sdoerrich97/colorist-6aa242c33d5b352735a4150f) 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.

```python
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.](https://github.com/sdoerrich97/colorist/raw/main/assets/results.png)
*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](https://github.com/sdoerrich97/colorist/blob/main/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:

```bash
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.

```bash
# 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:

```bash
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:

```bash
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

```bash
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](https://github.com/sdoerrich97/colorist/blob/main/LICENSE).

The thirteen comparison baselines under `experiments/reference_methods/` are third
party. [NOTICE](https://github.com/sdoerrich97/colorist/blob/main/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

```bibtex
@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.
