|
| 1 | +import json |
| 2 | + |
| 3 | +import numpy as np |
| 4 | +import pytest |
| 5 | +from PIL import Image, ImageChops |
| 6 | + |
| 7 | +import tiledb |
| 8 | +from tests import assert_image_similarity, get_path, get_schema |
| 9 | +from tiledb.bioimg.converters import DATASET_TYPE, FMT_VERSION |
| 10 | +from tiledb.bioimg.converters.png import PNGConverter |
| 11 | +from tiledb.bioimg.helpers import open_bioimg |
| 12 | +from tiledb.bioimg.openslide import TileDBOpenSlide |
| 13 | +from tiledb.cc import WebpInputFormat |
| 14 | + |
| 15 | + |
| 16 | +def create_synthetic_image( |
| 17 | + mode="RGB", width=100, height=100, filename="synthetic_image.png" |
| 18 | +): |
| 19 | + """ |
| 20 | + Creates a synthetic image with either RGB or RGBA channels and saves it as a PNG file. |
| 21 | +
|
| 22 | + Parameters: |
| 23 | + - image_type: 'RGB' for 3 channels, 'RGBA' for 4 channels. |
| 24 | + - width: width of the image. |
| 25 | + - height: height of the image. |
| 26 | + - filename: filename to store the image as a PNG. |
| 27 | + """ |
| 28 | + if mode == "RGB": |
| 29 | + # Create a (height, width, 3) NumPy array with random values for RGB |
| 30 | + data = np.random.randint(0, 256, (height, width, 3), dtype=np.uint8) |
| 31 | + elif mode == "RGBA": |
| 32 | + # Create a (height, width, 4) NumPy array with random values for RGBA |
| 33 | + data = np.random.randint(0, 256, (height, width, 4), dtype=np.uint8) |
| 34 | + else: |
| 35 | + raise ValueError("Other image type are tested with sample images.") |
| 36 | + # Convert NumPy array to a Pillow Image |
| 37 | + image = Image.fromarray(data, mode) |
| 38 | + # Save the image as a PNG |
| 39 | + image.save(filename) |
| 40 | + return filename |
| 41 | + |
| 42 | + |
| 43 | +def test_png_converter(tmp_path): |
| 44 | + input_path = str(get_path("pngs/PNG_1_L.png")) |
| 45 | + output_path = str(tmp_path) |
| 46 | + |
| 47 | + PNGConverter.to_tiledb(input_path, output_path) |
| 48 | + |
| 49 | + with TileDBOpenSlide(output_path) as t: |
| 50 | + assert len(tiledb.Group(output_path)) == t.level_count == 1 |
| 51 | + schemas = get_schema(1080, 1080, c_size=1) |
| 52 | + # Storing the images as 3-channel images in CYX format |
| 53 | + # the slicing below using negative indexes to extract |
| 54 | + # the last two elements in schema's shape. |
| 55 | + assert t.dimensions == schemas.shape[:-3:-1] |
| 56 | + for i in range(t.level_count): |
| 57 | + assert t.level_dimensions[i] == schemas.shape[:-3:-1] |
| 58 | + with open_bioimg(str(tmp_path / f"l_{i}.tdb")) as A: |
| 59 | + assert A.schema == schemas |
| 60 | + |
| 61 | + region = t.read_region(level=0, location=(100, 100), size=(300, 400)) |
| 62 | + assert isinstance(region, np.ndarray) |
| 63 | + assert region.ndim == 3 |
| 64 | + assert region.dtype == np.uint8 |
| 65 | + img = Image.fromarray(region.squeeze()) |
| 66 | + assert img.size == (300, 400) |
| 67 | + |
| 68 | + for level in range(t.level_count): |
| 69 | + region_data = t.read_region((0, 0), level, t.level_dimensions[level]) |
| 70 | + level_data = t.read_level(level) |
| 71 | + np.testing.assert_array_equal(region_data, level_data) |
| 72 | + |
| 73 | + |
| 74 | +@pytest.mark.parametrize("filename", ["pngs/PNG_1_L.png"]) |
| 75 | +def test_png_converter_group_metadata(tmp_path, filename): |
| 76 | + input_path = get_path(filename) |
| 77 | + tiledb_path = str(tmp_path / "to_tiledb") |
| 78 | + PNGConverter.to_tiledb(input_path, tiledb_path, preserve_axes=False) |
| 79 | + |
| 80 | + with TileDBOpenSlide(tiledb_path) as t: |
| 81 | + group_properties = t.properties |
| 82 | + assert group_properties["dataset_type"] == DATASET_TYPE |
| 83 | + assert group_properties["fmt_version"] == FMT_VERSION |
| 84 | + assert isinstance(group_properties["pkg_version"], str) |
| 85 | + assert group_properties["axes"] == "XY" |
| 86 | + assert group_properties["channels"] == json.dumps(["GRAYSCALE"]) |
| 87 | + |
| 88 | + levels_group_meta = json.loads(group_properties["levels"]) |
| 89 | + assert t.level_count == len(levels_group_meta) |
| 90 | + for level, level_meta in enumerate(levels_group_meta): |
| 91 | + assert level_meta["level"] == level |
| 92 | + assert level_meta["name"] == f"l_{level}.tdb" |
| 93 | + |
| 94 | + level_axes = level_meta["axes"] |
| 95 | + shape = level_meta["shape"] |
| 96 | + level_width, level_height = t.level_dimensions[level] |
| 97 | + assert level_axes == "YX" |
| 98 | + assert len(shape) == len(level_axes) |
| 99 | + assert shape[level_axes.index("X")] == level_width |
| 100 | + assert shape[level_axes.index("Y")] == level_height |
| 101 | + |
| 102 | + |
| 103 | +def compare_png(p1: Image, p2: Image, lossless: bool = True): |
| 104 | + if lossless: |
| 105 | + diff = ImageChops.difference(p1, p2) |
| 106 | + assert diff.getbbox() is None |
| 107 | + else: |
| 108 | + try: |
| 109 | + # Default min_threshold is 0.95 |
| 110 | + assert_image_similarity(np.array(p1), np.array(p2), channel_axis=-1) |
| 111 | + except AssertionError: |
| 112 | + try: |
| 113 | + # for PNGs the min_threshold for WEBP lossy is < 0.85 |
| 114 | + assert_image_similarity( |
| 115 | + np.array(p1), np.array(p2), min_threshold=0.84, channel_axis=-1 |
| 116 | + ) |
| 117 | + except AssertionError: |
| 118 | + assert False |
| 119 | + |
| 120 | + |
| 121 | +# PIL.Image does not support chunked reads/writes |
| 122 | +@pytest.mark.parametrize("preserve_axes", [False, True]) |
| 123 | +@pytest.mark.parametrize("chunked", [False]) |
| 124 | +@pytest.mark.parametrize( |
| 125 | + "compressor, lossless", |
| 126 | + [ |
| 127 | + (tiledb.ZstdFilter(level=0), True), |
| 128 | + (tiledb.WebpFilter(WebpInputFormat.WEBP_RGB, lossless=True), True), |
| 129 | + (tiledb.WebpFilter(WebpInputFormat.WEBP_NONE, lossless=True), True), |
| 130 | + ], |
| 131 | +) |
| 132 | +@pytest.mark.parametrize( |
| 133 | + "mode, width, height", |
| 134 | + [ |
| 135 | + ("RGB", 200, 200), # Square RGB image |
| 136 | + ("RGB", 150, 100), # Uneven dimensions |
| 137 | + ("RGB", 50, 150), # Tall image |
| 138 | + ], |
| 139 | +) |
| 140 | +def test_png_converter_RGB_roundtrip( |
| 141 | + tmp_path, preserve_axes, chunked, compressor, lossless, mode, width, height |
| 142 | +): |
| 143 | + |
| 144 | + input_path = str(tmp_path / f"test_{mode.lower()}_image_{width}x{height}.png") |
| 145 | + # Call the function to create a synthetic image |
| 146 | + create_synthetic_image(mode=mode, width=width, height=height, filename=input_path) |
| 147 | + tiledb_path = str(tmp_path / "to_tiledb") |
| 148 | + output_path = str(tmp_path / "from_tiledb") |
| 149 | + PNGConverter.to_tiledb( |
| 150 | + input_path, |
| 151 | + tiledb_path, |
| 152 | + preserve_axes=preserve_axes, |
| 153 | + chunked=chunked, |
| 154 | + compressor=compressor, |
| 155 | + log=False, |
| 156 | + ) |
| 157 | + # Store it back to PNG |
| 158 | + PNGConverter.from_tiledb(tiledb_path, output_path) |
| 159 | + compare_png(Image.open(input_path), Image.open(output_path), lossless=lossless) |
| 160 | + |
| 161 | + |
| 162 | +@pytest.mark.parametrize("filename", ["pngs/PNG_1_L.png"]) |
| 163 | +@pytest.mark.parametrize("preserve_axes", [False, True]) |
| 164 | +@pytest.mark.parametrize("chunked", [False]) |
| 165 | +@pytest.mark.parametrize( |
| 166 | + "compressor, lossless", |
| 167 | + [ |
| 168 | + (tiledb.ZstdFilter(level=0), True), |
| 169 | + # WEBP is not supported for Grayscale images |
| 170 | + ], |
| 171 | +) |
| 172 | +def test_png_converter_L_roundtrip( |
| 173 | + tmp_path, preserve_axes, chunked, compressor, lossless, filename |
| 174 | +): |
| 175 | + # For lossy WEBP we cannot use random generated images as they have so much noise |
| 176 | + input_path = str(get_path(filename)) |
| 177 | + tiledb_path = str(tmp_path / "to_tiledb") |
| 178 | + output_path = str(tmp_path / "from_tiledb") |
| 179 | + |
| 180 | + PNGConverter.to_tiledb( |
| 181 | + input_path, |
| 182 | + tiledb_path, |
| 183 | + preserve_axes=preserve_axes, |
| 184 | + chunked=chunked, |
| 185 | + compressor=compressor, |
| 186 | + log=False, |
| 187 | + ) |
| 188 | + # Store it back to PNG |
| 189 | + PNGConverter.from_tiledb(tiledb_path, output_path) |
| 190 | + compare_png(Image.open(input_path), Image.open(output_path), lossless=lossless) |
| 191 | + |
| 192 | + |
| 193 | +@pytest.mark.parametrize("filename", ["pngs/PNG_2_RGB.png"]) |
| 194 | +@pytest.mark.parametrize("preserve_axes", [False, True]) |
| 195 | +@pytest.mark.parametrize("chunked", [False]) |
| 196 | +@pytest.mark.parametrize( |
| 197 | + "compressor, lossless", |
| 198 | + [ |
| 199 | + (tiledb.WebpFilter(WebpInputFormat.WEBP_RGB, lossless=False), False), |
| 200 | + ], |
| 201 | +) |
| 202 | +def test_png_converter_RGB_roundtrip_lossy( |
| 203 | + tmp_path, preserve_axes, chunked, compressor, lossless, filename |
| 204 | +): |
| 205 | + # For lossy WEBP we cannot use random generated images as they have so much noise |
| 206 | + input_path = str(get_path(filename)) |
| 207 | + tiledb_path = str(tmp_path / "to_tiledb") |
| 208 | + output_path = str(tmp_path / "from_tiledb") |
| 209 | + |
| 210 | + PNGConverter.to_tiledb( |
| 211 | + input_path, |
| 212 | + tiledb_path, |
| 213 | + preserve_axes=preserve_axes, |
| 214 | + chunked=chunked, |
| 215 | + compressor=compressor, |
| 216 | + log=False, |
| 217 | + ) |
| 218 | + # Store it back to PNG |
| 219 | + PNGConverter.from_tiledb(tiledb_path, output_path) |
| 220 | + compare_png(Image.open(input_path), Image.open(output_path), lossless=lossless) |
| 221 | + |
| 222 | + |
| 223 | +@pytest.mark.parametrize("preserve_axes", [False]) |
| 224 | +# PIL.Image does not support chunked reads/writes |
| 225 | +@pytest.mark.parametrize("chunked", [False]) |
| 226 | +@pytest.mark.parametrize( |
| 227 | + "mode, width, height", |
| 228 | + [ |
| 229 | + ("RGBA", 200, 200), # Square RGBA image |
| 230 | + ("RGBA", 300, 150), # Uneven dimensions |
| 231 | + ("RGBA", 120, 240), # Tall image |
| 232 | + ], |
| 233 | +) |
| 234 | +@pytest.mark.parametrize( |
| 235 | + "compressor, lossless", |
| 236 | + [ |
| 237 | + (tiledb.ZstdFilter(level=0), True), |
| 238 | + (tiledb.WebpFilter(WebpInputFormat.WEBP_RGBA, lossless=True), True), |
| 239 | + (tiledb.WebpFilter(WebpInputFormat.WEBP_NONE, lossless=True), True), |
| 240 | + ], |
| 241 | +) |
| 242 | +def test_png_converter_RGBA_roundtrip( |
| 243 | + tmp_path, preserve_axes, chunked, compressor, lossless, mode, width, height |
| 244 | +): |
| 245 | + input_path = str(tmp_path / f"test_{mode.lower()}_image_{width}x{height}.png") |
| 246 | + # Call the function to create a synthetic image |
| 247 | + create_synthetic_image(mode=mode, width=width, height=height, filename=input_path) |
| 248 | + tiledb_path = str(tmp_path / "to_tiledb") |
| 249 | + output_path = str(tmp_path / "from_tiledb") |
| 250 | + PNGConverter.to_tiledb( |
| 251 | + input_path, |
| 252 | + tiledb_path, |
| 253 | + preserve_axes=preserve_axes, |
| 254 | + chunked=chunked, |
| 255 | + compressor=compressor, |
| 256 | + log=False, |
| 257 | + ) |
| 258 | + # Store it back to PNG |
| 259 | + PNGConverter.from_tiledb(tiledb_path, output_path) |
| 260 | + compare_png(Image.open(input_path), Image.open(output_path), lossless=lossless) |
| 261 | + |
| 262 | + |
| 263 | +@pytest.mark.parametrize("filename", ["pngs/PNG_2_RGBA.png"]) |
| 264 | +@pytest.mark.parametrize("preserve_axes", [False, True]) |
| 265 | +@pytest.mark.parametrize("chunked", [False]) |
| 266 | +@pytest.mark.parametrize( |
| 267 | + "compressor, lossless", |
| 268 | + [ |
| 269 | + (tiledb.WebpFilter(WebpInputFormat.WEBP_RGBA, lossless=False), False), |
| 270 | + ], |
| 271 | +) |
| 272 | +def test_png_converter_RGBA_roundtrip_lossy( |
| 273 | + tmp_path, preserve_axes, chunked, compressor, lossless, filename |
| 274 | +): |
| 275 | + # For lossy WEBP we cannot use random generated images as they have so much noise |
| 276 | + input_path = str(get_path(filename)) |
| 277 | + tiledb_path = str(tmp_path / "to_tiledb") |
| 278 | + output_path = str(tmp_path / "from_tiledb") |
| 279 | + |
| 280 | + PNGConverter.to_tiledb( |
| 281 | + input_path, |
| 282 | + tiledb_path, |
| 283 | + preserve_axes=preserve_axes, |
| 284 | + chunked=chunked, |
| 285 | + compressor=compressor, |
| 286 | + log=False, |
| 287 | + ) |
| 288 | + # Store it back to PNG |
| 289 | + PNGConverter.from_tiledb(tiledb_path, output_path) |
| 290 | + compare_png(Image.open(input_path), Image.open(output_path), lossless=lossless) |
0 commit comments