Skip to content

Commit 6960c4c

Browse files
Pad images with 0 on right and bottom, if images differ in dimensions (#371)
* Pad images with 0 on right and bottom, if images differ in dimensions * Add missing autofluorescence entry. Skip empty folders when parsing multiple subfolders. * Add test for skipping empty subfolder * code style fixes; more docstrings * fix mistake in comments * Obey translations stored in metadata for padding * Forgot to remove one skip decorator * remove napari dependencies --------- Co-authored-by: Luca Marconato <m.lucalmer@gmail.com>
1 parent 5c43217 commit 6960c4c

2 files changed

Lines changed: 307 additions & 26 deletions

File tree

src/spatialdata_io/readers/macsima.py

Lines changed: 108 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ class ChannelMetadata:
6161
roi: int
6262
fluorophore: str
6363
exposure: float
64+
translation_x: int
65+
translation_y: int
6466
clone: str | None = None # For example DAPI doesnt have a clone
6567

6668

@@ -103,6 +105,8 @@ def from_paths(
103105
fluorophore=metadata["fluorophore"],
104106
clone=metadata["clone"],
105107
exposure=metadata["exposure"],
108+
translation_x=metadata["translation_x"],
109+
translation_y=metadata["translation_y"],
106110
)
107111
)
108112

@@ -125,12 +129,11 @@ def from_paths(
125129
),
126130
)
127131
imgs = [imread(img, **imread_kwargs) for img in valid_files]
128-
for img, path in zip(imgs, valid_files, strict=True):
129-
if img.shape[1:] != imgs[0].shape[1:]:
130-
raise ValueError(
131-
f"Images are not all the same size. Image {path} has shape {img.shape[1:]} while the first image "
132-
f"{valid_files[0]} has shape {imgs[0].shape[1:]}"
133-
)
132+
133+
# Pad images to same dimensions if necessary
134+
if cls._check_for_differing_xy_dimensions(imgs):
135+
imgs = cls._pad_images(imgs, channel_metadata)
136+
134137
# create MultiChannelImage object with imgs and metadata
135138
output = cls(data=imgs, metadata=channel_metadata)
136139
return output
@@ -220,6 +223,77 @@ def calc_scale_factors(self, default_scale_factor: int = 2) -> list[int]:
220223
def get_stack(self) -> da.Array:
221224
return da.stack(self.data, axis=0).squeeze(axis=1)
222225

226+
@staticmethod
227+
def _check_for_differing_xy_dimensions(imgs: list[da.Array]) -> bool:
228+
"""Checks whether any of the images have differing extent in dimensions X and Y."""
229+
# Shape has order CYX
230+
dims_x = [x.shape[2] for x in imgs]
231+
dims_y = [x.shape[1] for x in imgs]
232+
233+
dims_x_different = len(set(dims_x)) != 1
234+
dims_y_different = len(set(dims_y)) != 1
235+
236+
different_dimensions = any([dims_x_different, dims_y_different])
237+
238+
warnings.warn(
239+
"Supplied images have different dimensions!",
240+
UserWarning,
241+
stacklevel=2,
242+
)
243+
244+
return different_dimensions
245+
246+
@staticmethod
247+
def _pad_images(imgs: list[da.Array], channel_metadata: list[ChannelMetadata]) -> list[da.Array]:
248+
"""Pad all images to the same dimensions in X and Y with 0s.
249+
250+
Padding obeys translations stored in ome metadata.
251+
If no translations are found, padding is added only away from the origin:
252+
on the right side for X and at the
253+
bottom for Y, so the top-left corner of each image stays aligned.
254+
"""
255+
min_translation_x = min(metadata.translation_x for metadata in channel_metadata)
256+
min_translation_y = min(metadata.translation_y for metadata in channel_metadata)
257+
normalized_translations_x = [metadata.translation_x - min_translation_x for metadata in channel_metadata]
258+
normalized_translations_y = [metadata.translation_y - min_translation_y for metadata in channel_metadata]
259+
260+
dims_x_max = max(
261+
img.shape[2] + translation_x for img, translation_x in zip(imgs, normalized_translations_x, strict=True)
262+
)
263+
dims_y_max = max(
264+
img.shape[1] + translation_y for img, translation_y in zip(imgs, normalized_translations_y, strict=True)
265+
)
266+
267+
warnings.warn(
268+
f"Padding images with 0s to same size of ({dims_y_max}, {dims_x_max})",
269+
UserWarning,
270+
stacklevel=2,
271+
)
272+
273+
padded_imgs = []
274+
for img, translation_x, translation_y in zip(
275+
imgs, normalized_translations_x, normalized_translations_y, strict=True
276+
):
277+
pad_y_prepend = translation_y
278+
pad_x_prepend = translation_x
279+
# For appending, we check how much space is already covered by the original image, and we take into account how many pixels were prepended
280+
# If there still is a difference, we append 0 to get to the same image size
281+
pad_y_append = dims_y_max - img.shape[1] - pad_y_prepend
282+
pad_x_append = dims_x_max - img.shape[2] - pad_x_prepend
283+
# Only pad if necessary
284+
if (pad_x_prepend, pad_x_append, pad_y_prepend, pad_y_append) != (0, 0, 0, 0):
285+
pad_width = (
286+
# c axis: no pad
287+
(0, 0),
288+
(pad_y_prepend, pad_y_append),
289+
(pad_x_prepend, pad_x_append),
290+
)
291+
292+
img = da.pad(img, pad_width, mode="constant", constant_values=0)
293+
padded_imgs.append(img)
294+
295+
return padded_imgs
296+
223297

