Multi-slide pooled fit_stain_reference - #1209
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1209 +/- ##
==========================================
+ Coverage 77.11% 77.23% +0.11%
==========================================
Files 63 63
Lines 9418 9466 +48
Branches 1585 1597 +12
==========================================
+ Hits 7263 7311 +48
Misses 1554 1554
Partials 601 601
🚀 New features to boost your workflow:
|
| def test_validation(self, image_key, tissue_mask_key, match: str) -> None: | ||
| sdata, _ = self._cohort(n=2) | ||
| with pytest.raises(ValueError, match=match): | ||
| fit_stain_reference(sdata, image_key, method="macenko", tissue_mask_key=tissue_mask_key) | ||
|
|
||
| def test_list_mask_with_str_image_raises(self) -> None: | ||
| sdata, _ = self._cohort(n=1) | ||
| with pytest.raises(ValueError, match="single `tissue_mask_key`"): | ||
| fit_stain_reference(sdata, "img0", method="macenko", tissue_mask_key=["img0_tissue"]) |
There was a problem hiding this comment.
can these tests be merged into a parameterized one?
There was a problem hiding this comment.
Folded into two parametrized tests: test_pooled_fit (runs + pool-of-one == single, exact except vahadane where NMF is not bit-reproducible even seeded) and test_validation (all raise cases as one table). test_order_matched_non_convention_masks stays separate since it asserts values, not an exception.
| if not image_keys: | ||
| raise ValueError("`image_key` list is empty; pass at least one image key.") | ||
| if len(set(image_keys)) != len(image_keys): | ||
| raise ValueError("`image_key` list has duplicate keys.") |
There was a problem hiding this comment.
Can't we use _unique_order_preserving here somehow?
There was a problem hiding this comment.
Done — duplicate check now goes through _unique_order_preserving.
There was a problem hiding this comment.
There are lines duplicated here:
You should normalize to [str] if it's str. Then you'd do this procedure for free on both
das, masks = [], []
for k, mk in zip(image_keys, mask_keys, strict=True):
da = _resolve_image(sdata, k, scale, prefer="coarsest")
validate_rgb_range(da)
das.append(da)
masks.append(_resolve_tissue_bool_mask(sdata, k, da, mk))
dtypes = {str(da.dtype) for da in das}
if len(dtypes) != 1:
raise ValueError(f"pooled images must share a dtype; got {sorted(dtypes)}.") if method == "reinhard":
return fit_reinhard_pooled(das, masks, params, image_keys)
bg = default_white_point(das[0]) if white_point is None else np.asarray(white_point, np.float64)
reference = RUIFROK_HE if canonical_reference is None else dict(canonical_reference)
return fit_decomposition_pooled(
das, masks, image_keys, method, params, bg, reference=reference, max_angle_deg=max_angle_deg
)
I'd also do
def fit_reinhard_pooled(
das: list[xr.DataArray],
masks: list[np.ndarray | None],
params: ReinhardParams,
image_keys: list[str | None],
) -> StainReference:
"""Fit Reinhard stats from the pooled tissue Lab pixels of one or more slides."""
cols = [_tissue_lab_pixels(da, params, m, image_key=k) for da, m, k in zip(das, masks, image_keys, strict=True)]
return _reinhard_from_pixels(np.concatenate(cols, axis=1))
def fit_reinhard(
image_rgb: xr.DataArray, params: ReinhardParams, *, tissue_mask: np.ndarray | None = None
) -> StainReference:
"""Single-image Reinhard fit — a pool of one."""
return fit_reinhard_pooled([image_rgb], [tissue_mask], params, [None])
or just have fit_reainhard(str|list[str]) tbh
There was a problem hiding this comment.
Done in 3a026a5 — image_key/tissue_mask_key are normalised to lists at the top of fit_stain_reference, one pipeline for both; _fit_pooled and both *_pooled functions are gone. fit_reinhard/fit_decomposition take one image or a sequence (pool of one), per your sketch with _tissue_lab_pixels.
selmanozleyen
left a comment
There was a problem hiding this comment.
please do the changes I mentioned. Overall writing the code as if all the inputs are [str] is a good idea and normalizing str to [str] will save duplicate computation and code
`fit_stain_reference` now accepts `image_key: str | list[str]`. A list pools
the tissue pixels of several same-dtype images into one fit (decomposition
pools tissue OD, Reinhard pools tissue Lab pixels) - one reference for a cohort
held in a single SpatialData. `tissue_mask_key` accepts an order-matched list
(or None for the `{key}_tissue` convention). A blank slide raises, named; no
silent skip. Single-image fit is unchanged.
KISS replacement for the old cohort-aggregation + persistence design: no new
module, no provenance fields, no serialization format (references are plain
Python objects users serialize themselves).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address review: normalise `image_key`/`tissue_mask_key` to lists up front so a single image is a pool of one and both cases run through one pipeline. Drops `_fit_pooled`, `fit_reinhard_pooled` and `fit_decomposition_pooled`; `fit_reinhard`/`fit_decomposition` now accept one image or a sequence. Duplicate-key check reuses `_unique_order_preserving`. Reinhard error names the image. Pooled-fit tests folded into two parametrized cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011JMbY8asahxaytthjvCKDv
2b517d6 to
3a026a5
Compare
selmanozleyen
left a comment
There was a problem hiding this comment.
I like to first assess this before going on with the experimental functions: #1279.
It's about the design and the structure. I can look into this PR meanwhile separately but I'd prefer to do after
fit_stain_referencenow acceptsimage_key: str | list[str]— a list pools several images' tissue pixels into one fit (one reference for a cohort in one SpatialData).tissue_mask_keytakes an order-matched list (orNonefor the{key}_tissueconvention). Single-image fit unchanged.