From aaa4ee5ba399a9f1bc42547b123d5d56735fb1e5 Mon Sep 17 00:00:00 2001 From: Jayaram Kancherla Date: Fri, 31 Jan 2025 13:36:01 -0800 Subject: [PATCH 1/5] Add all the spatialImage classes --- AUTHORS.md | 1 + src/spatialexperiment/SpatialImage.py | 548 ++++++++++++++++++++++++-- 2 files changed, 519 insertions(+), 30 deletions(-) diff --git a/AUTHORS.md b/AUTHORS.md index bac89e7..5b483bb 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -1,3 +1,4 @@ # Contributors * keviny2 [kevinyang10@gmail.com](mailto:kevinyang10@gmail.com) +* Jayaram Kancherla [jayaram.kancherla@gmail.com](mailto:jayaram.kancherla@gmail.com) diff --git a/src/spatialexperiment/SpatialImage.py b/src/spatialexperiment/SpatialImage.py index 2c74807..247f84a 100644 --- a/src/spatialexperiment/SpatialImage.py +++ b/src/spatialexperiment/SpatialImage.py @@ -1,38 +1,526 @@ import os +import shutil +import tempfile +from abc import ABC, abstractmethod +from functools import lru_cache +from pathlib import Path +from typing import Optional, Tuple, Union +from urllib.parse import urlparse +from warnings import warn +import numpy as np +import requests from PIL import Image -__author__ = "keviny2" -__copyright__ = "keviny2" +__author__ = "jkanche" +__copyright__ = "jkanche" __license__ = "MIT" -# TODO: add documentation, __repr__, __str__ -class SpatialImage: - def __init__(self, x): - if isinstance(x, SpatialImage): - self.image = x.image - self.path = x.path - elif isinstance(x, Image.Image): - self.image = x - self.path = None - elif isinstance(x, str): - if x.startswith(("http://", "https://", "ftp://")): - raise ValueError("URLs are not supported for SpatialImage.") - else: - self.image = None - self.path = os.path.normpath(x) +# keeping the same name as the R classes +class VirtualSpatialImage(ABC): + """Base class for spatial images.""" + + def __init__(self, metadata: Optional[dict] = None): + self._metadata = metadata if metadata is not None else {} + + ########################### + ######>> metadata <<####### + ########################### + + def get_metadata(self) -> dict: + """ + Returns: + Dictionary of metadata for this object. + """ + return self._metadata + + def set_metadata(self, metadata: dict, in_place: bool = False) -> "VirtualSpatialImage": + """Set additional metadata. + + Args: + metadata: + New metadata for this object. + + in_place: + Whether to modify the ``VirtualSpatialImage`` in place. + + Returns: + A modified ``VirtualSpatialImage`` object, either as a copy of the original + or as a reference to the (in-place-modified) original. + """ + if not isinstance(metadata, dict): + raise TypeError(f"`metadata` must be a dictionary, provided {type(metadata)}.") + output = self._define_output(in_place) + output._metadata = metadata + return output + + @property + def metadata(self) -> dict: + """Alias for :py:attr:`~get_metadata`.""" + return self.get_metadata() + + @metadata.setter + def metadata(self, metadata: dict): + """Alias for :py:attr:`~set_metadata` with ``in_place = True``. + + As this mutates the original object, a warning is raised. + """ + warn( + "Setting property 'metadata' is an in-place operation, use 'set_metadata' instead", + UserWarning, + ) + self.set_metadata(metadata, in_place=True) + + ############################ + ######>> img props <<####### + ############################ + + def get_dimensions(self) -> Tuple[int, int]: + """Get image dimensions (width, height).""" + img = self.img_raster() + return img.size + + @property + def dimensions(self) -> Tuple[int, int]: + """Alias for :py:meth:`~get_dimensions`.""" + return self.get_dimensions() + + ############################ + ######>> img utils <<####### + ############################ + + @abstractmethod + def img_raster(self) -> Image.Image: + """Get the image as a PIL Image object.""" + pass + + def rotate_img(self, degrees: float = 90) -> "LoadedSpatialImage": + """Rotate image by specified degrees clockwise.""" + img = self.img_raster() + + # PIL rotates counter-clockwise + rotated = img.rotate(-degrees, expand=True) + return LoadedSpatialImage(rotated) + + def mirror_img(self, axis: str = "h") -> "LoadedSpatialImage": + """Mirror image horizontally or vertically.""" + img = self.img_raster() + + if axis == "h": + mirrored = img.transpose(Image.FLIP_LEFT_RIGHT) + elif axis == "v": + mirrored = img.transpose(Image.FLIP_TOP_BOTTOM) + else: + raise ValueError("axis must be 'h' or 'v'") + + return LoadedSpatialImage(mirrored) + + +def _sanitize_loaded_image(image): + if isinstance(image, np.ndarray): + _result = Image.fromarray(image) + elif isinstance(image, Image.Image): + _result = image + else: + raise TypeError("image must be PIL Image or numpy array") + + return _result + + +class LoadedSpatialImage(VirtualSpatialImage): + """Class for images loaded into memory.""" + + def __init__(self, image: Union[Image.Image, np.ndarray], metadata: Optional[dict] = None): + """Initialize the object. + + Args: + image: + Image represented as a :py:class:`~numpy.ndarray` or :py:class:`~PIL.Image.Image`. + + metadata: + Additional image metadata. Defaults to None. + """ + super().__init__(metadata=metadata) + + self._image = _sanitize_loaded_image(image) + + def _define_output(self, in_place: bool = False) -> "LoadedSpatialImage": + if in_place is True: + return self + else: + return self.__copy__() + + ######################### + ######>> Copying <<###### + ######################### + + def __deepcopy__(self, memo=None, _nil=[]): + """ + Returns: + A deep copy of the current ``LoadedSpatialImage``. + """ + from copy import deepcopy + + _img_copy = deepcopy(self._image) + _metadata_copy = deepcopy(self.metadata) + + current_class_const = type(self) + return current_class_const( + imaage=_img_copy, + metadata=_metadata_copy, + ) + + def __copy__(self): + """ + Returns: + A shallow copy of the current ``LoadedSpatialImage``. + """ + current_class_const = type(self) + return current_class_const( + image=self._image, + metadata=self._metadata, + ) + + def copy(self): + """Alias for :py:meth:`~__copy__`.""" + return self.__copy__() + + ############################ + ######>> img props <<####### + ############################ + + def get_image(self) -> Image.Image: + """Get the image as a PIL Image object.""" + + return self._image + + def set_image(self, image: Union[Image.Image, np.ndarray], in_place: bool = False) -> "LoadedSpatialImage": + """Set new image. + + Args: + image: + Image represented as a :py:class:`~numpy.ndarray` or :py:class:`~PIL.Image.Image`. + + in_place: + Whether to modify the ``LoadedSpatialImage`` in place. Defaults to False. + + """ + _out = self._define_output(in_place=in_place) + _out._image = _sanitize_loaded_image(image) + return _out + + @property + def image(self) -> Image.Image: + """Alias for :py:meth:`~get_image`.""" + return self.get_image() + + @image.setter + def image(self, image: Union[Image.Image, np.ndarray]): + """Alias for :py:attr:`~set_image` with ``in_place = True``. + + As this mutates the original object, a warning is raised. + """ + warn( + "Setting property 'image' is an in-place operation, use 'set_image' instead", + UserWarning, + ) + return self.set_image(image=image, in_place=True) + + ############################ + ######>> img utils <<####### + ############################ + + def img_raster(self) -> Image.Image: + return self._image + + +def _sanitize_path(path): + _path = Path(path).resolve() + if not _path.exists(): + raise FileNotFoundError(f"Image file not found: {path}") + + return _path + + +class StoredSpatialImage(VirtualSpatialImage): + """Class for images stored on local filesystem.""" + + def __init__(self, path: Union[str, Path], metadata: Optional[dict] = None): + """Initialize the object. + + Args: + path: + Path to the image file. + + metadata: + Additional image metadata. Defaults to None. + """ + super().__init__(metadata=metadata) + + self._path = _sanitize_path(path) + + def _define_output(self, in_place: bool = False) -> "LoadedSpatialImage": + if in_place is True: + return self + else: + return self.__copy__() + + ######################### + ######>> Copying <<###### + ######################### + + def __deepcopy__(self, memo=None, _nil=[]): + """ + Returns: + A deep copy of the current ``StoredSpatialImage``. + """ + from copy import deepcopy + + _path_copy = deepcopy(self._path) + _metadata_copy = deepcopy(self.metadata) + + current_class_const = type(self) + return current_class_const( + path=_path_copy, + metadata=_metadata_copy, + ) + + def __copy__(self): + """ + Returns: + A shallow copy of the current ``StoredSpatialImage``. + """ + current_class_const = type(self) + return current_class_const( + path=self._path, + metadata=self._metadata, + ) + + def copy(self): + """Alias for :py:meth:`~__copy__`.""" + return self.__copy__() + + ############################# + ######>> path props <<####### + ############################# + + def get_path(self) -> Path: + """Get the path to the image file.""" + return self._path + + def set_path(self, path: Union[str, Path], in_place: bool = False) -> "StoredSpatialImage": + """Update the path to the image file. + + Args: + path: + New path for this image. + + in_place: + Whether to modify the ``StoredSpatialImage`` in place. + + Returns: + A modified ``StoredSpatialImage`` object, either as a copy of the original + or as a reference to the (in-place-modified) original. + """ + new_path = _sanitize_path(path) + + _out = self._define_output(in_place=in_place) + _out._path = new_path + return _out + + @property + def path(self) -> Path: + """Alias for :py:meth:`~get_path`.""" + return self.get_path() + + @path.setter + def path(self, path: Union[str, Path]): + """Alias for :py:attr:`~set_path` with ``in_place = True``. + + As this mutates the original object, a warning is raised. + """ + warn( + "Setting property 'path' is an in-place operation, use 'set_path' instead", + UserWarning, + ) + return self.set_path(path=path, in_place=True) + + def img_source(self, as_path: bool = False) -> str: + """Get the source path of the image.""" + return str(self._path) if as_path is True else self._path + + ############################ + ######>> img utils <<####### + ############################ + + # Simple in-memory cache + @lru_cache(maxsize=32) + def img_raster(self) -> Image.Image: + """Load and cache the image.""" + return Image.open(self._path) + + +def _validate_url(url): + parsed = urlparse(url) + if not all([parsed.scheme, parsed.netloc]): + raise ValueError(f"Invalid URL: {url}") + + +class RemoteSpatialImage(VirtualSpatialImage): + """Class for remotely hosted images.""" + + def __init__(self, url: str, metadata: Optional[dict] = None, validate: bool = True): + """Initialize the object. + + Args: + url: + URL to the image file. + + metadata: + Additional image metadata. Defaults to None. + + validate: + Whether to validate if the URL is valid. Defaults to True. + """ + super().__init__(metadata=metadata) + + self._url = url + self._cache_dir = Path(tempfile.gettempdir()) / "spatial_image_cache" + self._cache_dir.mkdir(exist_ok=True) + + if validate: + _validate_url(url) + + def _define_output(self, in_place: bool = False) -> "RemoteSpatialImage": + if in_place is True: + return self + else: + return self.__copy__() + + ######################### + ######>> Copying <<###### + ######################### + + def __deepcopy__(self, memo=None, _nil=[]): + """ + Returns: + A deep copy of the current ``RemoteSpatialImage``. + """ + from copy import deepcopy + + _url_copy = deepcopy(self._url) + _metadata_copy = deepcopy(self.metadata) + + current_class_const = type(self) + return current_class_const( + url=_url_copy, + metadata=_metadata_copy, + ) + + def __copy__(self): + """ + Returns: + A shallow copy of the current ``RemoteSpatialImage``. + """ + current_class_const = type(self) + return current_class_const( + url=self._url, + metadata=self._metadata, + ) + + def copy(self): + """Alias for :py:meth:`~__copy__`.""" + return self.__copy__() + + ############################ + ######>> url props <<####### + ############################ + + def get_url(self) -> str: + """Get the url to the image file.""" + return self._url + + def set_url(self, url: str, in_place: bool = False) -> "RemoteSpatialImage": + """Update the url to the image file. + + Args: + url: + New URL for this image. + + in_place: + Whether to modify the ``RemoteSpatialImage`` in place. + + Returns: + A modified ``RemoteSpatialImage`` object, either as a copy of the original + or as a reference to the (in-place-modified) original. + """ + _validate_url(url) + + _out = self._define_output(in_place=in_place) + _out.url = url + return _out + + @property + def url(self) -> Path: + """Alias for :py:meth:`~get_url`.""" + return self.get_url() + + @url.setter + def url(self, url: Union[str, Path]): + """Alias for :py:attr:`~set_url` with ``in_place = True``. + + As this mutates the original object, a warning is raised. + """ + warn( + "Setting property 'url' is an in-place operation, use 'set_url' instead", + UserWarning, + ) + return self.set_url(url=url, in_place=True) + + ############################ + ######>> img utils <<####### + ############################ + + def _download_image(self) -> Path: + """Download image to cache directory.""" + cache_path = self._cache_dir / Path(urlparse(self._url).path).name + + if not cache_path.exists(): + response = requests.get(self._url, stream=True) + response.raise_for_status() + + with cache_path.open("wb") as f: + shutil.copyfileobj(response.raw, f) + + return cache_path + + @lru_cache(maxsize=32) + def img_raster(self) -> Image.Image: + """Download (if needed) and load the image.""" + cache_path = self._download_image() + return Image.open(cache_path) + + def img_source(self, as_path: bool = False) -> str: + """Get the source URL or cached path of the image.""" + if as_path: + return str(self._download_image()) + return self._url + + +def SpatialImage(x: Union[str, Image.Image, np.ndarray], is_url: Optional[bool] = None) -> VirtualSpatialImage: + """Factory function to create appropriate SpatialImage object.""" + if isinstance(x, VirtualSpatialImage): + return x + elif isinstance(x, (Image.Image, np.ndarray)): + return LoadedSpatialImage(x) + elif isinstance(x, (str, Path)): + if is_url is None: + is_url = urlparse(str(x)).scheme in ("http", "https", "ftp") + + if is_url: + return RemoteSpatialImage(str(x)) else: - raise ValueError("Unknown input type for 'x'") - - def load_image(self): - """Load the image from the stored path into memory.""" - if self.image is None and self.path is not None: - self.image = Image.open(self.path) - return self.image - - def get_image(self): - """Retrieve the image, loading it if necessary.""" - if self.image is None: - return self.load_image() - return self.image + return StoredSpatialImage(x) + else: + raise TypeError(f"Unsupported input type: {type(x)}") From 593797bb337aeae2f7f9bff4ef5933f4dfe0d137 Mon Sep 17 00:00:00 2001 From: Jayaram Kancherla Date: Fri, 31 Jan 2025 14:23:56 -0800 Subject: [PATCH 2/5] Fix usage of SpatialImage as a class --- setup.cfg | 1 + src/spatialexperiment/SpatialExperiment.py | 36 +++++++++++++--------- src/spatialexperiment/SpatialImage.py | 3 +- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/setup.cfg b/setup.cfg index 6a6f055..111dcc3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -54,6 +54,7 @@ install_requires = summarizedexperiment>=0.5 singlecellexperiment>=0.5.6 pillow>=11.0 + requests [options.packages.find] diff --git a/src/spatialexperiment/SpatialExperiment.py b/src/spatialexperiment/SpatialExperiment.py index bf34cba..17dd142 100644 --- a/src/spatialexperiment/SpatialExperiment.py +++ b/src/spatialexperiment/SpatialExperiment.py @@ -1,8 +1,10 @@ +from pathlib import Path from typing import Any, Dict, List, Optional, Sequence, Union +from urllib.parse import urlparse from warnings import warn -import numpy as np import biocutils as ut +import numpy as np from biocframe import BiocFrame from PIL import Image from singlecellexperiment import SingleCellExperiment @@ -19,7 +21,7 @@ _validate_spatial_coords, _validate_spatial_coords_names, ) -from .SpatialImage import SpatialImage +from .SpatialImage import SpatialImage, VirtualSpatialImage, _validate_url __author__ = "keviny2" __copyright__ = "keviny2" @@ -141,7 +143,7 @@ def __init__( spatial_coords: Optional :py:class:`~np.ndarray` or :py:class:`~biocframe.BiocFrame.BiocFrame` containing columns of spatial coordinates. Must be the same length as `column_data`. - + If `spatial_coords` is a :py:class:`~biocframe.BiocFrame.BiocFrame`, typical column names might include: - **['x', 'y']**: For simple 2D coordinates. @@ -154,7 +156,7 @@ def __init__( Optional :py:class:`~biocframe.BiocFrame.BiocFrame` containing the image data, structured with the following columns: - **sample_id** (str): A string identifier for the sample to which an image corresponds. - **image_id** (str): A unique string identifier for each image within each sample. - - **data** (SpatialImage): The image itself, represented as a SpatialImage object. + - **data** (VirtualSpatialImage): The image itself, represented as a `VirtualSpatialImage` object or one of its subclasses. - **scale_factor** (float): A numerical value that indicates the scaling factor applied to the image. All 'sample_id's in 'img_data' must be present in the 'sample_id's of 'column_data'. @@ -380,7 +382,7 @@ def set_spatial_coordinates( Args: spatial_coords: Optional :py:class:`~np.ndarray` or :py:class:`~biocframe.BiocFrame.BiocFrame` containing columns of spatial coordinates. Must be the same length as `column_data`. - + If `spatial_coords` is a :py:class:`~biocframe.BiocFrame.BiocFrame`, typical column names might include: - **['x', 'y']**: For simple 2D coordinates. @@ -405,7 +407,9 @@ def set_spatial_coordinates( output._spatial_coords = spatial_coords return output - def set_spatial_coords(self, spatial_coords: Optional[Union[BiocFrame, np.ndarray]], in_place: bool = False) -> "SpatialExperiment": + def set_spatial_coords( + self, spatial_coords: Optional[Union[BiocFrame, np.ndarray]], in_place: bool = False + ) -> "SpatialExperiment": """Alias for :py:meth:`~set_spatial_coordinates`.""" return self.set_spatial_coordinates(spatial_coords=spatial_coords, in_place=in_place) @@ -541,7 +545,7 @@ def set_image_data(self, img_data: Optional[BiocFrame], in_place: bool = False) :py:class:`~biocframe.BiocFrame.BiocFrame` containing the image data, structured with the following columns: - **sample_id** (str): A string identifier for the sample to which an image corresponds. - **image_id** (str): A unique string identifier for each image within each sample. - - **data** (SpatialImage): The image itself, represented as a SpatialImage object. + - **data** (VirtualSpatialImage): The image itself, represented as a `VirtualSpatialImage` object or one of its subclasses. - **scale_factor** (float): A numerical value that indicates the scaling factor applied to the image. Image data are coerced to a @@ -715,7 +719,7 @@ def get_slice( def get_img( self, sample_id: Union[str, bool, None] = None, image_id: Union[str, bool, None] = None - ) -> Union[SpatialImage, List[SpatialImage]]: + ) -> Union[VirtualSpatialImage, List[VirtualSpatialImage]]: """ Retrieve spatial images based on the provided sample and image ids. @@ -731,7 +735,7 @@ def get_img( - `image_id=""`: Matches image(s) by its(their) id. Returns: - Zero, one, or more `SpatialImage` objects. + Zero, one, or more `VirtualSpatialImage` objects. Behavior: - sample_id = True, image_id = True: @@ -770,7 +774,7 @@ def get_img( def add_img( self, - image_source: str, + image_source: Union[Image.Image, np.ndarray, str, Path], scale_factor: float, sample_id: Union[str, bool, None], image_id: Union[str, bool, None], @@ -810,11 +814,15 @@ def add_img( """ _validate_sample_image_ids(img_data=self._img_data, new_sample_id=sample_id, new_image_id=image_id) - if load: - img = Image.open(image_source) - spi = SpatialImage(img) + if isinstance(image_source, (str, Path)): + is_url = urlparse(str(image_source)).scheme in ("http", "https", "ftp") + spi = SpatialImage(image_source, is_url=is_url) + + if load: + img = spi.img_raster() + spi = SpatialImage(img, is_url=False) else: - spi = SpatialImage(image_source) + spi = SpatialImage(image_source, is_url=False) new_row = BiocFrame( { diff --git a/src/spatialexperiment/SpatialImage.py b/src/spatialexperiment/SpatialImage.py index 247f84a..0450a88 100644 --- a/src/spatialexperiment/SpatialImage.py +++ b/src/spatialexperiment/SpatialImage.py @@ -1,4 +1,3 @@ -import os import shutil import tempfile from abc import ABC, abstractmethod @@ -17,7 +16,7 @@ __license__ = "MIT" -# keeping the same name as the R classes +# Keeping the same names as the R classes class VirtualSpatialImage(ABC): """Base class for spatial images.""" From ff7445fef20e4fdf9054cbb6314659951d0c51af Mon Sep 17 00:00:00 2001 From: Jayaram Kancherla Date: Fri, 31 Jan 2025 14:43:42 -0800 Subject: [PATCH 3/5] Updated the tests and renamed constructor to construct_spatial_image_class --- src/spatialexperiment/SpatialExperiment.py | 8 ++--- src/spatialexperiment/SpatialImage.py | 2 +- src/spatialexperiment/__init__.py | 2 +- tests/conftest.py | 8 ++--- tests/test_img_data_methods.py | 7 ++-- tests/test_si.py | 41 +++++++++++++--------- 6 files changed, 38 insertions(+), 30 deletions(-) diff --git a/src/spatialexperiment/SpatialExperiment.py b/src/spatialexperiment/SpatialExperiment.py index 17dd142..facd646 100644 --- a/src/spatialexperiment/SpatialExperiment.py +++ b/src/spatialexperiment/SpatialExperiment.py @@ -21,7 +21,7 @@ _validate_spatial_coords, _validate_spatial_coords_names, ) -from .SpatialImage import SpatialImage, VirtualSpatialImage, _validate_url +from .SpatialImage import construct_spatial_image_class, VirtualSpatialImage __author__ = "keviny2" __copyright__ = "keviny2" @@ -816,13 +816,13 @@ def add_img( if isinstance(image_source, (str, Path)): is_url = urlparse(str(image_source)).scheme in ("http", "https", "ftp") - spi = SpatialImage(image_source, is_url=is_url) + spi = construct_spatial_image_class(image_source, is_url=is_url) if load: img = spi.img_raster() - spi = SpatialImage(img, is_url=False) + spi = construct_spatial_image_class(img, is_url=False) else: - spi = SpatialImage(image_source, is_url=False) + spi = construct_spatial_image_class(image_source, is_url=False) new_row = BiocFrame( { diff --git a/src/spatialexperiment/SpatialImage.py b/src/spatialexperiment/SpatialImage.py index 0450a88..c9977d7 100644 --- a/src/spatialexperiment/SpatialImage.py +++ b/src/spatialexperiment/SpatialImage.py @@ -507,7 +507,7 @@ def img_source(self, as_path: bool = False) -> str: return self._url -def SpatialImage(x: Union[str, Image.Image, np.ndarray], is_url: Optional[bool] = None) -> VirtualSpatialImage: +def construct_spatial_image_class(x: Union[str, Image.Image, np.ndarray], is_url: Optional[bool] = None) -> VirtualSpatialImage: """Factory function to create appropriate SpatialImage object.""" if isinstance(x, VirtualSpatialImage): return x diff --git a/src/spatialexperiment/__init__.py b/src/spatialexperiment/__init__.py index 3a5ea71..feb7e39 100644 --- a/src/spatialexperiment/__init__.py +++ b/src/spatialexperiment/__init__.py @@ -16,4 +16,4 @@ del version, PackageNotFoundError from .SpatialExperiment import SpatialExperiment -from .SpatialImage import SpatialImage +from .SpatialImage import construct_spatial_image_class, RemoteSpatialImage, StoredSpatialImage, LoadedSpatialImage diff --git a/tests/conftest.py b/tests/conftest.py index b681f0b..0123424 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ import pytest import numpy as np from biocframe import BiocFrame -from spatialexperiment import SpatialExperiment, SpatialImage +from spatialexperiment import SpatialExperiment, construct_spatial_image_class from random import random @@ -53,9 +53,9 @@ def spe(): "sample_id": ["sample_1", "sample_1", "sample_2"], "image_id": ["aurora", "dice", "desert"], "data": [ - SpatialImage("tests/images/sample_image1.jpg"), - SpatialImage("tests/images/sample_image2.png"), - SpatialImage("tests/images/sample_image3.jpg"), + construct_spatial_image_class("tests/images/sample_image1.jpg"), + construct_spatial_image_class("tests/images/sample_image2.png"), + construct_spatial_image_class("tests/images/sample_image3.jpg"), ], "scale_factor": [1, 1, 1], } diff --git a/tests/test_img_data_methods.py b/tests/test_img_data_methods.py index ec1a48b..a626afd 100644 --- a/tests/test_img_data_methods.py +++ b/tests/test_img_data_methods.py @@ -1,6 +1,7 @@ import pytest from copy import deepcopy -from spatialexperiment import SpatialImage +from spatialexperiment import construct_spatial_image_class +from spatialexperiment.SpatialImage import VirtualSpatialImage __author__ = "keviny2" __copyright__ = "keviny2" @@ -23,7 +24,7 @@ def test_get_img_both_null(spe): res = spe.get_img(sample_id=None, image_id=None) image = spe.img_data["data"][0] - assert isinstance(res, SpatialImage) + assert isinstance(res, VirtualSpatialImage) assert res == image @@ -47,7 +48,7 @@ def test_get_img_specific_image(spe): res = spe.get_img(sample_id=True, image_id="desert") images = spe.img_data["data"][2] - assert isinstance(res, SpatialImage) + assert isinstance(res, VirtualSpatialImage) assert res == images diff --git a/tests/test_si.py b/tests/test_si.py index 998b6d2..09503a2 100644 --- a/tests/test_si.py +++ b/tests/test_si.py @@ -1,6 +1,7 @@ import pytest from PIL import Image -from spatialexperiment import SpatialImage +from spatialexperiment import construct_spatial_image_class +from spatialexperiment.SpatialImage import VirtualSpatialImage, StoredSpatialImage, LoadedSpatialImage, RemoteSpatialImage __author__ = "keviny2" __copyright__ = "keviny2" @@ -8,34 +9,40 @@ def test_si_constructor_path(): - si = SpatialImage("images/sample_image1.jpg") + si = construct_spatial_image_class("tests/images/sample_image1.jpg", is_url=False) - assert isinstance(si, SpatialImage) - assert si.path == "images/sample_image1.jpg" - assert si.image is None + assert issubclass(type(si), VirtualSpatialImage) + assert isinstance(si, StoredSpatialImage) + + assert "tests/images/sample_image1.jpg" in str(si.path) + assert si.path is not None def test_si_constructor_si(): - si_1 = SpatialImage("images/sample_image1.jpg") - si_2 = SpatialImage(si_1) + si_1 = construct_spatial_image_class("tests/images/sample_image1.jpg", is_url=False) + si_2 = construct_spatial_image_class(si_1, is_url=False) + + assert issubclass(type(si_2), VirtualSpatialImage) + assert isinstance(si_2, StoredSpatialImage) - assert isinstance(si_2, SpatialImage) - assert si_1.image == si_2.image - assert si_1.path == si_2.path + assert str(si_1.path) == str(si_2.path) def test_si_constructor_image(): image = Image.open("tests/images/sample_image2.png") - si = SpatialImage(image) + si = construct_spatial_image_class(image, is_url=False) + + assert issubclass(type(si), VirtualSpatialImage) + assert isinstance(si, LoadedSpatialImage) - assert isinstance(si, SpatialImage) - assert si.path is None assert si.image == image def test_invalid_input(): - with pytest.raises(ValueError): - SpatialImage("https://i.redd.it/3pw5uah7xo041.jpg") + si_remote = construct_spatial_image_class("https://i.redd.it/3pw5uah7xo041.jpg", is_url=True) + assert issubclass(type(si_remote), VirtualSpatialImage) + assert isinstance(si_remote, RemoteSpatialImage) + assert si_remote.url == "https://i.redd.it/3pw5uah7xo041.jpg" - with pytest.raises(ValueError): - SpatialImage(5) + with pytest.raises(Exception): + construct_spatial_image_class(5, is_url=False) From fc9a2488f3e83d44bc234e63e237750121248c69 Mon Sep 17 00:00:00 2001 From: Jayaram Kancherla Date: Fri, 31 Jan 2025 14:48:59 -0800 Subject: [PATCH 4/5] Update changelog and readme --- CHANGELOG.md | 4 ++++ README.md | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b38798..ae72436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Version 0.0.3 + +- Streamlining the `SpatialImage` class implementations. + ## Version 0.0.1 - 0.0.2 - Initial version of the SpatialExperiment class with the additional slots. diff --git a/README.md b/README.md index 1cc671e..ecc9280 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ The `SpatialExperiment` class extends `SingleCellExperiment` with the following Here's how to create a SpatialExperiment object from scratch: ```python -from spatialexperiment import SpatialExperiment, SpatialImage +from spatialexperiment import SpatialExperiment, construct_spatial_image_class import numpy as np from biocframe import BiocFrame @@ -75,9 +75,9 @@ img_data = BiocFrame({ "sample_id": ["sample_1", "sample_1", "sample_2"], "image_id": ["aurora", "dice", "desert"], "data": [ - SpatialImage("tests/images/sample_image1.jpg"), - SpatialImage("tests/images/sample_image2.png"), - SpatialImage("tests/images/sample_image3.jpg"), + construct_spatial_image_class("tests/images/sample_image1.jpg"), + construct_spatial_image_class("tests/images/sample_image2.png"), + construct_spatial_image_class("tests/images/sample_image3.jpg"), ], "scale_factor": [1, 1, 1], }) From 09ee7cd5d4090f7cd465031aa87ce6b3973a84c3 Mon Sep 17 00:00:00 2001 From: Jayaram Kancherla Date: Fri, 31 Jan 2025 14:56:42 -0800 Subject: [PATCH 5/5] nicer prints --- src/spatialexperiment/SpatialImage.py | 89 ++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/src/spatialexperiment/SpatialImage.py b/src/spatialexperiment/SpatialImage.py index c9977d7..381c68b 100644 --- a/src/spatialexperiment/SpatialImage.py +++ b/src/spatialexperiment/SpatialImage.py @@ -7,6 +7,7 @@ from urllib.parse import urlparse from warnings import warn +import biocutils as ut import numpy as np import requests from PIL import Image @@ -185,6 +186,34 @@ def copy(self): """Alias for :py:meth:`~__copy__`.""" return self.__copy__() + ########################## + ######>> Printing <<###### + ########################## + + def __repr__(self) -> str: + """ + Returns: + A string representation. + """ + output = f"{type(self).__name__}" + output += ", image=" + self._image.__repr__() + if len(self._metadata) > 0: + output += ", metadata=" + ut.print_truncated_dict(self._metadata) + output += ")" + + return output + + def __str__(self) -> str: + """ + Returns: + A pretty-printed string containing the contents of this object. + """ + output = f"class: {type(self).__name__}\n" + output += f"image: ({self._image})\n" + output += f"metadata({str(len(self.metadata))}): {ut.print_truncated_list(list(self.metadata.keys()), sep=' ', include_brackets=False, transform=lambda y: y)}\n" + + return output + ############################ ######>> img props <<####### ############################ @@ -300,6 +329,34 @@ def copy(self): """Alias for :py:meth:`~__copy__`.""" return self.__copy__() + ########################## + ######>> Printing <<###### + ########################## + + def __repr__(self) -> str: + """ + Returns: + A string representation. + """ + output = f"{type(self).__name__}" + output += ", path=" + self._path + if len(self._metadata) > 0: + output += ", metadata=" + ut.print_truncated_dict(self._metadata) + output += ")" + + return output + + def __str__(self) -> str: + """ + Returns: + A pretty-printed string containing the contents of this object. + """ + output = f"class: {type(self).__name__}\n" + output += f"path: ({self._path})\n" + output += f"metadata({str(len(self.metadata))}): {ut.print_truncated_list(list(self.metadata.keys()), sep=' ', include_brackets=False, transform=lambda y: y)}\n" + + return output + ############################# ######>> path props <<####### ############################# @@ -432,6 +489,34 @@ def copy(self): """Alias for :py:meth:`~__copy__`.""" return self.__copy__() + ########################## + ######>> Printing <<###### + ########################## + + def __repr__(self) -> str: + """ + Returns: + A string representation. + """ + output = f"{type(self).__name__}" + output += ", url=" + self._url + if len(self._metadata) > 0: + output += ", metadata=" + ut.print_truncated_dict(self._metadata) + output += ")" + + return output + + def __str__(self) -> str: + """ + Returns: + A pretty-printed string containing the contents of this object. + """ + output = f"class: {type(self).__name__}\n" + output += f"url: ({self._url})\n" + output += f"metadata({str(len(self.metadata))}): {ut.print_truncated_list(list(self.metadata.keys()), sep=' ', include_brackets=False, transform=lambda y: y)}\n" + + return output + ############################ ######>> url props <<####### ############################ @@ -507,7 +592,9 @@ def img_source(self, as_path: bool = False) -> str: return self._url -def construct_spatial_image_class(x: Union[str, Image.Image, np.ndarray], is_url: Optional[bool] = None) -> VirtualSpatialImage: +def construct_spatial_image_class( + x: Union[str, Image.Image, np.ndarray], is_url: Optional[bool] = None +) -> VirtualSpatialImage: """Factory function to create appropriate SpatialImage object.""" if isinstance(x, VirtualSpatialImage): return x