224298
def macsima(
225299
path: str | Path,
@@ -244,6 +318,8 @@ def macsima(
244318
This function reads images from a MACSima cyclic imaging experiment. MACSima data follows the OME-TIFF specificiation.
245319
All metadata is parsed from the OME metadata. The exact metadata schema can change between software versions of MACSiQView.
246320
As there is no public specification of the metadata fields used, please consider the provided test data sets as ground truth to guide development.
321+
If images from different cycles differ in spatial dimensions, they are zero-padded on the right (X) and bottom (Y) to match
322+
the largest dimensions, keeping the top-left origin aligned; a warning is emitted in that case.
247323
248324
.. seealso::
249325
@@ -412,6 +488,25 @@ def _get_software_major_version(version: str) -> int:
412488
return major
413489

414490

491+
def _get_translations(ome: OME) -> dict[str, int]:
492+
try:
493+
translations = {
494+
"translation_x": ome.images[0].pixels.planes[0].position_x,
495+
"translation_y": ome.images[0].pixels.planes[0].position_y,
496+
}
497+
# If the position attributes are not present the values will be None and we default to (0,0)
498+
if any(v is None for v in translations.values()):
499+
logger.debug(f"No translation found for {ome.images[0].name}, defaulting to (0, 0)")
500+
translations = {"translation_x": 0, "translation_y": 0}
501+
502+
# In case the ome is faulty, also default to (0,0)
503+
except AttributeError:
504+
logger.debug(f"No translation found for {ome.images[0].name}, defaulting to (0, 0)")
505+
translations = {"translation_x": 0, "translation_y": 0}
506+
507+
return translations
508+
509+
415510
def _parse_v0_ome_metadata(ome: OME) -> dict[str, Any]:
416511
"""Parse Legacy Format of OME Metadata (software version 0.x.x)."""
417512
logger.debug("Parsing OME metadata expecting version 0 format")
@@ -589,12 +684,16 @@ def _parse_ome_metadata(ome: OME) -> dict[str, Any]:
589684
major = _get_software_major_version(version_str)
590685

591686
if major == 0:
592-
return _parse_v0_ome_metadata(ome)
687+
metadata = _parse_v0_ome_metadata(ome)
593688
elif major == 1:
594-
return _parse_v1_ome_metadata(ome)
689+
metadata = _parse_v1_ome_metadata(ome)
595690
else:
596691
raise ValueError("Unknown software version, cannot determine parser")
597692

693+
translations = _get_translations(ome)
694+
metadata.update(translations)
695+
return metadata
696+
598697

599698
def parse_metadata(path: Path) -> dict[str, Any]:
600699
"""Parse metadata for a file.
@@ -757,6 +856,7 @@ def create_table(mci: MultiChannelImage) -> ad.AnnData:
757856
clones = mci.get_clones()
758857
exposures = mci.get_exposures()
759858

859+
# We dont add the translations, because they are usually not interesting for the user
760860
df = pd.DataFrame(
761861
{
762862
"name": names,

0 commit comments

Comments
 (0)