Skip to content

Commit 8fc45bb

Browse files
authored
Support ingestion of PNG images (#136)
* Initial working ingestion of PNG images * Adding png tests * PR fixes * Fix based on Julia's case * Addressing Julia's/Xantho's comments * Solution 1 * Rebase with #133
1 parent e84e83b commit 8fc45bb

File tree

10 files changed

+580
-26
lines changed

10 files changed

+580
-26
lines changed

setup.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,18 @@
3333
"tiff_reader = tiledb.bioimg.converters.ome_tiff:OMETiffReader",
3434
"zarr_reader = tiledb.bioimg.converters.ome_zarr:OMEZarrReader",
3535
"osd_reader = tiledb.bioimg.converters.openslide:OpenSlideReader",
36+
"png_reader = tiledb.bioimg.converters.png.PNGReader",
3637
],
3738
"bioimg.writers": [
3839
"tiff_writer = tiledb.bioimg.converters.ome_tiff:OMETiffWriter",
3940
"zarr_writer = tiledb.bioimg.converters.ome_tiff:OMEZarrWriter",
41+
"png_writer = tiledb.bioimg.converters.png.PNGWriter",
4042
],
4143
"bioimg.converters": [
4244
"tiff_converter = tiledb.bioimg.converters.ome_tiff:OMETiffConverter",
4345
"zarr_converter = tiledb.bioimg.converters.ome_zarr:OMEZarrConverter",
4446
"osd_converter = tiledb.bioimg.converters.openslide:OpenSlideConverter",
47+
"png_converter = tiledb.bioimg.converters.png:PNGConverter",
4548
],
4649
},
4750
)

tests/__init__.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,16 @@ def get_schema(x_size, y_size, c_size=3, compressor=tiledb.ZstdFilter(level=0)):
3636
lossless=compressor.lossless,
3737
)
3838
else:
39-
dims.append(
40-
tiledb.Dim(
41-
"C",
42-
(0, c_size - 1),
43-
tile=c_size,
44-
dtype=np.uint32,
45-
filters=tiledb.FilterList([compressor]),
39+
if c_size > 1:
40+
dims.append(
41+
tiledb.Dim(
42+
"C",
43+
(0, c_size - 1),
44+
tile=c_size,
45+
dtype=np.uint32,
46+
filters=tiledb.FilterList([compressor]),
47+
)
4648
)
47-
)
4849

4950
dims.append(
5051
tiledb.Dim(

tests/data/pngs/PNG_1_L.png

29.8 KB
Loading

tests/data/pngs/PNG_2_RGB.png

2.79 MB
Loading

tests/data/pngs/PNG_2_RGBA.png

2.94 MB
Loading
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
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)

tiledb/bioimg/__init__.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from typing import Optional
1111
from .types import Converters
1212

13-
osd_exc: Optional[ImportError]
13+
_osd_exc: Optional[ImportError]
1414

1515
if hasattr(os, "add_dll_directory"):
1616
# Python >= 3.8 on Windows
@@ -22,9 +22,9 @@
2222
"Openslide Converter requires 'openslide-python' package. "
2323
"You can install 'tiledb-bioimg' with the 'openslide' or 'full' flag"
2424
)
25-
osd_exc = err_osd
25+
_osd_exc = err_osd
2626
else:
27-
osd_exc = None
27+
_osd_exc = None
2828
else:
2929
try:
3030
importlib.util.find_spec("openslide")
@@ -33,8 +33,8 @@
3333
"Openslide Converter requires 'openslide-python' package. "
3434
"You can install 'tiledb-bioimg' with the 'openslide' or 'full' flag"
3535
)
36-
osd_exc = err_osd
36+
_osd_exc = err_osd
3737
else:
38-
osd_exc = None
38+
_osd_exc = None
3939

4040
from .wrappers import *

0 commit comments

Comments
 (0)