Skip to content

Commit c84f947

Browse files
author
Vasu Jaganath
committed
add actual (portable) data test for single and multi image pyramid generation
1 parent d99f699 commit c84f947

File tree

8 files changed

+363
-1
lines changed

8 files changed

+363
-1
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
[build-system]
2-
requires = ["setuptools>=61.0", "wheel", "looseversion", "versioneer", "cmake"]
2+
requires = ["setuptools>=61.0", "wheel", "looseversion", "versioneer", "pytest", "cmake"]
33
build-backend = "setuptools.build_meta"

pytest.ini

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[pytest]
2+
testpaths =
3+
tests/python
4+
python_files =
5+
test_*.py
6+
addopts =
7+
--ignore=tests/python/test_read.py
8+
markers =
9+
integration: integration tests that touch disk/network

tests/python/io_utils.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from dataclasses import dataclass
2+
from pathlib import Path
3+
import subprocess
4+
5+
6+
7+
@dataclass(frozen=True)
8+
class PolusTestDataRepo:
9+
repo_url: str = "https://github.com/sameeul/polus-test-data.git"
10+
repo_dir: Path = Path("polus-test-data")
11+
default_branch: str = "main" # used only if you later add pull/update logic
12+
13+
14+
def ensure_repo_cloned(repo: PolusTestDataRepo, depth: int = 1) -> Path:
15+
"""
16+
Ensure the repo exists locally. If not, clone it.
17+
Returns the local repo directory.
18+
"""
19+
if repo.repo_dir.exists():
20+
return repo.repo_dir
21+
22+
repo.repo_dir.parent.mkdir(parents=True, exist_ok=True)
23+
subprocess.run(
24+
["git", "clone", "--depth", str(depth), repo.repo_url, str(repo.repo_dir)],
25+
check=True,
26+
)
27+
return repo.repo_dir
28+
29+
30+
def get_local_file_path(repo_dir: Path, rel_path: Path) -> Path:
31+
"""
32+
Resolve a path inside the repo and validate it exists.
33+
"""
34+
local_path = (repo_dir / rel_path).resolve()
35+
if not local_path.exists():
36+
raise FileNotFoundError(f"File not found: {local_path}")
37+
return local_path

tests/python/multi_images.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from pathlib import Path
2+
from typing import Mapping
3+
from types import MappingProxyType
4+
5+
from argolid import PyramidGenerartor
6+
DEFAULT_DOWNSAMPLE_METHODS = MappingProxyType({1: "mean"})
7+
from .io_utils import ensure_repo_cloned, PolusTestDataRepo
8+
9+
10+
def generate_pyramid_from_repo_path(
11+
*,
12+
repo: PolusTestDataRepo,
13+
file_pattern: str,
14+
image_name: str = "test_image",
15+
output_dir: str | Path,
16+
min_dim: int = 1024,
17+
vis_type: str = "Viv",
18+
downsample_methods: Mapping[int, str] = DEFAULT_DOWNSAMPLE_METHODS,
19+
) -> None:
20+
"""
21+
Clone the repo if needed, locate the image file, then run Argolid pyramid generation.
22+
"""
23+
repo_dir = ensure_repo_cloned(repo)
24+
input_dir = str(repo_dir / "argolid")
25+
26+
output_dir = Path(output_dir)
27+
output_dir.parent.mkdir(parents=True, exist_ok=True)
28+
29+
pyr_gen = PyramidGenerartor()
30+
pyr_gen.generate_from_image_collection(
31+
input_dir,
32+
file_pattern,
33+
image_name,
34+
str(output_dir),
35+
min_dim,
36+
vis_type,
37+
dict(downsample_methods),
38+
)
39+
40+
41+
def main() -> None:
42+
repo = PolusTestDataRepo()
43+
file_pattern = "x{x:d}_y{y:d}_c{c:d}.ome.tiff"
44+
output_dir = Path("output") / "2D_pyramid_assembled"
45+
46+
generate_pyramid_from_repo_path(
47+
repo=repo,
48+
file_pattern=file_pattern,
49+
image_name="test_image",
50+
output_dir=output_dir,
51+
min_dim=1024,
52+
vis_type="Viv",
53+
downsample_methods={1: "mean"},
54+
)
55+
56+
57+
if __name__ == "__main__":
58+
main()

