Skip to content

Commit

Permalink
Merge pull request #93 from sagar87/feature/8bit_conversion
Browse files Browse the repository at this point in the history
Feature/8bit conversion
  • Loading branch information
MeyerBender authored Sep 9, 2024
2 parents abb288c + b54d2b8 commit e700179
Show file tree
Hide file tree
Showing 7 changed files with 224 additions and 31 deletions.
65 changes: 46 additions & 19 deletions docs/notebooks/Plotting.ipynb

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "spatialproteomics"
packages = [
{ include = "spatialproteomics" },
]
version = "0.5.4"
version = "0.5.5"
description = "spatialproteomics provides tools for the analysis of highly multiplexed immunofluorescence data"
readme = "README.md"
authors = ["Harald Vohringer", "Matthias Meyer-Bender"]
Expand Down
47 changes: 37 additions & 10 deletions spatialproteomics/pl/plot.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import List, Optional, Union

import matplotlib.colors as mcolors
import matplotlib.pyplot as plt
import numpy as np
import xarray as xr
Expand Down Expand Up @@ -230,6 +231,7 @@ def show(
legend_image: bool = True,
legend_segmentation: bool = True,
legend_label: bool = True,
background: str = "black",
downsample: int = 1,
legend_kwargs: dict = {"framealpha": 1},
segmentation_kwargs: dict = {},
Expand All @@ -246,6 +248,7 @@ def show(
- legend_image (bool): Whether to show the channel legend. Default is True.
- legend_segmentation (bool): Whether to show the segmentation legend (only becomes relevant when dealing with multiple segmentation layers, e. g. when using cellpose). Default is False.
- legend_label (bool): Whether to show the label legend. Default is True.
- background (str): Background color of the image. Default is "black".
- downsample (int): Downsample factor for the image. Default is 1 (no downsampling).
- legend_kwargs (dict): Keyword arguments for configuring the legend. Default is {"framealpha": 1}.
- segmentation_kwargs (dict): Keyword arguments for rendering the segmentation. Default is {}.
Expand All @@ -269,19 +272,43 @@ def show(
[render_image, render_labels, render_segmentation]
), "No rendering element specified. Please set at least one of 'render_image', 'render_labels', or 'render_segmentation' to True."

# copying the input object to avoid colorizing the original object in place
# store a copy of the original object to avoid overwriting it
obj = self._obj.copy()
if Layers.PLOT not in self._obj and render_image:
# if there are more than 20 channels, only the first one is plotted
if self._obj.sizes[Dims.CHANNELS] > 20:
if Layers.PLOT not in self._obj:
if render_image:
# if there are more than 20 channels, only the first one is plotted
if self._obj.sizes[Dims.CHANNELS] > 20:
logger.warning(
"More than 20 channels are present in the image. Plotting first channel only. You can subset the channels via pp.[['channel1', 'channel2', ...]] or specify your own color scheme by calling pp.colorize() before calling pl.show()."
)
channel = str(self._obj.coords[Dims.CHANNELS].values[0])
obj = self._obj.pp[channel].pl.colorize(colors=["white"], background=background)
# if there are less than 20 channels, all are plotted
else:
obj = self._obj.pl.colorize(background=background)
else:
# if no image is rendered, we need to add a plot layer with a background color
rgba_background = mcolors.to_rgba(background)
colored = np.ones((self._obj.sizes[Dims.Y], self._obj.sizes[Dims.X], 4)) * rgba_background

da = xr.DataArray(
colored,
coords=[
self._obj.coords[Dims.Y],
self._obj.coords[Dims.X],
["r", "g", "b", "a"],
],
dims=[Dims.Y, Dims.X, Dims.RGBA],
name=Layers.PLOT,
)

obj = xr.merge([self._obj, da])
else:
# if a plot already exists, but the user tries to set a background color, we raise a warning
if background != "black":
logger.warning(
"More than 20 channels are present in the image. Plotting first channel only. You can subset the channels via pp.[['channel1', 'channel2', ...]] or specify your own color scheme by calling pp.colorize() before calling pl.show()."
"The background color is set during the first color pass. If you called pl.colorize() before pl.show(), please set the background color there instead using pl.colorize(background='your_color')."
)
channel = str(self._obj.coords[Dims.CHANNELS].values[0])
obj = self._obj.pp[channel].pl.colorize(colors=["white"])
# if there are less than 20 channels, all are plotted
else:
obj = self._obj.pl.colorize()

if render_labels:
obj = obj.pl.render_labels(**label_kwargs)
Expand Down
41 changes: 41 additions & 0 deletions spatialproteomics/pp/preprocessing.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from ..constants import COLORS, Attrs, Dims, Features, Labels, Layers, Props
from ..la.utils import _format_labels
from .utils import (
_convert_to_8bit,
_get_disconnected_cell,
_merge_segmentation,
_normalize,
Expand Down Expand Up @@ -1523,3 +1524,43 @@ def mask_cells(self, mask_key: str = Layers.MASK, segmentation_key=Layers.SEGMEN

# adding the new filtered and relabeled segmentation
return xr.merge([obj, da])

def convert_to_8bit(self, key: str = Layers.IMAGE, key_added: str = Layers.IMAGE):
"""
Convert the image to 8-bit.
Parameters:
key (str): The key of the image layer in the object. Default is Layers.IMAGE.
key_added (str): The key to assign to the 8-bit image in the object. Default is Layers.IMAGE, which overwrites the original image.
Returns:
xr.Dataset: The object with the image converted to 8-bit.
"""
# checking if the key exists
assert key in self._obj, f"The key {key} does not exist in the object."

# getting the image from the object
image = self._obj[key].values

# converting the image to 8-bit
image_8bit = _convert_to_8bit(image)

# removing the old image from the object
if key == key_added:
obj = self._obj.drop_vars(key)
else:
obj = self._obj.copy()

# assigning the 8-bit image to the object
# special case: if the image is 2D, we need to add a channel dimension
if len(image_8bit.shape) == 2:
image_8bit = np.expand_dims(image_8bit, axis=0)

da = xr.DataArray(
image_8bit,
coords=[self._obj.coords[Dims.CHANNELS], self._obj.coords[Dims.Y], self._obj.coords[Dims.X]],
dims=[Dims.CHANNELS, Dims.Y, Dims.X],
name=key_added,
)

return xr.merge([obj, da])
48 changes: 48 additions & 0 deletions spatialproteomics/pp/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,3 +344,51 @@ def _get_disconnected_cell(segmentation: np.ndarray) -> int:
_, num_features = scipy.ndimage.label(binary_mask)
if num_features != 1:
return cell


def _convert_to_8bit(image):
"""
Convert an image to 8-bit format.
Parameters:
----------
image : np.ndarray
The input image.
Returns:
-------
np.ndarray
The 8-bit image.
"""
# if the image is already uint8, nothing happens
if image.dtype == np.uint8:
return image

# checking that there are no negative values in the image
assert np.min(image) >= 0, "The image contains negative values. Please make sure that the image is non-negative."

# if the image is of type float, we check if the values are already in the range [0, 1]
if image.dtype == np.float32 or image.dtype == np.float64:
if np.max(image) <= 1:
return (image * 255).astype(np.uint8)
else:
raise ValueError(
"The image is of type float, but the values are not in the range [0, 1]. Please normalize the image first."
)
# checking if the integers are signed
elif image.dtype == np.uint16:
assert (
np.max(image) <= 65535
), "The image contains values larger than 65535. Please make sure that the image is in the range [0, 65535]."
# normalizing to the highest possible value
return (image / 65535 * 255).astype(np.uint8)
elif image.dtype == np.uint32:
assert (
np.max(image) <= 4294967295
), "The image contains values larger than 4294967295. Please make sure that the image is in the range [0, 4294967295]."
# normalizing to the highest possible value
return (image / 4294967295 * 255).astype(np.uint8)
else:
raise ValueError(
f"Could not convert image of type {image.dtype} to 8-bit. Please make sure that the image is of type uint8, uint16, uint32, float32, or float64. If the image is of type float, make sure that the values are in the range [0, 1]."
)
2 changes: 1 addition & 1 deletion spatialproteomics/tl/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ def astir(
# raise an error if the image is of anything other than uint8
if self._obj[Layers.IMAGE].dtype != "uint8":
logger.warning(
"The image is not of type uint8, which is required for astir to work properly. Use the dtype argument in add_quantification() to convert the image to uint8. If you applied operations such as filtering, you may ignore this warning."
"The image is not of type uint8, which is required for astir to work properly. Use pp.convert_to_8bit() to convert the image to uint8. If you applied operations such as filtering, you may ignore this warning."
)

# warn the user if the input dict has the wrong format
Expand Down
50 changes: 50 additions & 0 deletions tests/pp/test_convert_to_8bit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import numpy as np
import pytest


def test_convert_to_8bit(dataset):
# this is already in 8 bit, so should be fine
converted = dataset.pp.convert_to_8bit(key_added="_converted_image")
assert converted["_converted_image"].values.dtype == np.uint8


def test_convert_16_to_8bit(dataset):
img_size = (dataset.sizes["x"], dataset.sizes["y"])
ds = dataset.pp.add_layer(np.random.randint(0, 2**16, img_size).astype(np.uint16), "_16bit_image")
ds = ds.pp.convert_to_8bit("_16bit_image", key_added="_converted_image")
assert ds["_converted_image"].values.dtype == np.uint8


def test_convert_32_to_8bit(dataset):
img_size = (dataset.sizes["x"], dataset.sizes["y"])
ds = dataset.pp.add_layer(np.random.randint(0, 2**32, img_size).astype(np.uint32), "_32bit_image")
ds = ds.pp.convert_to_8bit("_32bit_image", key_added="_converted_image")
assert ds["_converted_image"].values.dtype == np.uint8


def test_convert_float_to_8bit(dataset):
img_size = (dataset.sizes["x"], dataset.sizes["y"])
ds = dataset.pp.add_layer(np.random.rand(*img_size).astype(np.float64), "_float_image")
ds = ds.pp.convert_to_8bit("_float_image", key_added="_converted_image")
assert ds["_converted_image"].values.dtype == np.uint8


def test_convert_float_with_values_larger_than_one(dataset):
img_size = (dataset.sizes["x"], dataset.sizes["y"])
ds = dataset.pp.add_layer(np.random.rand(*img_size) * 2, "_float_image")
with pytest.raises(ValueError, match="The image is of type float, but the values are not in the range"):
ds.pp.convert_to_8bit("_float_image", key_added="_converted_image")


def test_convert_float_with_values_smaller_than_zero(dataset):
img_size = (dataset.sizes["x"], dataset.sizes["y"])
ds = dataset.pp.add_layer(np.random.rand(*img_size) - 1, "_float_image")
with pytest.raises(
AssertionError, match="The image contains negative values. Please make sure that the image is non-negative."
):
ds.pp.convert_to_8bit("_float_image", key_added="_converted_image")


def test_convert_to_8bit_key_does_not_exist(dataset):
with pytest.raises(AssertionError, match="The key non_existing_key does not exist in the object."):
dataset.pp.convert_to_8bit("non_existing_key", key_added="_converted_image")

0 comments on commit e700179

Please sign in to comment.