Skip to content

Commit 5c43217

Browse files
MACSIMA parse subfolders only when explicitly requested (#379)
* Change parsing defaults! Remove auto parsing style option entirely. By default, all tif files inside a folder and all its subdirectories are parsed into a single Image element inside a SpatialData object. Only if the user specifically requests parsing subfolders, then the first level underneath the specified directory defines the image elements. All tifs in these directories, and subdirectores, will be parsed into separate image elements, with corresponding tables and coordinate systems. * Skip empty folders when parsing multiple folders * Clarify parsing_style docstring and add tests * make pytest checks more explicit * change default macsima parsing style in cli --------- Co-authored-by: Luca Marconato <m.lucalmer@gmail.com>
1 parent fafeac8 commit 5c43217

3 files changed

Lines changed: 68 additions & 33 deletions

File tree

src/spatialdata_io/__main__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -794,9 +794,9 @@ def xenium_wrapper(
794794
@_input_output_click_options
795795
@click.option(
796796
"--parsing-style",
797-
type=click.Choice(["auto", "processed_single_folder", "processed_multiple_folders", "raw"]),
798-
default="auto",
799-
help="Parsing style for MACSima data. [default: auto]",
797+
type=click.Choice(["processed_single_folder", "processed_multiple_folders", "raw"]),
798+
default="processed_single_folder",
799+
help="Parsing style for MACSima data. [default: processed_single_folder]",
800800
)
801801
@click.option(
802802
"--filter-folder-names",
@@ -870,7 +870,7 @@ def macsima_wrapper(
870870
input: str,
871871
output: str,
872872
*,
873-
parsing_style: str = "auto",
873+
parsing_style: str = "processed_single_folder",
874874
filter_folder_names: list[str] | None = None,
875875
subset: int | None = None,
876876
c_subset: int | None = None,

src/spatialdata_io/readers/macsima.py

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ class MACSimaParsingStyle(ModeEnum):
4848
PROCESSED_SINGLE_FOLDER = "processed_single_folder"
4949
PROCESSED_MULTIPLE_FOLDERS = "processed_multiple_folders"
5050
RAW = "raw"
51-
AUTO = "auto"
5251

5352

5453
@dataclass
@@ -224,7 +223,7 @@ def get_stack(self) -> da.Array:
224223

225224
def macsima(
226225
path: str | Path,
227-
parsing_style: MACSimaParsingStyle | str = MACSimaParsingStyle.AUTO,
226+
parsing_style: MACSimaParsingStyle | str = MACSimaParsingStyle.PROCESSED_SINGLE_FOLDER,
228227
filter_folder_names: list[str] | None = None,
229228
imread_kwargs: Mapping[str, Any] = MappingProxyType({}),
230229
subset: int | None = None,
@@ -255,7 +254,8 @@ def macsima(
255254
path
256255
Path to the directory containing the data.
257256
parsing_style
258-
Parsing style to use. If ``auto``, the parsing style is determined based on the contents of the path.
257+
Parsing style to use. If ``processed_single_folder``, all subfolders of ``path`` are combined into a stack.
258+
If ``processed_multiple_folders``, a stack is created for each folder directly beneath ``path``.
259259
filter_folder_names
260260
List of folder names to filter out when parsing multiple folders.
261261
imread_kwargs
@@ -295,19 +295,13 @@ def macsima(
295295
if not isinstance(parsing_style, MACSimaParsingStyle):
296296
parsing_style = MACSimaParsingStyle(parsing_style)
297297

298-
if parsing_style == MACSimaParsingStyle.AUTO:
299-
assert path.is_dir(), f"Path {path} is not a directory."
300-
301-
if any(p.suffix in [".tif", ".tiff"] for p in path.iterdir()):
302-
# if path contains tifs, do parse_processed_folder on path
303-
parsing_style = MACSimaParsingStyle.PROCESSED_SINGLE_FOLDER
304-
elif all(p.is_dir() for p in path.iterdir() if not p.name.startswith(".")):
305-
# if path contains only folders or hidden files, do parse_processed_folder on each folder
306-
parsing_style = MACSimaParsingStyle.PROCESSED_MULTIPLE_FOLDERS
307-
else:
308-
raise ValueError(f"Cannot determine parsing style for path {path}. Please specify the parsing style.")
309-
310298
if parsing_style == MACSimaParsingStyle.PROCESSED_SINGLE_FOLDER:
299+
if filter_folder_names:
300+
warnings.warn(
301+
"single_processed_folder was requested but filter_folder_names was specified. Note that it is ignored here, filtering only happens for processed_multi_folders!",
302+
UserWarning,
303+
stacklevel=2,
304+
)
311305
return parse_processed_folder(
312306
path=path,
313307
imread_kwargs=imread_kwargs,
@@ -332,6 +326,9 @@ def macsima(
332326
for p in path.iterdir()
333327
if p.is_dir() and (not filter_folder_names or not any(f in p.name for f in filter_folder_names))
334328
]:
329+
if not len(list(p.glob("*.tif*"))):
330+
warnings.warn(f"No tif files found in {p}, skipping it!", UserWarning, stacklevel=2)
331+
continue
335332
sdatas[p.stem] = parse_processed_folder(
336333
path=p,
337334
imread_kwargs=imread_kwargs,
@@ -625,7 +622,7 @@ def parse_processed_folder(
625622
nuclei_channel_name: str = "DAPI",
626623
split_threshold_nuclei_channel: int | None = 2,
627624
skip_rounds: list[int] | None = None,
628-
file_pattern: str = "*.tif*",
625+
file_pattern: str = "**/*.tif*",
629626
include_cycle_in_channel_name: bool = False,
630627
) -> SpatialData:
631628
"""Parse a single folder containing images from a cyclical imaging platform."""

tests/test_macsima.py

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@
3535
_parse_v1_ome_metadata,
3636
macsima,
3737
)
38-
from tests._utils import skip_if_below_python_version
3938

4039
RNG = da.random.default_rng(seed=0)
4140

@@ -101,7 +100,6 @@ def test_exception_on_no_valid_files(tmp_path: Path) -> None:
101100
macsima(tmp_path)
102101

103102

104-
@skip_if_below_python_version()
105103
@pytest.mark.parametrize(
106104
"dataset,expected",
107105
[
@@ -123,7 +121,6 @@ def test_image_size(dataset: str, expected: dict[str, Any]) -> None:
123121
assert extent == expected
124122

125123

126-
@skip_if_below_python_version()
127124
@pytest.mark.parametrize(
128125
"dataset,expected",
129126
[("OMAP10_small", 4), ("OMAP23_small", 5)],
@@ -139,7 +136,6 @@ def test_total_channels(dataset: str, expected: int) -> None:
139136
assert channels == expected
140137

141138

142-
@skip_if_below_python_version()
143139
@pytest.mark.parametrize(
144140
"dataset,expected",
145141
[
@@ -161,7 +157,6 @@ def test_channel_names_with_cycle_in_name(dataset: str, expected: list[str]) ->
161157
assert list(channels) == expected
162158

163159

164-
@skip_if_below_python_version()
165160
@pytest.mark.parametrize(
166161
"dataset,expected",
167162
[
@@ -178,7 +173,6 @@ def test_total_rounds(dataset: str, expected: list[int]) -> None:
178173
assert max_cycle == expected
179174

180175

181-
@skip_if_below_python_version()
182176
@pytest.mark.parametrize(
183177
"dataset,skip_rounds,expected",
184178
[
@@ -201,6 +195,57 @@ def test_skip_rounds(dataset: str, skip_rounds: list[int], expected: list[str])
201195
assert list(channels) == expected, f"Expected {expected}, got {list(channels)}"
202196

203197

198+
def test_unsupported_parsing_styles() -> None:
199+
with pytest.raises(ValueError, match="Invalid option `not_a_parsing_style` for `MACSimaParsingStyle`."):
200+
macsima(Path(), parsing_style="not_a_parsing_style")
201+
202+
203+
def test_processed_single_folder_parsing_returns_a_single_image_stack(tmp_path: Path) -> None:
204+
omap10_path = Path("./data/OMAP10_small")
205+
shutil.copytree(omap10_path, tmp_path / "OMAP10_small_1")
206+
shutil.copytree(omap10_path, tmp_path / "OMAP10_small_2")
207+
208+
sdata = macsima(tmp_path, parsing_style="processed_single_folder")
209+
210+
assert len(sdata.images) == 1
211+
# omap10_small has 4 channels, so we expect 8 here
212+
el = sdata[list(sdata.images.keys())[0]]
213+
assert len(get_channel_names(el)) == 8
214+
assert len(sdata.tables) == 1
215+
216+
217+
def test_processed_single_folder_parsing_warns_when_specifying_filtered_folders(tmp_path: Path) -> None:
218+
omap10_path = Path("./data/OMAP10_small")
219+
shutil.copytree(omap10_path, tmp_path / "OMAP10_small_1")
220+
shutil.copytree(omap10_path, tmp_path / "OMAP10_small_2")
221+
with pytest.warns(UserWarning, match="filtering only happens for processed_multi_folders"):
222+
macsima(tmp_path, parsing_style="processed_single_folder", filter_folder_names=["OMAP10_small_2"])
223+
224+
225+
def test_processed_multiple_folders_returns_an_image_stack_per_subfolder(tmp_path: Path) -> None:
226+
omap10_path = Path("./data/OMAP10_small")
227+
shutil.copytree(omap10_path, tmp_path / "OMAP10_small_1")
228+
shutil.copytree(omap10_path, tmp_path / "OMAP10_small_2")
229+
230+
sdata = macsima(tmp_path, parsing_style="processed_multiple_folders")
231+
232+
assert len(sdata.images) == 2
233+
for el in sdata.images.keys():
234+
assert len(get_channel_names(sdata[el])) == 4
235+
assert len(sdata.tables) == 2
236+
237+
238+
def test_processed_multiple_folders_skips_filtered_folder_names(tmp_path: Path) -> None:
239+
shutil.copytree(Path("./data/OMAP10_small"), tmp_path / "OMAP10_small")
240+
shutil.copytree(Path("./data/OMAP23_small"), tmp_path / "OMAP23_small")
241+
242+
sdata = macsima(tmp_path, parsing_style="processed_multiple_folders", filter_folder_names=["OMAP10_small"])
243+
assert len(sdata.images) == 1
244+
assert list(sdata.images.keys()) == ["OMAP23_small_image"]
245+
assert len(sdata.tables) == 1
246+
assert list(sdata.tables.keys()) == ["OMAP23_small_table"]
247+
248+
204249
METADATA_COLUMN_ORDER = [
205250
"cycle",
206251
"imagetype",
@@ -242,7 +287,6 @@ def test_skip_rounds(dataset: str, skip_rounds: list[int], expected: list[str])
242287
)
243288

244289

245-
@skip_if_below_python_version()
246290
@pytest.mark.parametrize(
247291
"dataset,expected_df",
248292
[
@@ -262,11 +306,6 @@ def test_metadata_table(dataset: str, expected_df: pd.DataFrame) -> None:
262306
pd.testing.assert_frame_equal(actual, expected_df)
263307

264308

265-
def test_parsing_style() -> None:
266-
with pytest.raises(ValueError):
267-
macsima(Path(), parsing_style="not_a_parsing_style")
268-
269-
270309
def test_mci_sort_by_channel() -> None:
271310
sizes = [100, 200, 300]
272311
c_names = ["test11", "test3", "test2"]
@@ -315,7 +354,6 @@ def test_mci_array_reference() -> None:
315354
assert da.all(mci.data[0] == orig_arr1)
316355

317356

318-
@skip_if_below_python_version()
319357
@pytest.mark.parametrize("dataset", ["OMAP10_small", "OMAP23_small"])
320358
def test_cli_macsima(runner: CliRunner, dataset: str) -> None:
321359
f = Path("./data") / dataset

0 commit comments

Comments
 (0)