Skip to content

Commit a1d205c

Browse files
authored
fix: support upscaling output space (tlambert03#121)
* fix: support upscaling output space * refactor: update simulation parameters for upscale and downscale handling; improve test coverage * change implementation
1 parent c8c1fb9 commit a1d205c

6 files changed

Lines changed: 72 additions & 9 deletions

File tree

examples/basic_confocal.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from microsim import schema as ms
2-
from microsim.util import ndview, ortho_plot
2+
from microsim.util import ortho_plot
33

44
sim = ms.Simulation(
5-
truth_space=ms.ShapeScaleSpace(shape=(64, 256, 256), scale=(0.04, 0.02, 0.02)),
6-
output_space={"downscale": 4},
5+
truth_space={"upscale": 4},
6+
output_space=ms.ShapeScaleSpace(shape=(16, 64, 64), scale=(0.16, 0.08, 0.08)),
77
sample=[
88
ms.FluorophoreDistribution(
99
distribution=ms.MatsLines(density=0.5, length=30, azimuth=5, max_r=1),
@@ -15,9 +15,7 @@
1515
modality=ms.Confocal(pinhole_au=0.5),
1616
settings=ms.Settings(random_seed=100, max_psf_radius_aus=8),
1717
detector=ms.CameraCCD(qe=0.82, read_noise=2, bit_depth=12),
18-
# output_path="au1.tif",
1918
)
2019

2120
result = sim.run()
22-
ortho_plot(result.data, show=True)
23-
ndview(result)
21+
ortho_plot(result)

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,9 @@ init_typed = false
155155
module = ["microsim.xarray_jax.*"]
156156
ignore_errors = true
157157

158+
[tool.pyright]
159+
reportArgumentType = false # too hard with pydantic
160+
158161
# https://docs.pytest.org/en/6.2.x/customize.html
159162
[tool.pytest.ini_options]
160163
minversion = "7.0"

src/microsim/schema/simulation.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -268,9 +268,9 @@ def digital_image(
268268
optical_image = self.optical_image()
269269
image = optical_image # (C, Z, Y, X)
270270

271-
# downscale to output space
271+
# rescale to output space
272272
# TODO: consider how we would integrate detector pixel size
273-
# rather than a user-sepicified output space
273+
# rather than a user-specified output space
274274
if self.output_space is not None:
275275
logger.info(f"Rescaling to output space {self.output_space.shape}")
276276
image = self.output_space.rescale(image)

src/microsim/schema/space.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from collections.abc import Callable, Sequence
2-
from typing import Any, Protocol, TypeVar
2+
from typing import Any, Protocol, TypeVar, runtime_checkable
33

44
import numpy as np
55
from pydantic import (
@@ -34,6 +34,7 @@ def _wrap_validator(
3434
return np.array(value, dtype=np.float64)
3535

3636

37+
@runtime_checkable
3738
class SpaceProtocol(Protocol):
3839
@property
3940
def axes(self) -> tuple[str, ...]: ...
@@ -72,6 +73,39 @@ def coords(self: SpaceProtocol) -> dict[str, FloatArray]:
7273
class _AxesSpace(_Space):
7374
axes: tuple[Axis, ...] = (Axis.Z, Axis.Y, Axis.X)
7475

76+
def _get_scale_ratios(self, img_space: Any) -> dict[str, int]:
77+
"""Calculate integer scale ratios between two spaces for coarsening."""
78+
if not (
79+
isinstance(img_space, SpaceProtocol) and isinstance(self, SpaceProtocol)
80+
): # pragma: no cover
81+
raise NotImplementedError(
82+
f"Rescaling from {type(img_space)} to {type(self)} is not implemented."
83+
)
84+
85+
if set(self.axes) != set(img_space.axes): # pragma: no cover
86+
raise ValueError(
87+
f"Spaces must have the same axes. Got {self.axes} and {img_space.axes}."
88+
)
89+
# Create axis->scale mappings
90+
self_scales = dict(zip(self.axes, self.scale, strict=True))
91+
img_scales = dict(zip(img_space.axes, img_space.scale, strict=True))
92+
return {
93+
ax: int(self_scales[ax] / img_scales[ax])
94+
for ax in self.axes
95+
if ax in img_scales
96+
}
97+
98+
def rescale(self, img: xrDataArray) -> xrDataArray:
99+
if not (img_space := getattr(img, "space", None)): # pragma: no cover
100+
raise ValueError("Input image must have a 'space' attribute.")
101+
102+
dims = self._get_scale_ratios(img_space)
103+
if any(d < 1 for d in dims.values()): # pragma: no cover
104+
raise NotImplementedError(
105+
f"Can only downscale an image. Got downscale factors {dims}."
106+
)
107+
return img.coarsen(dims).sum() # type: ignore
108+
75109
@field_validator("axes", mode="before")
76110
def _cast_axes(cls, value: Any) -> tuple[Axis, ...]:
77111
return tuple(value)

tests/_util.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import os
12
import socket
23
from collections.abc import Callable
34

45
import pytest
56

67
try:
8+
if os.getenv("MICROSIM_TEST_NO_INTERNET"):
9+
raise OSError("Skipping internet test due to MICROSIM_TEST_NO_INTERNET")
710
socket.create_connection(("8.8.8.8", 53), timeout=1)
811
HAVE_INTERNET = True
912
except OSError:

tests/test_scaling.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from microsim import schema as ms
2+
3+
4+
def test_downscale_truth() -> None:
5+
sample = [
6+
ms.FluorophoreDistribution(
7+
distribution=ms.MatsLines(density=0.5, length=30, azimuth=5, max_r=1),
8+
)
9+
]
10+
11+
sim_down = ms.Simulation(
12+
truth_space=ms.ShapeScaleSpace(shape=(64, 256, 256), scale=(0.04, 0.02, 0.02)),
13+
output_space={"downscale": 4},
14+
sample=sample,
15+
settings=ms.Settings(random_seed=100, max_psf_radius_aus=1),
16+
)
17+
sim_up = ms.Simulation(
18+
truth_space={"upscale": 4},
19+
output_space=ms.ShapeScaleSpace(shape=(16, 64, 64), scale=(0.16, 0.08, 0.08)),
20+
sample=sample,
21+
settings=ms.Settings(random_seed=100, max_psf_radius_aus=1),
22+
)
23+
24+
assert sim_down.ground_truth().equals(sim_up.ground_truth())
25+
assert sim_down.digital_image().identical(sim_up.digital_image())

0 commit comments

Comments
 (0)