tests/python/single_image.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from pathlib import Path
2+
from typing import Mapping
3+
from types import MappingProxyType
4+
5+
from argolid import PyramidGenerartor
6+
from .io_utils import ensure_repo_cloned, get_local_file_path, PolusTestDataRepo
7+
8+
9+
DEFAULT_DOWNSAMPLE_METHODS: Mapping[int, str] = MappingProxyType({1: "mean"})
10+
11+
12+
def generate_pyramid_from_repo_file(
13+
*,
14+
repo: PolusTestDataRepo,
15+
rel_image_path: Path,
16+
output_dir: str | Path,
17+
min_dim: int = 1024,
18+
vis_type: str = "Viv",
19+
downsample_methods: Mapping[int, str] = DEFAULT_DOWNSAMPLE_METHODS,
20+
) -> None:
21+
"""
22+
Clone the repo if needed, locate the image file, then run Argolid pyramid generation.
23+
"""
24+
repo_dir = ensure_repo_cloned(repo)
25+
input_file = str(get_local_file_path(repo_dir, rel_image_path))
26+
27+
output_dir = Path(output_dir)
28+
output_dir.parent.mkdir(parents=True, exist_ok=True)
29+
30+
pyr_gen = PyramidGenerartor()
31+
pyr_gen.generate_from_single_image(
32+
input_file,
33+
str(output_dir),
34+
min_dim,
35+
vis_type,
36+
dict(downsample_methods),
37+
)
38+
39+
40+
def main() -> None:
41+
repo = PolusTestDataRepo()
42+
43+
rel_image_path = Path("argolid") / "x0_y0_c1.ome.tiff"
44+
output_dir = Path("output") / "one_image_ome_zarr"
45+
46+
generate_pyramid_from_repo_file(
47+
repo=repo,
48+
rel_image_path=rel_image_path,
49+
output_dir=output_dir,
50+
min_dim=1024,
51+
vis_type="Viv",
52+
downsample_methods={1: "mean"},
53+
)
54+
55+
56+
if __name__ == "__main__":
57+
main()

tests/python/test_multi_images.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import shutil
2+
from pathlib import Path
3+
4+
import pytest
5+
6+
from .io_utils import PolusTestDataRepo
7+
from .multi_images import generate_pyramid_from_repo_path
8+
from .zarr_assertions import assert_is_argolid_omexml_zarr_pyramid
9+
10+
11+
@pytest.mark.integration
12+
def test_stitched_image_collection_pyramid(tmp_path: Path) -> None:
13+
if shutil.which("git") is None:
14+
pytest.skip("git not available")
15+
16+
repo = PolusTestDataRepo(repo_dir=tmp_path / "polus-test-data")
17+
out_dir = tmp_path / "out"
18+
19+
generate_pyramid_from_repo_path(
20+
repo=repo,
21+
file_pattern="x{x:d}_y{y:d}_c{c:d}.ome.tiff",
22+
image_name="stitched_image",
23+
output_dir=out_dir,
24+
min_dim=1024,
25+
vis_type="Viv",
26+
downsample_methods={1: "mean"},
27+
)
28+
29+
shapes = assert_is_argolid_omexml_zarr_pyramid(out_dir / "stitched_image.zarr" , expect_levels=2)
30+
31+
# ensure at least one spatial dimension shrinks between level 0 and 1
32+
y0, x0 = shapes[0][-2], shapes[0][-1]
33+
y1, x1 = shapes[1][-2], shapes[1][-1]
34+
assert (y1 < y0) or (x1 < x0), f"Expected level 1 to be downsampled vs level 0, got L0={shapes[0]} L1={shapes[1]}"

tests/python/test_single_image.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import shutil
2+
from pathlib import Path
3+
4+
import pytest
5+
6+
from .io_utils import PolusTestDataRepo
7+
from .single_image import generate_pyramid_from_repo_file
8+
from .zarr_assertions import assert_is_argolid_omexml_zarr_pyramid
9+
10+
11+
@pytest.mark.integration
12+
def test_single_image_pyramid(tmp_path: Path) -> None:
13+
if shutil.which("git") is None:
14+
pytest.skip("git not available")
15+
16+
repo = PolusTestDataRepo(repo_dir=tmp_path / "polus-test-data")
17+
out_dir = tmp_path / "out" / "single_image"
18+
19+
generate_pyramid_from_repo_file(
20+
repo=repo,
21+
rel_image_path=Path("argolid") / "x0_y0_c1.ome.tiff",
22+
output_dir=out_dir,
23+
)
24+
25+
shapes = assert_is_argolid_omexml_zarr_pyramid(out_dir, expect_levels=2)
26+
shapes = assert_is_argolid_omexml_zarr_pyramid(out_dir, expect_levels=2)
27+
28+
# ensure at least one spatial dimension shrinks between level 0 and 1
29+
y0, x0 = shapes[0][-2], shapes[0][-1]
30+
y1, x1 = shapes[1][-2], shapes[1][-1]
31+
assert (y1 < y0) or (x1 < x0), f"Expected level 1 to be downsampled vs level 0, got L0={shapes[0]} L1={shapes[1]}"
32+

