def test_custom_default_name(self, small_cont: ImageContainer): custom_features = small_cont.features_custom(np.mean, layer="image", channels=[0]) summary_features = small_cont.features_summary("image", feature_name="summary", channels=[0]) assert len(custom_features) == 1 assert f"{np.mean.__name__}_0" in custom_features assert custom_features[f"{np.mean.__name__}_0"] == summary_features["summary_ch-0_mean"]
def small_cont_seg() -> ImageContainer: np.random.seed(42) img = ImageContainer(np.random.randint(low=0, high=255, size=(100, 100, 3), dtype=np.uint8), layer="image") mask = np.zeros((100, 100), dtype="uint8") mask[20:30, 10:20] = 1 mask[50:60, 30:40] = 2 img.add_img(mask, layer="segmented", channel_dim="mask") return img
def test_textures_props(self, small_cont: ImageContainer, props: Sequence[str]): if not len(props): with pytest.raises(ValueError, match=r"No properties have been selected."): small_cont.features_texture("image", feature_name="foo", props=props) else: features = small_cont.features_texture("image", feature_name="foo", props=props) haystack = features.keys() for prop in props: assert any(f"{prop}_dist" in h for h in haystack), haystack
def test_textures_distances(self, small_cont: ImageContainer, distances: Sequence[int]): if not len(distances): with pytest.raises(ValueError, match=r"No distances have been selected."): small_cont.features_texture("image", feature_name="foo", distances=distances) else: features = small_cont.features_texture("image", feature_name="foo", distances=distances) haystack = features.keys() for d in distances: assert any(f"dist-{d}" in h for h in haystack), haystack
def test_textures_angles(self, small_cont: ImageContainer, angles: Sequence[float]): if not len(angles): with pytest.raises(ValueError, match=r"No angles have been selected."): small_cont.features_texture("image", feature_name="foo", angles=angles) else: features = small_cont.features_texture("image", feature_name="foo", angles=angles) haystack = features.keys() for a in angles: assert any(f"angle-{a:.2f}" in h for h in haystack), haystack
def test_summary_quantiles(self, small_cont: ImageContainer, quantiles: Tuple[float, ...]): if not len(quantiles): with pytest.raises(ValueError, match=r"No quantiles have been selected."): small_cont.features_summary("image", quantiles=quantiles, feature_name="foo", channels=(0, 1)) else: features = small_cont.features_summary("image", quantiles=quantiles, feature_name="foo", channels=(0, 1)) haystack = features.keys() assert isinstance(features, dict) for c in (0, 1): for agg in ("mean", "std"): assert f"foo_ch-{c}_{agg}" in haystack, haystack for q in quantiles: assert f"foo_ch-{c}_quantile-{q}" in haystack, haystack
def test_histogram_bins(self, small_cont: ImageContainer, bins: int): features = small_cont.features_histogram("image", bins=bins, feature_name="histogram", channels=(0,)) assert isinstance(features, dict) haystack = features.keys() for c in (0,): for b in range(bins): assert f"histogram_ch-{c}_bin-{b}" in features, haystack
def test_custom_returns_iterable(self, small_cont: ImageContainer): def dummy(_: np.ndarray) -> Tuple[int, int]: return 0, 1 features = small_cont.features_custom(dummy, layer="image", feature_name="foo") assert len(features) == 2 assert features["foo_0"] == 0 assert features["foo_1"] == 1
def test_segmentation_centroid(self, small_cont_seg: ImageContainer): features = small_cont_seg.features_segmentation( label_layer="segmented", intensity_layer=None, feature_name="foo", props=["centroid"] ) assert isinstance(features, dict) assert "foo_centroid" in features assert isinstance(features["foo_centroid"], np.ndarray) assert features["foo_centroid"].ndim == 2
def _calculate_image_features_helper( obs_ids: Sequence[str], adata: AnnData, img: ImageContainer, layer: str, features: List[ImageFeature], features_kwargs: Mapping[str, Any], queue: Optional[SigQueue] = None, **kwargs: Any, ) -> pd.DataFrame: features_list = [] for crop in img.generate_spot_crops(adata, obs_names=obs_ids, return_obs=False, as_array=False, **kwargs): if TYPE_CHECKING: assert isinstance(crop, ImageContainer) # load crop in memory to enable faster processing crop._data = crop.data.load() features_dict = {} for feature in features: feature = ImageFeature(feature) feature_kwargs = features_kwargs.get(feature.s, {}) if feature == ImageFeature.TEXTURE: res = crop.features_texture(layer=layer, **feature_kwargs) elif feature == ImageFeature.COLOR_HIST: res = crop.features_histogram(layer=layer, **feature_kwargs) elif feature == ImageFeature.SUMMARY: res = crop.features_summary(layer=layer, **feature_kwargs) elif feature == ImageFeature.SEGMENTATION: res = crop.features_segmentation(intensity_layer=layer, **feature_kwargs) elif feature == ImageFeature.CUSTOM: res = crop.features_custom(layer=layer, **feature_kwargs) else: # should never get here raise NotImplementedError( f"Feature `{feature}` is not yet implemented.") features_dict.update(res) features_list.append(features_dict) if queue is not None: queue.put(Signal.UPDATE) if queue is not None: queue.put(Signal.FINISH) return pd.DataFrame(features_list, index=list(obs_ids))
def test_segmentation_props(self, small_cont_seg: ImageContainer, props: Sequence[str]): if not len(props): with pytest.raises(ValueError, match=r"No properties have been selected."): small_cont_seg.features_segmentation( label_layer="segmented", intensity_layer="image", feature_name="foo", props=props ) else: features = small_cont_seg.features_segmentation( label_layer="segmented", intensity_layer="image", feature_name="foo", props=props, channels=[0] ) haystack = features.keys() int_props = [p for p in props if "intensity" in props] no_int_props = [p for p in props if "intensity" not in props] for p in no_int_props: assert any(f"{p}_mean" in h for h in haystack), haystack assert any(f"{p}_std" in h for h in haystack), haystack for p in int_props: assert any(f"ch-0_{p}_mean" in h for h in haystack), haystack assert any(f"ch-0_{p}_std" in h for h in haystack), haystack
def napari_cont() -> ImageContainer: return ImageContainer("tests/_data/test_img.jpg", layer="V1_Adult_Mouse_Brain")
def cont_dot() -> ImageContainer: ys, xs = 100, 200 img_orig = np.zeros((ys, xs, 10), dtype=np.uint8) img_orig[20, 50, :] = range(10, 20) # put a dot at y 20, x 50 return ImageContainer(img_orig, layer="image_0")
def small_cont_1c() -> ImageContainer: np.random.seed(42) return ImageContainer(np.random.normal(size=(100, 100, 1)) + 1, layer="image")
def cont() -> ImageContainer: return ImageContainer("tests/_data/test_img.jpg")
def small_cont() -> ImageContainer: np.random.seed(42) return ImageContainer(np.random.uniform(size=(100, 100, 3), low=0, high=1), layer="image")
def segment( img: ImageContainer, layer: Optional[str] = None, method: Union[str, Callable[..., np.ndarray]] = "watershed", channel: int = 0, size: Optional[Union[int, Tuple[int, int]]] = None, layer_added: Optional[str] = None, copy: bool = False, show_progress_bar: bool = True, n_jobs: Optional[int] = None, backend: str = "loky", **kwargs: Any, ) -> Optional[ImageContainer]: """ Segment an image. If ``size`` is defined, iterate over crops of that size and segment those. Recommended for large images. Parameters ---------- %(img_container)s %(img_layer)s %(seg_blob.parameters)s - `{m.WATERSHED.s!r}` - :func:`skimage.segmentation.watershed`. %(custom_fn)s channel Channel index to use for segmentation. %(size)s %(layer_added)s If `None`, use ``'segmented_{{model}}'``. thresh Threshold for creation of masked image. The areas to segment should be contained in this mask. If `None`, it is determined by `Otsu's method <https://en.wikipedia.org/wiki/Otsu%27s_method>`_. Only used if ``method = {m.WATERSHED.s!r}``. geq Treat ``thresh`` as upper or lower bound for defining areas to segment. If ``geq = True``, mask is defined as ``mask = arr >= thresh``, meaning high values in ``arr`` denote areas to segment. invert Whether to segment an inverted array. Only used if ``method`` is one of :mod:`skimage` blob methods. %(copy_cont)s %(segment_kwargs)s %(parallelize)s kwargs Keyword arguments for ``method``. Returns ------- If ``copy = True``, returns a new container with the segmented image in ``'{{layer_added}}'``. Otherwise, modifies the ``img`` with the following key: - :class:`squidpy.im.ImageContainer` ``['{{layer_added}}']`` - the segmented image. """ layer = img._get_layer(layer) channel_dim = img[layer].dims[-1] kind = SegmentationBackend.CUSTOM if callable(method) else SegmentationBackend(method) layer_new = Key.img.segment(kind, layer_added=layer_added) if kind in (SegmentationBackend.LOG, SegmentationBackend.DOG, SegmentationBackend.DOH): segmentation_model: SegmentationModel = SegmentationBlob(model=kind) elif kind == SegmentationBackend.WATERSHED: segmentation_model = SegmentationWatershed() elif kind == SegmentationBackend.CUSTOM: if TYPE_CHECKING: assert callable(method) segmentation_model = SegmentationCustom(func=method) else: raise NotImplementedError(f"Model `{kind}` is not yet implemented.") n_jobs = _get_n_cores(n_jobs) crops: List[ImageContainer] = list(img.generate_equal_crops(size=size, as_array=False)) start = logg.info(f"Segmenting `{len(crops)}` crops using `{segmentation_model}` and `{n_jobs}` core(s)") crops: List[ImageContainer] = parallelize( # type: ignore[no-redef] _segment, collection=crops, unit="crop", extractor=lambda res: list(chain.from_iterable(res)), n_jobs=n_jobs, backend=backend, show_progress_bar=show_progress_bar and len(crops) > 1, )(model=segmentation_model, layer=layer, layer_new=layer_new, channel=channel, **kwargs) if isinstance(segmentation_model, SegmentationWatershed): # By convention, segments are numbered from 1..number of segments within each crop. # Next, we have to account for that before merging the crops so that segments are not confused. # TODO use overlapping crops to not create confusion at boundaries counter = 0 for crop in crops: data = crop[layer_new].data data[data > 0] += counter counter += np.max(crop[layer_new].data) res: ImageContainer = ImageContainer.uncrop(crops, shape=img.shape) res._data = res.data.rename({channel_dim: f"{channel_dim}:{channel}"}) logg.info("Finish", time=start) if copy: return res img.add_img(res, layer=layer_new, copy=False, channel_dim=res[layer_new].dims[-1])
def _(self, img: ImageContainer, layer: str, channel: int = 0, **kwargs: Any) -> ImageContainer: # simple inversion of control, we rename the channel dim later return img.apply(self.segment, layer=layer, channel=channel, **kwargs)
def test_segmentation_label(self, small_cont_seg: ImageContainer): features = small_cont_seg.features_segmentation("image", feature_name="foo", props=["label"]) assert isinstance(features, dict) assert "foo_label" in features assert features["foo_label"] == 254
def process( img: ImageContainer, layer: Optional[str] = None, method: Union[str, Callable[..., np.ndarray]] = "smooth", size: Optional[Tuple[int, int]] = None, layer_added: Optional[str] = None, channel_dim: Optional[str] = None, copy: bool = False, **kwargs: Any, ) -> Optional[ImageContainer]: """ Process an image by applying a transformation. Note that crop-wise processing can save memory but may change behavior of cropping if global statistics are used. Leave ``size = None`` in order to process the full image in one go. Parameters ---------- %(img_container)s %(img_layer)s method Processing method to use. Valid options are: - `{p.SMOOTH.s!r}` - :func:`skimage.filters.gaussian`. - `{p.GRAY.s!r}` - :func:`skimage.color.rgb2gray`. %(custom_fn)s %(size)s %(layer_added)s If `None`, use ``'{{layer}}_{{method}}'``. channel_dim Name of the channel dimension of the new image layer. Default is the same as the original, if the processing function does not change the number of channels, and ``'{{channel}}_{{processing}}'`` otherwise. %(copy_cont)s kwargs Keyword arguments for ``method``. Returns ------- If ``copy = True``, returns a new container with the processed image in ``'{{layer_added}}'``. Otherwise, modifies the ``img`` with the following key: - :class:`squidpy.im.ImageContainer` ``['{{layer_added}}']`` - the processed image. Raises ------ NotImplementedError If ``method`` has not been implemented. """ layer = img._get_layer(layer) method = Processing(method) if isinstance( method, (str, Processing)) else method # type: ignore[assignment] if channel_dim is None: channel_dim = img[layer].dims[-1] layer_new = Key.img.process(method, layer, layer_added=layer_added) if callable(method): callback = method elif method == Processing.SMOOTH: # type: ignore[comparison-overlap] callback = partial(skimage.filters.gaussian, multichannel=True) elif method == Processing.GRAY: # type: ignore[comparison-overlap] if img[layer].shape[-1] != 3: raise ValueError( f"Expected channel dimension to be `3`, found `{img[layer].shape[-1]}`." ) callback = skimage.color.rgb2gray else: raise NotImplementedError(f"Method `{method}` is not yet implemented.") start = logg.info(f"Processing image using `{method}` method") crops = [ crop.apply(callback, layer=layer, copy=True, **kwargs) for crop in img.generate_equal_crops(size=size) ] res: ImageContainer = ImageContainer.uncrop(crops=crops, shape=img.shape) # if the method changes the number of channels if res[layer].shape[-1] != img[layer].shape[-1]: modifier = "_".join( layer_new.split("_")[1:]) if layer_added is None else layer_added channel_dim = f"{channel_dim}_{modifier}" res._data = res.data.rename({ res[layer].dims[-1]: channel_dim }).rename_vars({layer: layer_new}) logg.info("Finish", time=start) if copy: return res img.add_img(img=res, layer=layer_new, channel_dim=channel_dim)
def test_segmentation_invalid_props(self, small_cont: ImageContainer): with pytest.raises(ValueError, match=r"Invalid property `foobar`. Valid properties are"): small_cont.features_segmentation("image", feature_name="foo", props=["foobar"])
def test_container_empty(self): cont = ImageContainer() with pytest.raises(ValueError, match=r"The container is empty."): cont.features_summary("image")
def calculate_image_features( adata: AnnData, img: ImageContainer, layer: Optional[str] = None, features: Union[str, Sequence[str]] = ImageFeature.SUMMARY.s, features_kwargs: Mapping[str, Mapping[str, Any]] = MappingProxyType({}), key_added: str = "img_features", copy: bool = False, n_jobs: Optional[int] = None, backend: str = "loky", show_progress_bar: bool = True, **kwargs: Any, ) -> Optional[pd.DataFrame]: """ Calculate image features for all observations in ``adata``. Parameters ---------- %(adata)s %(img_container)s %(img_layer)s features Features to be calculated. Valid options are: - `{f.TEXTURE.s!r}` - summary stats based on repeating patterns :meth:`squidpy.im.ImageContainer.features_texture`. - `{f.SUMMARY.s!r}` - summary stats of each image channel :meth:`squidpy.im.ImageContainer.features_summary`. - `{f.COLOR_HIST.s!r}` - counts in bins of image channel's histogram :meth:`squidpy.im.ImageContainer.features_histogram`. - `{f.SEGMENTATION.s!r}` - stats of a cell segmentation mask :meth:`squidpy.im.ImageContainer.features_segmentation`. - `{f.CUSTOM.s!r}` - extract features using a custom function :meth:`squidpy.im.ImageContainer.features_custom`. features_kwargs Keyword arguments for the different features that should be generated, such as ``{{ {f.TEXTURE.s!r}: {{ ... }}, ... }}``. key_added Key in :attr:`anndata.AnnData.obsm` where to store the calculated features. %(copy)s %(parallelize)s kwargs Keyword arguments for :meth:`squidpy.im.ImageContainer.generate_spot_crops`. Returns ------- If ``copy = True``, returns a :class:`panda.DataFrame` where columns correspond to the calculated features. Otherwise, modifies the ``adata`` object with the following key: - :attr:`anndata.AnnData.uns` ``['{{key_added}}']`` - the above mentioned dataframe. Raises ------ ValueError If a feature is not known. """ layer = img._get_layer(layer) if isinstance(features, (str, ImageFeature)): features = [features] features = sorted({ImageFeature(f).s for f in features}) n_jobs = _get_n_cores(n_jobs) start = logg.info( f"Calculating features `{list(features)}` using `{n_jobs}` core(s)") res = parallelize( _calculate_image_features_helper, collection=adata.obs_names, extractor=pd.concat, n_jobs=n_jobs, backend=backend, show_progress_bar=show_progress_bar, )(adata, img, layer=layer, features=features, features_kwargs=features_kwargs, **kwargs) if copy: logg.info("Finish", time=start) return res _save_data(adata, attr="obsm", key=key_added, data=res, time=start)
def test_invalid_layer(self, small_cont: ImageContainer): with pytest.raises(KeyError, match=r"Image layer `foobar` not found in"): small_cont.features_summary("foobar")
def test_invalid_channels(self, small_cont: ImageContainer): with pytest.raises(ValueError, match=r"Channel `-1` is not in"): small_cont.features_summary("image", channels=-1)