Skip to content

Commit f6f6f75

Browse files
authored
Merge pull request #16 from fmi-faim/copilot/add-channel-list-parameter
Add channel_list parameter to sanitize_pixels/sanitize_image
2 parents 659754f + a687ed9 commit f6f6f75

File tree

2 files changed

+131
-69
lines changed

2 files changed

+131
-69
lines changed

src/ome2xarray/companion.py

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import xarray as xr
1212

1313

14-
def sanitize_pixels(image: Image, include_sg: bool = False) -> Pixels:
14+
def sanitize_pixels(image: Image, *, include_sg: bool = False, channel_list: list[str] | None = None) -> Pixels:
1515
"""
1616
Sanitize incomplete/corrupted pixels by regenerating tiff_data_blocks and planes.
1717
@@ -27,6 +27,10 @@ def sanitize_pixels(image: Image, include_sg: bool = False) -> Pixels:
2727
include_sg : bool, optional
2828
If True, include stage group suffix (_sg1, _sg2, etc.) in file names.
2929
Default is False.
30+
channel_list : list[str] | None, optional
31+
List of channel names in the correct order. If provided, this list will be
32+
used instead of pixels.channels to determine channel names.
33+
Default is None (use pixels.channels).
3034
3135
Returns:
3236
--------
@@ -50,11 +54,8 @@ def sanitize_pixels(image: Image, include_sg: bool = False) -> Pixels:
5054
# Parse stage_label.name like "0:Number1_sg:0" or "4:Position5:0"
5155
# Extract the first number after splitting by ":"
5256
parts = image.stage_label.name.split(':')
53-
try:
54-
stage_pos = int(parts[0])
55-
stage_suffix = f"_s{stage_pos + 1}"
56-
except (ValueError, IndexError):
57-
pass
57+
stage_pos = int(parts[0])
58+
stage_suffix = f"_s{stage_pos + 1}"
5859

5960
# Extract stage group if include_sg is True
6061
if include_sg:
@@ -96,24 +97,24 @@ def sanitize_pixels(image: Image, include_sg: bool = False) -> Pixels:
9697

9798
# Track UUIDs per file name to ensure consistency
9899
file_uuids = {}
99-
100-
for c in range(pixels.size_c):
101-
channel = pixels.channels[c]
102-
channel_name = channel.name or f"Channel{c}"
103-
100+
101+
if channel_list is None:
102+
channel_list = [channel.name for channel in pixels.channels]
103+
104+
for c, channel_name in enumerate(channel_list):
104105
# Generate file name for this channel
105106
# Format: {base_name}_w{c+1}{channel_name}{sg_suffix}{stage_suffix}{time_suffix}.ome.tif
106107
file_base = f"{base_name}_w{c+1}{channel_name}{sg_suffix}{stage_suffix}"
107-
108+
108109
for t in range(pixels.size_t):
109110
# Add time suffix only if there are multiple timepoints
110111
time_suffix = f"_t{t+1}" if pixels.size_t > 1 else ""
111112
file_name = f"{file_base}{time_suffix}.ome.tif"
112-
113+
113114
# Generate a unique UUID for this file if not already done
114115
if file_name not in file_uuids:
115116
file_uuids[file_name] = f"urn:uuid:{uuid4()}"
116-
117+
117118
for z in range(pixels.size_z):
118119
# Create TiffData block
119120
tiff_data = TiffData(
@@ -213,7 +214,7 @@ def get_ome_metadata(self) -> OME:
213214
"""
214215
return self._ome
215216