tests/python/zarr_assertions.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
import zarr
5+
6+
7+
def find_argolid_root(out_dir: Path) -> Path:
8+
"""
9+
Find the Argolid output root directory under out_dir.
10+
The root is identified by:
11+
- directory ending with .zarr or .ome.zarr
12+
- containing METADATA.ome.xml
13+
- containing data.zarr/
14+
"""
15+
out_dir = out_dir.resolve()
16+
17+
if not out_dir.exists():
18+
raise FileNotFoundError(f"Output directory does not exist: {out_dir}")
19+
20+
# If caller passed the root itself
21+
if (
22+
out_dir.is_dir()
23+
and out_dir.suffix == ".zarr"
24+
and (out_dir / "METADATA.ome.xml").exists()
25+
and (out_dir / "data.zarr").is_dir()
26+
):
27+
return out_dir
28+
29+
# Otherwise search one level down
30+
candidates = []
31+
for p in out_dir.iterdir():
32+
if not p.is_dir():
33+
continue
34+
if p.suffix != ".zarr":
35+
continue
36+
if not (p / "METADATA.ome.xml").exists():
37+
continue
38+
if not (p / "data.zarr").is_dir():
39+
continue
40+
candidates.append(p)
41+
42+
if len(candidates) == 1:
43+
return candidates[0]
44+
45+
if len(candidates) > 1:
46+
# deterministic choice, newest wins
47+
return max(candidates, key=lambda p: p.stat().st_mtime)
48+
49+
raise FileNotFoundError(
50+
f"No Argolid zarr output found under {out_dir}. "
51+
f"Expected a *.zarr directory containing METADATA.ome.xml and data.zarr/"
52+
)
53+
54+
55+
def open_argolid_data_group(argolid_root: Path) -> zarr.Group:
56+
"""
57+
Open the data.zarr group inside Argolid output.
58+
"""
59+
data_path = argolid_root / "data.zarr"
60+
if not data_path.exists():
61+
raise FileNotFoundError(f"Missing data.zarr at {data_path}")
62+
return zarr.open_group(str(data_path), mode="r")
63+
64+
65+
def assert_is_argolid_omexml_zarr_pyramid(out_dir: Path, expect_levels: int | None = None) -> list[tuple[int, ...]]:
66+
"""
67+
Validate Argolid-style output:
68+
<name>.ome.zarr/METADATA.ome.xml
69+
<name>.ome.zarr/data.zarr/0/<level>/...
70+
71+
Returns:
72+
Shapes for each pyramid level found under data.zarr/0/
73+
"""
74+
root = find_argolid_root(out_dir)
75+
76+
# METADATA.ome.xml exists and non-empty
77+
ome_xml = root / "METADATA.ome.xml"
78+
assert ome_xml.exists(), f"Missing {ome_xml}"
79+
assert ome_xml.stat().st_size > 0, f"Empty metadata file: {ome_xml}"
80+
81+
data = open_argolid_data_group(root)
82+
83+
# Argolid stores pyramids under "0/<level>"
84+
assert "0" in data, f"Expected '0' in data.zarr. keys={list(data.keys())}"
85+
series0 = data["0"]
86+
assert isinstance(series0, zarr.Group)
87+
88+
# levels can be arrays or groups, depending on how the writer stored them
89+
level_names = sorted(
90+
[k for k in series0.keys() if str(k).isdigit()],
91+
key=lambda s: int(s),
92+
)
93+
94+
assert level_names, (
95+
f"No pyramid levels found under data.zarr/0. "
96+
f"keys={list(series0.keys())}, groups={list(series0.group_keys())}, arrays={list(series0.array_keys())}"
97+
)
98+
99+
level_strs = {str(k) for k in level_names}
100+
assert "0" in level_strs, f"Missing level 0. Levels found: {level_names}"
101+
102+
103+
if expect_levels is not None:
104+
assert len(level_names) >= expect_levels, f"Expected at least {expect_levels} level(s), found {len(level_names)}: {level_names}"
105+
106+
shapes: list[tuple[int, ...]] = []
107+
for lvl in level_names:
108+
node = series0[str(lvl)]
109+
110+
# Level can be a direct array: data.zarr/0/<lvl>
111+
if isinstance(node, zarr.Array):
112+
shapes.append(node.shape)
113+
continue
114+
115+
# Or a group containing one or more arrays: data.zarr/0/<lvl>/<array>
116+
if isinstance(node, zarr.Group):
117+
array_keys = list(node.array_keys())
118+
assert array_keys, f"No arrays found in level group {lvl}. keys={list(node.keys())}"
119+
arr = node[array_keys[0]]
120+
shapes.append(arr.shape)
121+
continue
122+
123+
raise AssertionError(f"Unexpected type at level {lvl}: {type(node)}")
124+
125+
# after shapes computed:
126+
assert shapes, "No shapes collected from pyramid levels"
127+
y0, x0 = shapes[0][-2], shapes[0][-1]
128+
assert y0 > 0 and x0 > 0, "Each array must have non-zero shape"
129+
130+
# pyramid monotonicity: dims should not increase
131+
for prev, nxt in zip(shapes, shapes[1:]):
132+
assert len(prev) == len(nxt), f"Rank changed: {prev} -> {nxt}"
133+
assert all(n <= p for p, n in zip(prev, nxt)), f"Not a pyramid: {prev} -> {nxt}"
134+
135+
return shapes

0 commit comments

Comments
 (0)