Skip to content

Commit d9fb079

Browse files
authored
Implement img_source() (#36)
1 parent 08baac1 commit d9fb079

6 files changed

Lines changed: 289 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Changelog
22

3-
## [Unreleased]
3+
## Version 0.0.7
4+
- Added `img_source` function in main SpatialExperiment class and child classes of VirtualSpatialExperiment (PR #36)
45
- Added `remove_img` function (PR #34)
56
- Refactored `get_img_idx` for improved maintainability
67
- Disambiguated `get_img_data` between `_imgutils.py` and `SpatialExperiment.py`

src/spatialexperiment/SpatialExperiment.py

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -745,8 +745,7 @@ def get_img(
745745
sample_id: Union[str, bool, None] = None,
746746
image_id: Union[str, bool, None] = None,
747747
) -> Union[VirtualSpatialImage, List[VirtualSpatialImage]]:
748-
"""
749-
Retrieve spatial images based on the provided sample and image ids.
748+
"""Retrieve spatial images based on the provided sample and image ids.
750749
751750
Args:
752751
sample_id:
@@ -760,7 +759,9 @@ def get_img(
760759
- `image_id="<str>"`: Matches image(s) by its(their) id.
761760
762761
Returns:
763-
Zero, one, or more `VirtualSpatialImage` objects.
762+
The image(s) matching the specified criteria. Returns `None` if `img_data` is `None`.
763+
When a single image matches, returns a :py:class:`~VirtualSpatialImage` object.
764+
When multiple images match, returns a list of :py:class:`~VirtualSpatialImage` objects.
764765
765766
Behavior:
766767
- sample_id = True, image_id = True:
@@ -783,6 +784,9 @@ def get_img(
783784
784785
- sample_id = <str>, image_id = <str>:
785786
Returns the image matching the specified sample and image identifiers.
787+
788+
Raises:
789+
ValueError: If no row matches the provided sample_id and image_id pair.
786790
"""
787791
_validate_id(sample_id)
788792
_validate_id(image_id)
@@ -792,6 +796,9 @@ def get_img(
792796

793797
indices = get_img_idx(img_data=self.img_data, sample_id=sample_id, image_id=image_id)
794798

799+
if len(indices) == 0:
800+
raise ValueError(f"No matching rows for sample_id={sample_id} and image_id={image_id}")
801+
795802
images = self.img_data[indices,]["data"]
796803
return images[0] if len(images) == 1 else images
797804

@@ -834,6 +841,9 @@ def add_img(
834841
835842
Raises:
836843
ValueError: If the sample_id and image_id pair already exists.
844+
845+
Note:
846+
See :py:meth:`~get_img` for detailed behavior regarding sample_id and image_id parameters.
837847
"""
838848
_validate_sample_image_ids(img_data=self._img_data, new_sample_id=sample_id, new_image_id=image_id)
839849

@@ -880,12 +890,24 @@ def remove_img(
880890
in_place:
881891
Whether to modify the ``SpatialExperiment`` in place.
882892
Defaults to False.
893+
894+
Returns:
895+
A modified ``SpatialExperiment`` object, either as a copy of the original or as a reference to the (in-place-modified) original.
896+
897+
Raises:
898+
ValueError: If no row matches the provided sample_id and image_id pair.
899+
900+
Note:
901+
See :py:meth:`~get_img` for detailed behavior regarding sample_id and image_id parameters.
883902
"""
884903
_validate_id(sample_id)
885904
_validate_id(image_id)
886905

887906
indices = get_img_idx(img_data=self.img_data, sample_id=sample_id, image_id=image_id)
888907

908+
if len(indices) == 0:
909+
raise ValueError(f"No matching rows for sample_id={sample_id} and image_id={image_id}")
910+
889911
new_img_data = self._img_data.remove_rows(indices)
890912

891913
output = self._define_output(in_place=in_place)
@@ -898,7 +920,43 @@ def img_source(
898920
image_id: Union[str, bool, None] = None,
899921
path=False,
900922
):
901-
raise NotImplementedError("This function is irrelevant because it is for `RemoteSpatialImages`")
923+
"""Retrieve the source(s) for images stored in the SpatialExperiment object.
924+
925+
Args:
926+
sample_id:
927+
- `sample_id=True`: Matches all samples.
928+
- `sample_id=None`: Matches the first sample.
929+
- `sample_id="<str>"`: Matches a sample by its id.
930+
931+
image_id:
932+
- `image_id=True`: Matches all images for the specified sample(s).
933+
- `image_id=None`: Matches the first image for the sample(s).
934+
- `image_id="<str>"`: Matches image(s) by its(their) id.
935+
936+
path: If True, returns path as string. Defaults to False.
937+
938+
Returns:
939+
The image source(s) for the matching criteria. Returns `None` if `img_data` is `None`.
940+
When a single image matches, returns its source as a `str`, `Path`, or `None`.
941+
When multiple images match, returns a list of sources.
942+
943+
Raises:
944+
ValueError: If no row matches the provided sample_id and image_id pair.
945+
946+
Note:
947+
See :py:meth:`~get_img` for detailed behavior regarding sample_id and image_id parameters.
948+
"""
949+
spis = self.get_img(sample_id=sample_id, image_id=image_id)
950+
951+
if spis is None:
952+
return None
953+
954+
if isinstance(spis, VirtualSpatialImage):
955+
return spis.img_source(as_path=path)
956+
957+
img_sources = [spi.img_source(as_path=path) for spi in spis]
958+
959+
return img_sources
902960

903961
def img_raster(self, sample_id=None, image_id=None):
904962
# NOTE: this function seems redundant, might be an artifact of the different subclasses of SpatialImage in the R implementation? just call `get_img()` for now

src/spatialexperiment/SpatialImage.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,18 @@ def dimensions(self) -> Tuple[int, int]:
105105
######>> img utils <<#######
106106
############################
107107

108+
@abstractmethod
109+
def img_source(self, as_path: bool = False) -> Union[str, None]:
110+
"""Get the source of the image.
111+
112+
Args:
113+
as_path: If True, returns path as string. Defaults to False.
114+
115+
Returns:
116+
Source path/URL of the image, or None if loaded in memory.
117+
"""
118+
pass
119+
108120
@abstractmethod
109121
def img_raster(self) -> Image.Image:
110122
"""Get the image as a PIL Image object."""
@@ -282,6 +294,14 @@ def image(self, image: Union[Image.Image, np.ndarray]):
282294
)
283295
return self.set_image(image=image, in_place=True)
284296

297+
def img_source(self, as_path: bool = False) -> None:
298+
"""Get the source of the loaded image.
299+
300+
Returns:
301+
Always returns None.
302+
"""
303+
return None
304+
285305
############################
286306
######>> img utils <<#######
287307
############################
@@ -440,7 +460,14 @@ def path(self, path: Union[str, Path]):
440460
return self.set_path(path=path, in_place=True)
441461

442462
def img_source(self, as_path: bool = False) -> str:
443-
"""Get the source path of the image."""
463+
"""Get the source path of the image.
464+
465+
Args:
466+
as_path: If True, returns string path. Defaults to False.
467+
468+
Returns:
469+
Path to the image.
470+
"""
444471
return str(self._path) if as_path is True else self._path
445472

446473
############################
@@ -633,7 +660,14 @@ def img_raster(self) -> Image.Image:
633660
return Image.open(cache_path)
634661

635662
def img_source(self, as_path: bool = False) -> str:
636-
"""Get the source URL or cached path of the image."""
663+
"""Get the source URL or cached path of the image.
664+
665+
Args:
666+
as_path: If True, returns downloaded path. Defaults to False.
667+
668+
Returns:
669+
URL or cached path of the image.
670+
"""
637671
if as_path:
638672
return str(self._download_image())
639673
return self._url

tests/test_get_img.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pytest
22
from copy import deepcopy
33
import numpy as np
4+
45
from spatialexperiment.SpatialImage import VirtualSpatialImage
56

67
__author__ = "keviny2"

tests/test_img_source.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import pytest
2+
from copy import deepcopy
3+
from pathlib import Path
4+
from PIL import Image
5+
import numpy as np
6+
from spatialexperiment import construct_spatial_image_class
7+
from spatialexperiment.SpatialImage import (
8+
LoadedSpatialImage,
9+
StoredSpatialImage,
10+
RemoteSpatialImage
11+
)
12+
13+
def test_loaded_spatial_image_img_source():
14+
image = Image.open("tests/images/sample_image2.png")
15+
spi_loaded = construct_spatial_image_class(image, is_url=False)
16+
17+
assert isinstance(spi_loaded, LoadedSpatialImage)
18+
assert spi_loaded.img_source() is None
19+
assert spi_loaded.img_source(as_path=True) is None
20+
21+
np_image = np.zeros((100, 100, 3), dtype=np.uint8)
22+
spi_loaded_np = construct_spatial_image_class(np_image)
23+
24+
assert isinstance(spi_loaded_np, LoadedSpatialImage)
25+
assert spi_loaded_np.img_source() is None
26+
assert spi_loaded_np.img_source(as_path=True) is None
27+
28+
29+
def test_stored_spatial_image_img_source():
30+
image_path = "tests/images/sample_image1.jpg"
31+
spi_stored = construct_spatial_image_class(image_path, is_url=False)
32+
33+
assert isinstance(spi_stored, StoredSpatialImage)
34+
35+
source_path = spi_stored.img_source()
36+
assert isinstance(source_path, Path)
37+
assert image_path in str(source_path)
38+
39+
source_str = spi_stored.img_source(as_path=True)
40+
assert isinstance(source_str, str)
41+
assert image_path in source_str
42+
43+
assert str(source_path) == str(spi_stored.path)
44+
45+
46+
def test_remote_spatial_image_img_source():
47+
image_url = "https://example.com/test_image.jpg"
48+
spi_remote = construct_spatial_image_class(image_url, is_url=True)
49+
50+
assert isinstance(spi_remote, RemoteSpatialImage)
51+
52+
source = spi_remote.img_source()
53+
assert isinstance(source, str)
54+
assert source == image_url
55+
56+
57+
def test_remote_spatial_image_img_source_with_mock(monkeypatch):
58+
image_url = "https://example.com/test_image.jpg"
59+
spi_remote = construct_spatial_image_class(image_url, is_url=True)
60+
61+
assert isinstance(spi_remote, RemoteSpatialImage)
62+
63+
# Mock the _download_image method to return a fixed path
64+
mock_path = Path("/tmp/image.jpg")
65+
monkeypatch.setattr(spi_remote, "_download_image", lambda: mock_path)
66+
67+
# Test with as_path=True (returns the cached path)
68+
source_path = spi_remote.img_source(as_path=True)
69+
assert source_path == str(mock_path)
70+
71+
# Test default behavior returns URL
72+
assert spi_remote.img_source() == image_url
73+
74+
75+
def test_img_source_no_img_data(spe):
76+
tspe = deepcopy(spe)
77+
tspe.img_data = None
78+
assert not tspe.img_source()
79+
80+
81+
def test_img_source_no_matches(spe):
82+
with pytest.raises(ValueError):
83+
sources = spe.img_source(sample_id="foo", image_id="foo")
84+
85+
86+
def test_img_source_both_str(spe):
87+
res = spe.img_source(sample_id="sample_1", image_id="dice")
88+
expected_source = spe.get_img(sample_id="sample_1", image_id="dice").img_source()
89+
90+
assert isinstance(res, Path)
91+
assert res == expected_source
92+
93+
94+
def test_img_source_both_str_path(spe):
95+
res = spe.img_source(sample_id="sample_1", image_id="dice", path=True)
96+
expected_source = spe.get_img(sample_id="sample_1", image_id="dice").img_source(as_path=True)
97+
98+
assert isinstance(res, str)
99+
assert res == expected_source
100+
101+
102+
def test_img_source_both_true(spe):
103+
res = spe.img_source(sample_id=True, image_id=True)
104+
images = spe.get_img(sample_id=True, image_id=True)
105+
expected_sources = [img.img_source() for img in images]
106+
107+
assert isinstance(res, list)
108+
assert res == expected_sources
109+
110+
111+
def test_img_source_both_true_path(spe):
112+
res = spe.img_source(sample_id=True, image_id=True, path=True)
113+
images = spe.get_img(sample_id=True, image_id=True)
114+
expected_sources = [img.img_source(as_path=True) for img in images]
115+
116+
assert isinstance(res, list)
117+
assert res == expected_sources
118+
119+
120+
def test_img_source_both_none(spe):
121+
res = spe.img_source(sample_id=None, image_id=None)
122+
expected_source = spe.get_img(sample_id=None, image_id=None).img_source()
123+
124+
assert isinstance(res, Path)
125+
assert res == expected_source
126+
127+
128+
def test_img_source_sample_str_image_true(spe):
129+
res = spe.img_source(sample_id="sample_1", image_id=True)
130+
images = spe.get_img(sample_id="sample_1", image_id=True)
131+
expected_sources = [img.img_source() for img in images]
132+
133+
assert isinstance(res, list)
134+
assert res == expected_sources
135+
136+
137+
def test_img_source_sample_true_image_str(spe):
138+
res = spe.img_source(sample_id=True, image_id="desert")
139+
expected_source = spe.get_img(sample_id=True, image_id="desert").img_source()
140+
141+
assert isinstance(res, Path)
142+
assert res == expected_source
143+
144+
145+
def test_img_source_sample_str_image_none(spe):
146+
res = spe.img_source(sample_id="sample_1", image_id=None)
147+
expected_source = spe.get_img(sample_id="sample_1", image_id=None).img_source()
148+
149+
assert isinstance(res, Path)
150+
assert res == expected_source
151+
152+
153+
def test_img_source_sample_none_image_str(spe):
154+
res = spe.img_source(sample_id=None, image_id="aurora")
155+
expected_source = spe.get_img(sample_id=None, image_id="aurora").img_source()
156+
157+
assert isinstance(res, Path)
158+
assert res == expected_source
159+
160+
161+
def test_img_source_sample_true_image_none(spe):
162+
res = spe.img_source(sample_id=True, image_id=None)
163+
images = spe.get_img(sample_id=True, image_id=None)
164+
expected_sources = [img.img_source() for img in images]
165+
166+
assert isinstance(res, list)
167+
assert res == expected_sources
168+
169+
170+
def test_img_source_sample_none_image_true(spe):
171+
res = spe.img_source(sample_id=None, image_id=True)
172+
images = spe.get_img(sample_id=None, image_id=True)
173+
expected_sources = [img.img_source() for img in images]
174+
175+
assert isinstance(res, list)
176+
assert res == expected_sources

0 commit comments

Comments
 (0)