216-
def sanitize_image(self, image_index: int, include_sg: bool = False) -> None:
217+
def sanitize_image(self, image_index: int, include_sg: bool = False, channel_list: list[str] | None = None) -> None:
217218
"""
218219
Sanitize an image's pixels by regenerating tiff_data_blocks and planes.
219220
@@ -227,6 +228,10 @@ def sanitize_image(self, image_index: int, include_sg: bool = False) -> None:
227228
include_sg : bool, optional
228229
If True, include stage group suffix (_sg1, _sg2, etc.) in file names.
229230
Default is False.
231+
channel_list : list[str] | None, optional
232+
List of channel names in the correct order. If provided, this list will be
233+
used instead of pixels.channels to determine channel names. The length must
234+
match pixels.size_c. Default is None (use pixels.channels).
230235
"""
231236
if image_index < 0 or image_index >= len(self._ome.images):
232237
raise IndexError(
@@ -236,7 +241,7 @@ def sanitize_image(self, image_index: int, include_sg: bool = False) -> None:
236241
image = self._ome.images[image_index]
237242

238243
# Sanitize the pixels
239-
sanitized_pixels = sanitize_pixels(image, include_sg=include_sg)
244+
sanitized_pixels = sanitize_pixels(image, include_sg=include_sg, channel_list=channel_list)
240245

241246
# Replace the image's pixels with sanitized version
242247
# We need to create a new image with the sanitized pixels

tests/test_sanitize.py

Lines changed: 110 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -289,29 +289,6 @@ def test_companion_file_sanitize_image_invalid_index():
289289
companion_file.sanitize_image(-1)
290290

291291

292-
def test_sanitize_pixels_channel_without_name():
293-
"""Test sanitization with a channel that has no name."""
294-
pixels = Pixels(
295-
id="Pixels:0",
296-
dimension_order="XYZCT",
297-
type="uint16",
298-
size_x=512,
299-
size_y=512,
300-
size_z=1,
301-
size_c=1,
302-
size_t=1,
303-
channels=[Channel(id="Channel:0", name=None)], # No name
304-
tiff_data_blocks=[],
305-
planes=[]
306-
)
307-
308-
image = Image(id="Image:0", name="test", pixels=pixels)
309-
sanitized = sanitize_pixels(image)
310-
311-
# Should generate a default channel name
312-
assert sanitized.tiff_data_blocks[0].uuid.file_name == "test_w1Channel0.ome.tif"
313-
314-
315292
def test_sanitize_pixels_removes_stage_label_from_image_name():
316293
"""Test that stage label is removed from image.name to form basename."""
317294
pixels = Pixels(
@@ -379,13 +356,17 @@ def test_sanitize_pixels_with_include_sg():
379356
assert sanitized.tiff_data_blocks[0].uuid.file_name == "test_w1DAPI_sg1_s1.ome.tif"
380357

381358

382-
def test_companion_file_filenames_with_and_without_sanitize_sg1():
359+
@pytest.mark.parametrize("filename", [
360+
"20250910_test4ch_2roi_3z_1_sg1.companion.ome",
361+
"20250910_test4ch_2roi_3z_1_sg2.companion.ome"
362+
])
363+
def test_companion_file_filenames_with_and_without_sanitize_sg1(filename):
383364
"""Test that filenames match between original and sanitized (with include_sg=True) for sg1 dataset."""
384365
companion_file_path = (
385366
Path(__file__).parent
386367
/ "resources"
387368
/ "20250910_VV7-0-0-6-ScanSlide"
388-
/ "20250910_test4ch_2roi_3z_1_sg1.companion.ome"
369+
/ filename
389370
)
390371

391372
companion_file = CompanionFile(companion_file_path)
@@ -409,13 +390,13 @@ def test_companion_file_filenames_with_and_without_sanitize_sg1():
409390
)
410391

411392

412-
def test_companion_file_filenames_with_and_without_sanitize_sg2():
413-
"""Test that filenames match between original and sanitized (with include_sg=True) for sg2 dataset."""
393+
def test_companion_file_filenames_without_include_sg():
394+
"""Test that filenames are different when sanitizing without include_sg for sg1/sg2 datasets."""
414395
companion_file_path = (
415396
Path(__file__).parent
416397
/ "resources"
417398
/ "20250910_VV7-0-0-6-ScanSlide"
418-
/ "20250910_test4ch_2roi_3z_1_sg2.companion.ome"
399+
/ "20250910_test4ch_2roi_3z_1_sg1.companion.ome"
419400
)
420401

421402
companion_file = CompanionFile(companion_file_path)
@@ -425,22 +406,99 @@ def test_companion_file_filenames_with_and_without_sanitize_sg2():
425406
original_image = metadata.images[0]
426407
original_filenames = {block.uuid.file_name for block in original_image.pixels.tiff_data_blocks}
427408

428-
# Sanitize with include_sg=True
429-
companion_file.sanitize_image(0, include_sg=True)
409+
# Sanitize without include_sg (default behavior)
410+
companion_file.sanitize_image(0, include_sg=False)
430411
sanitized_metadata = companion_file.get_ome_metadata()
431412
sanitized_image = sanitized_metadata.images[0]
432413
sanitized_filenames = {block.uuid.file_name for block in sanitized_image.pixels.tiff_data_blocks}
433414

434-
# The filenames should match
435-
assert original_filenames == sanitized_filenames, (
436-
f"Filenames don't match.\n"
437-
f"Original: {sorted(original_filenames)}\n"
438-
f"Sanitized: {sorted(sanitized_filenames)}"
415+
# The filenames should NOT match (original has _sg1, sanitized without include_sg doesn't)
416+
assert original_filenames != sanitized_filenames, (
417+
"Filenames should be different when include_sg=False"
439418
)
419+
420+
# Verify that original has _sg1 and sanitized doesn't
421+
for orig_fn in original_filenames:
422+
assert "_sg1_" in orig_fn, f"Original filename should contain _sg1_: {orig_fn}"
423+
424+
for san_fn in sanitized_filenames:
425+
assert "_sg" not in san_fn, f"Sanitized filename should not contain _sg: {san_fn}"
440426

441427

442-
def test_companion_file_filenames_without_include_sg():
443-
"""Test that filenames are different when sanitizing without include_sg for sg1/sg2 datasets."""
428+
def test_sanitize_pixels_with_channel_list():
429+
"""Test sanitization with custom channel_list parameter."""
430+
pixels = Pixels(
431+
id="Pixels:0",
432+
dimension_order="XYZCT",
433+
type="uint16",
434+
size_x=512,
435+
size_y=512,
436+
size_z=1,
437+
size_c=3,
438+
size_t=1,
439+
channels=[
440+
Channel(id="Channel:0", name="Red"),
441+
Channel(id="Channel:1", name="Green"),
442+
Channel(id="Channel:2", name="Blue")
443+
],
444+
tiff_data_blocks=[],
445+
planes=[
446+
Plane(the_c=0, the_t=0, the_z=0, position_x=0.0, position_y=0.0, position_z=0.0),
447+
]
448+
)
449+
450+
image = Image(
451+
id="Image:0",
452+
name="test_image",
453+
pixels=pixels
454+
)
455+
456+
# Sanitize with custom channel list (different order)
457+
channel_list = ["DAPI", "GFP"]
458+
sanitized = sanitize_pixels(image, channel_list=channel_list)
459+
460+
# Check that the custom channel names are used
461+
assert len(sanitized.tiff_data_blocks) == 2
462+
assert sanitized.tiff_data_blocks[0].uuid.file_name == "test_image_w1DAPI.ome.tif"
463+
assert sanitized.tiff_data_blocks[1].uuid.file_name == "test_image_w2GFP.ome.tif"
464+
465+
466+
def test_sanitize_pixels_channel_list_none_uses_default():
467+
"""Test that passing channel_list=None uses the default behavior."""
468+
pixels = Pixels(
469+
id="Pixels:0",
470+
dimension_order="XYZCT",
471+
type="uint16",
472+
size_x=512,
473+
size_y=512,
474+
size_z=1,
475+
size_c=2,
476+
size_t=1,
477+
channels=[
478+
Channel(id="Channel:0", name="OriginalA"),
479+
Channel(id="Channel:1", name="OriginalB")
480+
],
481+
tiff_data_blocks=[],
482+
planes=[]
483+
)
484+
485+
image = Image(
486+
id="Image:0",
487+
name="test_image",
488+
pixels=pixels
489+
)
490+
491+
# Sanitize with channel_list=None (explicit)
492+
sanitized = sanitize_pixels(image, channel_list=None)
493+
494+
# Check that the original channel names are used
495+
assert len(sanitized.tiff_data_blocks) == 2
496+
assert sanitized.tiff_data_blocks[0].uuid.file_name == "test_image_w1OriginalA.ome.tif"
497+
assert sanitized.tiff_data_blocks[1].uuid.file_name == "test_image_w2OriginalB.ome.tif"
498+
499+
500+
def test_companion_file_sanitize_image_with_channel_list():
501+
"""Test CompanionFile.sanitize_image with channel_list parameter."""
444502
companion_file_path = (
445503
Path(__file__).parent
446504
/ "resources"
@@ -451,24 +509,23 @@ def test_companion_file_filenames_without_include_sg():
451509
companion_file = CompanionFile(companion_file_path)
452510
metadata = companion_file.get_ome_metadata()
453511

454-
# Get the first image
512+
# Get original channel names for the first image
455513
original_image = metadata.images[0]
456-
original_filenames = {block.uuid.file_name for block in original_image.pixels.tiff_data_blocks}
514+
original_channels = [ch.name for ch in original_image.pixels.channels]
457515

458-
# Sanitize without include_sg (default behavior)
459-
companion_file.sanitize_image(0, include_sg=False)
460-
sanitized_metadata = companion_file.get_ome_metadata()
461-
sanitized_image = sanitized_metadata.images[0]
462-
sanitized_filenames = {block.uuid.file_name for block in sanitized_image.pixels.tiff_data_blocks}
516+
# Create a custom channel list with different names
517+
custom_channel_list = [f"Custom{i}" for i in range(len(original_channels))]
463518

464-
# The filenames should NOT match (original has _sg1, sanitized without include_sg doesn't)
465-
assert original_filenames != sanitized_filenames, (
466-
"Filenames should be different when include_sg=False"
467-
)
519+
# Sanitize with custom channel list
520+
companion_file.sanitize_image(0, channel_list=custom_channel_list)
468521

469-
# Verify that original has _sg1 and sanitized doesn't
470-
for orig_fn in original_filenames:
471-
assert "_sg1_" in orig_fn, f"Original filename should contain _sg1_: {orig_fn}"
522+
# Get sanitized metadata
523+
sanitized_metadata = companion_file.get_ome_metadata()
524+
sanitized_image = sanitized_metadata.images[0]
472525

473-
for san_fn in sanitized_filenames:
474-
assert "_sg" not in san_fn, f"Sanitized filename should not contain _sg: {san_fn}"
526+
# Check that custom channel names are used in filenames
527+
for block in sanitized_image.pixels.tiff_data_blocks:
528+
# The filename should contain one of the custom channel names
529+
assert any(custom_ch in block.uuid.file_name for custom_ch in custom_channel_list), (
530+
f"Filename {block.uuid.file_name} doesn't contain any custom channel name"
531+
)

0 commit comments

Comments
 (0)