Repository navigation
Add RGB-to-scalar stain presets and the preset catalogue - #95
Conversation
New stain module with rgb_to_scalar and a read-only registry of four P0 presets (he_eosin, he_hematoxylin, luminance_inverted, lab_a). 8-bit sRGB, axes YXC or CYX, refusals instead of repairs, optional histology extra (scikit-image, imported lazily). Contract section 3.4 and plan N67 record the scope change; rgb.py and the file formats are untouched. Refs #94 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW
The main CI matrix installs only .[dev], so tests/test_stain.py was skipped there; a dedicated histology job installs the extra and runs it. _to_yxc returned a view for contiguous YXC input (np.ascontiguousarray does not copy); it now always copies, as its docstring says. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW
to_scalar(pixels, *, axes, preset, white, stain_vectors, polarizer) is the general entry point; rgb_to_scalar delegates to it and the four P0 presets give bitwise identical outputs. New presets: fluorescence and shg (passthrough), psr_brightfield, he_custom and trichrome_custom (custom_od, optical density against a declared white in NumPy), psr_polarized (rgb2gray, declared polarizer), trichrome_blue (bpx_from_rgb) and trichrome_green (minus CIELAB a*). Each preset carries a modality and consumer hints. Helpers white_from_region and stain_vectors_from_regions derive the white and the stain vectors from labelled regions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW
…ests in CI Contract §3.4 gets the preset table with modality, method, target and exclusion, hints, refusals, the optical-density definitions and limits. Plan N68 records the owner's catalogue decision; CHANGELOG and the AGENTS.md repository map list the new modules. The histology CI job now runs the catalogue and optical-density tests too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW
Python 3.11's dataclasses refuse a MappingProxyType default as mutable (3.13 accepts it), so test collection failed on the 3.11 CI jobs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW
Review of b152472 (ChatGPT, verified against this head): - np.ma.MaskedArray passed the ndarray check and np.array dropped its mask; masked arrays are now refused for RGB and plane presets. - An image with Y or X of 0 reached np.mean and gave a NaN provenance; it is now refused. - clipped_fraction counted every zero component, not only clipped ones; renamed zero_fraction (unreleased API). - polarity 'bright' is a signal direction, not a stain amount. Tests: masked and empty refusals, fixed reference values (white, black, neutral grey). The pixelwise scikit-image parity test of the P0 presets was already present since 5040a67's parent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW
…tions he_custom and trichrome_custom get published default stain vectors (Ruifrok-Johnston H&E rows of rgb_from_hed; Methyl Blue and Ponceau Fuchsin rows of rgb_from_bpx), used when stain_vectors is not given and recorded as stain_vectors_source = preset_default with a citation; psr_brightfield keeps requiring user vectors. trichrome_custom's exclusion stain is now ponceau_fuchsin (the published matrix has no nuclear stain). white stays required for custom_od. luminance_inverted, lab_a, psr_polarized and trichrome_green accept an optional white: the RGB is rescaled to clip(rint(I * 255 / white), 0, 255) as uint8 before scikit-image, with the clipped fraction recorded. Without white the outputs are bitwise unchanged; white = 255 is the identity. The preset registry moves to stain_presets.py (file length). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW
… plan N69) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW
StainPreset.image_name (recorded in the provenance as image_name) names the image a preset makes, as CellProfiler's UnmixColors names its outputs: he_eosin -> Eosin, he_hematoxylin -> Hematoxylin, trichrome_blue -> MethylBlue, psr_brightfield -> SiriusRed, and so on; unique tokens, which consumers use as the derived channel's name in feature columns (owner decision: CellProfiler-style names). Contract §3.4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW
3a7af3e) - check_white and stain vectors refuse a masked array, or a list holding a masked value, before np.asarray could drop the mask (an all-false mask too). - Region helpers refuse a masked region array. - stain_vectors_from_regions refuses a reference region holding a pixel with a channel at 0 (its OD would be taken at the floor and bend the vector); zeros outside the regions are allowed. - MAX_CONDITION documented as a heuristic norm-wise threshold, not a per-map error guarantee; psr_polarized's green-weighted greyscale bias documented; white_from_region's median-255 refusal says a declared white of 255 is accepted. - Regression tests through to_scalar and the public region helpers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW
External review of
|
| Finding | Fix |
|---|---|
B1: masked white or stain-vector components unmasked by np.asarray |
Refused (TypeError): a masked array, an all-false mask, or a list holding np.ma.masked. Tested through to_scalar |
| B2: masked boolean regions accepted | Refused in both region helpers. Tested with one masked selected pixel next to valid ones |
| B3: reference regions with zero channels silently floored | stain_vectors_from_regions refuses a region holding a pixel with a channel at 0. Zeros outside the regions are allowed. Both cases are tested |
FILE 1: psr_polarized greyscale bias |
Contract §3.4 documents rgb2gray's weights (0.2125 R + 0.7154 G + 0.0721 B), which favour green, and their effect on fibres of different colours |
| FILE 2: condition-number claim | MAX_CONDITION is now described as a heuristic norm-wise threshold, not a per-map or per-pixel error guarantee. The measured value stays in the provenance |
| C7 suggestion | white_from_region's refusal of a median of 255 now says an unsaturated white cannot be inferred from that region, and that a declared white of 255 is accepted |
The reviewer confirmed cond = cot(θ/2) for the acute angle, and that OD @ inv(M) is correct for stain vectors stored as rows. The Beer–Lambert caveat (scanner transfer function) stays documented.
Also new since the review: 734c808 adds StainPreset.image_name, a CellProfiler-style name per preset (he_eosin → Eosin), recorded in the provenance. Owner decision on column naming.
Full check at 8cc45da: 2023 passed, 2 skipped, exit 0.
Generated by Claude Code
… (review of 8cc45da) - Contract §3.4: he_custom is deliberately not the three-stain HED conversion (cross-product residual instead of DAB, log10 OD against the declared white, no clipping); the wrong "completion of rgb_from_hed" sentence is corrected; the declared-white path's uint8 re-quantisation (at most half a grey level) is documented. - Tests: literal published vectors independent of scikit-image; he_custom on a reference pixel computed by hand; its difference from scikit-image's HED eosin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW
External review of
|
pyproject 0.6.0; CHANGELOG 'Unreleased' becomes v0.6.0 (2026-11-13), an additive release (stain presets, optional extra histology; nothing that existed changes) with the known limitations the release review asked to state; README release line, install command and a stain-presets bullet; plan status; AGENTS.md decision range N1-N69 (release review FILE 1). No code change. Checked: full check 2026 passed; a clean NumPy-only install reports 0.6.0, runs he_custom and refuses he_eosin naming the histology extra; a clean [histology] install runs he_eosin (scikit-image 0.26.0). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW
Release review (prompt I, at
|
Refs #94
Summary
The P0 stain presets (
rgb_to_scalar, H&E first, plan N67), plus the preset catalogue the owner approved in #94 (comment) (plan N68, contract §3.4).to_scalar(pixels, *, axes, preset, white=None, stain_vectors=None, polarizer=None) -> ScalarResultis the general entry point.rgb_to_scalarcalls it with no optional arguments. Outputs ofhe_eosin,he_hematoxylin,luminance_invertedandlab_aare bitwise identical to b152472: same SHA-256 on a 64x80 random image in YXC and CYX, and a regression test compares them with the direct scikit-image calls.StainPresetgainsmodality, read-only consumerhints(polarity,scaling,mask,measures),needs,inverted,target_stainandexclusion_stain. The hints are recorded in the provenance and are not applied here.PRESETS_BY_MODALITYgives a read-only listing by modality.custom_odis written here in NumPy, without scikit-image (optical_density.py):OD = -log10(max(I, 1) / white), using a declaredwhitein (0, 255]."residual").C = OD @ inv(M). Negative concentrations are kept, not clipped.negative_fraction,above_white_fractionandfloored_fraction.white_from_region(median over the background) andstain_vectors_from_regions(normalised mean OD per labelled region, as in TWOMBLI).optical_density.pyandscalar_inputs.pyare added to the layer map. Every file stays at or below 600 lines.rgb.py,NormalizationParametersandplane_histograms.csvare unchanged.he_eosinhed_from_rgbcomp. 1he_hematoxylinhed_from_rgbcomp. 0luminance_inverted1 - rgb2graylab_afluorescenceshgpsr_brightfieldpsrcounterstainpsr_polarizedrgb2gray(not inverted)trichrome_bluebpx_from_rgbcomp. 0 (Methyl Blue)trichrome_greenhe_customeosinhematoxylintrichrome_customcollagenponceau_fuchsin(wasnuclei, N69)For
psr_polarized,polarizermust be"circular"or"linear". The provenance hints recordorientation_valid = (polarizer == "circular").Tests
tests/test_stain.pycovers P0 and the regression.tests/test_stain_catalogue.pycovers the catalogue.tests/test_optical_density.pycovers the OD maths.tests/stain_simulation.pymakes the synthetic Beer–Lambert images.I = white * 10^(-C @ M)with white (240, 235, 245).sum_c log10(r_c/(r_c-0.5)) * |inv(M)[c,j]|. This runs for everycustom_odpreset, throughto_scalarwith CYX input.cond = cot(θ/2)for two vectors; scale invariance of the vectors.white: out of range, wrong shape or type;whiteorstain_vectors; missing or invalidpolarizer; unused arguments;white_from_regionreturns the background median.stain_vectors_from_regionsrecovers the generating vectors (angle under 0.2–0.5 degrees), and unmixing with them separates the regions.psr_polarizedequalsrgb2gray;trichrome_blueequalsseparate_stains(bpx)[..., 0], andzero_fractionis reported;trichrome_greenequals-rgb2lab[..., 1].float32_exact).custom_odwithskimageblocked, and checks that the other presets raiseImportError.Check results (at 30bab3d, which has the same code as beadf62 plus docs and CI)
python tools/check.pyin a venv with.[dev,histology](scikit-image 0.26.0): exit 0. 1955 passed, 2 skipped. The two skips aretests/test_rgb.py, because csbdeep is not installed..[dev]only (no scikit-image),pytest tests/test_stain.py tests/test_stain_catalogue.py tests/test_optical_density.py: 162 passed, 41 skipped. Every skip is a test that needs scikit-image. Exit 0.Follow-up: default stain vectors and declared white (owner decisions of 13 November 2026, plan N69)
Implements #94 (comment) (commits 32c48d7, ee72fe3).
default_stain_vectors,default_stain_vectors_citation), copied into the code (the presets do not need scikit-image):he_custom:hematoxylin(0.65, 0.70, 0.29) andeosin(0.07, 0.99, 0.11), Ruifrok & Johnston 2001, rows 0 and 1 ofskimage.color.rgb_from_hed.trichrome_custom:collagen= Methyl Blue (0.7995107, 0.5913521, 0.10528667) andponceau_fuchsin(0.09997159, 0.73738605, 0.6680326), rows 0 and 1 ofrgb_from_bpx(Fiji's Masson Trichrome).psr_brightfield: no default;stain_vectorsstays required.stain_vectorsoverrides the defaults. The provenance recordsstain_vectors_source("preset_default"or"user") andstain_vectors_citation.whitestays required for everycustom_odpreset.trichrome_customis nowponceau_fuchsin(it wasnuclei). The bpx matrix has no nuclear stain, so a default namednucleiwould be wrong; returning Ponceau Fuchsin keeps the two-staincustom_oddesign without a special case. The mask hint staysexclude_nuclei: the nuclear mask is built in preprocessing, not taken from this map. User vectors for this preset now namecollagenandponceau_fuchsin(breaking for the draft only; preset version stays 1 since it is unreleased).whitefor the colour projections:luminance_inverted,lab_a,psr_polarizedandtrichrome_greenaccept an optionalwhite, checked likecustom_od's. The RGB is rescaled toclip(rint(I * 255 / white), 0, 255)as uint8 (float64,rinthalf to even) before scikit-image. Provenance:white,white_applied,white_rescale,white_clipped_fraction(a channel above 255 after rounding) andabove_white_fraction. Withoutwhitethe outputs are bitwise unchanged, andwhite = (255, 255, 255)is the identity.he_eosin,he_hematoxylinortrichrome_blue:separate_stainsfixes white at 255, andhe_customandtrichrome_customare the deconvolution path for a declared white. These presets still refusewhite. Passthrough presets refuse it too.StainPreset.accepts: arguments a preset uses when given but does not require. Everything outsideneeds | acceptsis still refused.stain_presets.py, re-exported bystain.py(stain.pywould have exceeded the 600-line rule).tests/test_stain_defaults.py, plus updates totest_stain_catalogue.py):rgb_from_bpxrow 2 is their cross product);he_customandtrichrome_custom, within the uint8 rounding bound;test_stain_defaults.py.python tools/check.pywith.[dev,histology](scikit-image 0.26.0, Python 3.11): exit 0,2016 passed, 2 skipped(csbdeep). Without scikit-image, the four stain/OD test files: 204 passed, 60 skipped (every skip needs scikit-image).Not in this PR
fixed_rangestaysNone; it needs calibration on a reference set.🤖 Generated with Claude Code
https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW