Diff of /tests/test_wsic.py [000000] .. [fde104]

Switch to unified view

a b/tests/test_wsic.py
1
"""Tests for `wsic` package."""
2
import sys
3
import warnings
4
from pathlib import Path
5
from typing import Dict
6
7
import cv2 as _cv2  # Avoid adding "cv2" to sys.modules for fallback tests
8
import numpy as np
9
import pytest
10
import tifffile
11
import zarr
12
13
from wsic import readers, utils, writers
14
from wsic.enums import Codec, ColorSpace
15
from wsic.readers import Reader
16
from wsic.writers import Writer
17
18
19
def test_jp2_to_deflate_tiled_tiff(samples_path, tmp_path):
20
    """Test that we can convert a JP2 to a DEFLATE compressed tiled TIFF."""
21
    with warnings.catch_warnings():
22
        warnings.simplefilter("error")
23
24
        reader = readers.Reader.from_file(samples_path / "XYC.jp2")
25
        writer = writers.TIFFWriter(
26
            path=tmp_path / "XYC.tiff",
27
            shape=reader.shape,
28
            overwrite=False,
29
            tile_size=(256, 256),
30
            codec="deflate",
31
            microns_per_pixel=(0.5, 0.5),  # the input .jp2 has no resolution box
32
        )
33
        writer.copy_from_reader(reader=reader, num_workers=3, read_tile_size=(512, 512))
34
35
    assert writer.path.exists()
36
    assert writer.path.is_file()
37
    assert writer.path.stat().st_size > 0
38
39
    output = tifffile.imread(writer.path)
40
    assert np.all(reader[:512, :512] == output[:512, :512])
41
42
43
def test_jp2_to_deflate_pyramid_tiff(samples_path, tmp_path):
44
    """Test that we can convert a JP2 to a DEFLATE compressed pyramid TIFF."""
45
    pyramid_downsamples = [2, 4]
46
47
    with warnings.catch_warnings():
48
        warnings.simplefilter("error")
49
50
        reader = readers.Reader.from_file(samples_path / "XYC.jp2")
51
        writer = writers.TIFFWriter(
52
            path=tmp_path / "XYC.tiff",
53
            shape=reader.shape,
54
            overwrite=False,
55
            tile_size=(256, 256),
56
            codec="deflate",
57
            pyramid_downsamples=pyramid_downsamples,
58
            microns_per_pixel=(0.5, 0.5),  # the input .jp2 has no resolution box
59
        )
60
        writer.copy_from_reader(reader=reader, num_workers=3, read_tile_size=(512, 512))
61
62
    assert writer.path.exists()
63
    assert writer.path.is_file()
64
    assert writer.path.stat().st_size > 0
65
66
    output = tifffile.imread(writer.path)
67
    assert np.all(reader[:512, :512] == output[:512, :512])
68
69
    tif = tifffile.TiffFile(writer.path)
70
    assert len(tif.series[0].levels) == len(pyramid_downsamples) + 1
71
72
73
def test_no_tqdm(samples_path, tmp_path, monkeypatch):
74
    """Test making a pyramid TIFF with no tqdm (progress bar) installed."""
75
    # Make tqdm unavailable
76
    monkeypatch.setitem(sys.modules, "tqdm", None)
77
    monkeypatch.setitem(sys.modules, "tqdm.auto", None)
78
79
    # Sanity check the imports fail
80
    with pytest.raises(ImportError):
81
        import tqdm  # noqa
82
83
    with pytest.raises(ImportError):
84
        from tqdm.auto import tqdm  # noqa
85
86
    pyramid_downsamples = [2, 4]
87
88
    with warnings.catch_warnings():
89
        warnings.simplefilter("error")
90
91
        reader = readers.Reader.from_file(samples_path / "XYC.jp2")
92
        writer = writers.TIFFWriter(
93
            path=tmp_path / "XYC.tiff",
94
            shape=reader.shape,
95
            overwrite=False,
96
            tile_size=(256, 256),
97
            codec="deflate",
98
            pyramid_downsamples=pyramid_downsamples,
99
            microns_per_pixel=(0.5, 0.5),  # the input .jp2 has no resolution box
100
        )
101
        writer.copy_from_reader(reader=reader, num_workers=3, read_tile_size=(512, 512))
102
103
    assert writer.path.exists()
104
    assert writer.path.is_file()
105
    assert writer.path.stat().st_size > 0
106
107
    output = tifffile.imread(writer.path)
108
    assert np.all(reader[:512, :512] == output[:512, :512])
109
110
    tif = tifffile.TiffFile(writer.path)
111
    assert len(tif.series[0].levels) == len(pyramid_downsamples) + 1
112
113
114
def test_pyramid_tiff(samples_path, tmp_path, monkeypatch):
115
    """Test pyramid generation using OpenCV to downsample."""
116
    # Try to make a pyramid TIFF
117
    reader = readers.Reader.from_file(samples_path / "XYC.jp2")
118
    pyramid_downsamples = [2, 4]
119
    writer = writers.TIFFWriter(
120
        path=tmp_path / "XYC.tiff",
121
        shape=reader.shape,
122
        overwrite=False,
123
        tile_size=(256, 256),
124
        codec="deflate",
125
        pyramid_downsamples=pyramid_downsamples,
126
    )
127
    writer.copy_from_reader(
128
        reader=reader, num_workers=3, read_tile_size=(512, 512), downsample_method="cv2"
129
    )
130
131
    assert writer.path.exists()
132
    assert writer.path.is_file()
133
    assert writer.path.stat().st_size > 0
134
135
    output = tifffile.imread(writer.path)
136
    assert np.all(reader[:512, :512] == output[:512, :512])
137
138
    tif = tifffile.TiffFile(writer.path)
139
    assert len(tif.series[0].levels) == len(pyramid_downsamples) + 1
140
141
142
def test_pyramid_tiff_no_cv2(samples_path, tmp_path, monkeypatch):
143
    """Test pyramid generation when cv2 is not installed.
144
145
    This will use SciPy. This method has a high error on synthetic data,
146
    e.g. a test grid image. It performns better on natural images.
147
    """
148
    # Make cv2 unavailable
149
    monkeypatch.setitem(sys.modules, "cv2", None)
150
151
    # Sanity check the import fails
152
    with pytest.raises(ImportError):
153
        import cv2  # noqa # skipcq
154
155
    # Try to make a pyramid TIFF
156
    reader = readers.Reader.from_file(samples_path / "XYC.jp2")
157
    pyramid_downsamples = [2, 4]
158
    writer = writers.TIFFWriter(
159
        path=tmp_path / "XYC.tiff",
160
        shape=reader.shape,
161
        overwrite=False,
162
        tile_size=(256, 256),
163
        codec="deflate",
164
        pyramid_downsamples=pyramid_downsamples,
165
    )
166
    writer.copy_from_reader(
167
        reader=reader,
168
        num_workers=3,
169
        read_tile_size=(512, 512),
170
        downsample_method="scipy",
171
    )
172
173
    assert writer.path.exists()
174
    assert writer.path.is_file()
175
    assert writer.path.stat().st_size > 0
176
177
    output = tifffile.imread(writer.path)
178
    assert np.all(reader[:512, :512] == output[:512, :512])
179
180
    tif = tifffile.TiffFile(writer.path)
181
    level_0 = tif.series[0].levels[0].asarray()
182
    assert len(tif.series[0].levels) == len(pyramid_downsamples) + 1
183
184
    for level in tif.series[0].levels[:2]:
185
        level_array = level.asarray()
186
        level_size = level_array.shape[:2][::-1]
187
        resized_level_0 = _cv2.resize(level_0, level_size)
188
        level_array = _cv2.GaussianBlur(level_array, (11, 11), 0)
189
        resized_level_0 = _cv2.GaussianBlur(resized_level_0, (11, 11), 0)
190
        mse = ((level_array.astype(float) - resized_level_0.astype(float)) ** 2).mean()
191
        assert mse < 200
192
        assert len(np.unique(level_array)) > 1
193
        assert resized_level_0.mean() == pytest.approx(level_array.mean(), abs=5)
194
        assert np.allclose(level_array, resized_level_0, atol=50)
195
196
197
def test_pyramid_tiff_no_cv2_no_scipy(samples_path, tmp_path, monkeypatch):
198
    """Test pyramid generation when neither cv2 or scipy are installed."""
199
    # Make cv2 and scipy unavailable
200
    monkeypatch.setitem(sys.modules, "cv2", None)
201
    monkeypatch.setitem(sys.modules, "scipy", None)
202
    # Sanity check the imports fail
203
    with pytest.raises(ImportError):
204
        import cv2  # noqa # skipcq
205
    with pytest.raises(ImportError):
206
        import scipy  # noqa # skipcq
207
    # Try to make a pyramid TIFF
208
    reader = readers.Reader.from_file(samples_path / "XYC.jp2")
209
    pyramid_downsamples = [2, 4]
210
    writer = writers.TIFFWriter(
211
        path=tmp_path / "XYC.tiff",
212
        shape=reader.shape,
213
        overwrite=False,
214
        tile_size=(256, 256),
215
        codec="deflate",
216
        pyramid_downsamples=pyramid_downsamples,
217
    )
218
    writer.copy_from_reader(
219
        reader=reader, num_workers=3, read_tile_size=(512, 512), downsample_method="np"
220
    )
221
222
    assert writer.path.exists()
223
    assert writer.path.is_file()
224
    assert writer.path.stat().st_size > 0
225
226
    output = tifffile.imread(writer.path)
227
    assert np.all(reader[:512, :512] == output[:512, :512])
228
229
    tif = tifffile.TiffFile(writer.path)
230
    level_0 = tif.series[0].levels[0].asarray()
231
    assert len(tif.series[0].levels) == len(pyramid_downsamples) + 1
232
    # Check that the levels are not blank and have a sensible range
233
    for level in tif.series[0].levels[:2]:
234
        level_array = level.asarray()
235
        level_size = level_array.shape[:2][::-1]
236
        resized_level_0 = _cv2.resize(level_0, level_size)
237
        mse = ((level_array.astype(float) - resized_level_0.astype(float)) ** 2).mean()
238
        assert mse < 10
239
        assert len(np.unique(level_array)) > 1
240
        assert resized_level_0.mean() == pytest.approx(level_array.mean(), abs=1)
241
        assert np.allclose(level_array, resized_level_0, atol=1)
242
243
244
def test_jp2_to_webp_tiled_tiff(samples_path, tmp_path):
245
    """Test that we can convert a JP2 to a WebP compressed tiled TIFF."""
246
    with warnings.catch_warnings():
247
        warnings.simplefilter("error")
248
        reader = readers.Reader.from_file(samples_path / "XYC.jp2")
249
        writer = writers.TIFFWriter(
250
            path=tmp_path / "XYC.tiff",
251
            shape=reader.shape,
252
            overwrite=False,
253
            tile_size=(256, 256),
254
            codec="WebP",
255
            compression_level=-1,  # <0 for lossless
256
            microns_per_pixel=(0.5, 0.5),  # input has no resolution box
257
        )
258
        writer.copy_from_reader(reader=reader, num_workers=3, read_tile_size=(512, 512))
259
260
    assert writer.path.exists()
261
    assert writer.path.is_file()
262
    assert writer.path.stat().st_size > 0
263
264
    output = tifffile.imread(writer.path)
265
    assert np.all(reader[:512, :512] == output[:512, :512])
266
267
268
def test_jp2_to_zarr(samples_path, tmp_path):
269
    """Convert JP2 to a single level Zarr."""
270
    with warnings.catch_warnings():
271
        warnings.simplefilter("error")
272
273
        reader = readers.Reader.from_file(samples_path / "XYC.jp2")
274
        writer = writers.ZarrWriter(
275
            path=tmp_path / "XYC.zarr",
276
            shape=reader.shape,
277
        )
278
        writer.copy_from_reader(
279
            reader=reader,
280
            num_workers=3,
281
            read_tile_size=(512, 512),
282
        )
283
284
    assert writer.path.exists()
285
    assert writer.path.is_dir()
286
    assert list(writer.path.iterdir())
287
288
    output = zarr.open(writer.path)
289
    assert np.all(reader[:512, :512] == output[0][:512, :512])
290
291
292
def test_jp2_to_pyramid_zarr(samples_path, tmp_path):
293
    """Convert JP2 to a pyramid Zarr."""
294
    with warnings.catch_warnings():
295
        warnings.simplefilter("error")
296
297
        reader = readers.Reader.from_file(samples_path / "XYC.jp2")
298
        pyramid_downsamples = [2, 4, 8, 16, 32]
299
        writer = writers.ZarrWriter(
300
            path=tmp_path / "XYC.zarr",
301
            shape=reader.shape,
302
            pyramid_downsamples=pyramid_downsamples,
303
            tile_size=(256, 256),
304
        )
305
        writer.copy_from_reader(reader=reader, num_workers=3, read_tile_size=(256, 256))
306
307
    assert writer.path.exists()
308
    assert writer.path.is_dir()
309
    assert list(writer.path.iterdir())
310
311
    output = zarr.open(writer.path)
312
    assert np.all(reader[:512, :512] == output[0][:512, :512])
313
314
    for level, dowmsample in zip(output.values(), [1] + pyramid_downsamples):
315
        assert level.shape[:2] == (
316
            reader.shape[0] // dowmsample,
317
            reader.shape[1] // dowmsample,
318
        )
319
320
321
def test_warn_unused(samples_path, tmp_path):
322
    """Test the warning about unsued arguments."""
323
    reader = readers.Reader.from_file(samples_path / "XYC.jp2")
324
    with pytest.warns(UserWarning):
325
        writers.JP2Writer(
326
            path=tmp_path / "XYC.tiff",
327
            shape=reader.shape,
328
            overwrite=False,
329
            tile_size=(256, 256),
330
            codec="WebP",
331
            compression_level=70,
332
        )
333
334
335
def test_read_zarr_array(tmp_path):
336
    """Test that we can open a Zarr array."""
337
    # Create a Zarr array
338
    array = zarr.open(
339
        tmp_path / "test.zarr",
340
        mode="w",
341
        shape=(10, 10),
342
        chunks=(2, 2),
343
        dtype=np.uint8,
344
    )
345
    array[:] = np.random.randint(0, 255, size=(10, 10))
346
347
    # Open the array
348
    reader = readers.Reader.from_file(tmp_path / "test.zarr")
349
350
    assert reader.shape == (10, 10)
351
    assert reader.dtype == np.uint8
352
353
354
def test_tiff_get_tile(samples_path):
355
    """Test getting a tile from a TIFF."""
356
    reader = readers.TIFFReader(samples_path / "CMU-1-Small-Region.svs")
357
    tile = reader.get_tile((1, 1), decode=False)
358
    assert isinstance(tile, bytes)
359
360
361
def test_transcode_jpeg_svs_to_zarr(samples_path, tmp_path):
362
    """Test that we can transcode an JPEG SVS to a Zarr."""
363
    reader = readers.TIFFReader(samples_path / "CMU-1-Small-Region.svs")
364
    writer = writers.ZarrWriter(
365
        path=tmp_path / "CMU-1-Small-Region.zarr",
366
        shape=reader.shape,
367
        tile_size=reader.tile_shape[::-1],
368
        dtype=reader.dtype,
369
    )
370
    writer.transcode_from_reader(reader=reader)
371
372
    assert writer.path.exists()
373
    assert writer.path.is_dir()
374
    assert list(writer.path.iterdir())
375
376
    output = zarr.open(writer.path)
377
    assert np.all(reader[...] == output[0][...])
378
379
380
def test_transcode_svs_to_zarr(samples_path, tmp_path):
381
    """Test that we can transcode an J2K SVS to a Zarr."""
382
    reader = readers.Reader.from_file(
383
        samples_path
384
        / "bfconvert"
385
        / (
386
            "XYC_-compression_JPEG-2000"
387
            "_-tilex_128_-tiley_128"
388
            "_-pyramid-scale_2"
389
            "_-merge.ome.tiff"
390
        )
391
    )
392
    writer = writers.ZarrWriter(
393
        path=tmp_path
394
        / (
395
            "XYC_-compression_JPEG-2000"
396
            "_-tilex_128_-tiley_128_"
397
            "-pyramid-scale_2_"
398
            "-merge.zarr"
399
        ),
400
        shape=reader.shape,
401
        tile_size=reader.tile_shape[::-1],
402
        dtype=reader.dtype,
403
    )
404
    writer.transcode_from_reader(reader=reader)
405
406
    assert writer.path.exists()
407
    assert writer.path.is_dir()
408
    assert list(writer.path.iterdir())
409
410
    output = zarr.open(writer.path)
411
    original = reader[...]
412
    new = output[0][...]
413
414
    assert np.array_equal(original, new)
415
416
417
def test_transcode_svs_to_pyramid_ome_zarr(samples_path, tmp_path):
418
    """Test that we can transcode an J2K SVS to a pyramid OME-Zarr (NGFF)."""
419
    reader = readers.Reader.from_file(
420
        samples_path
421
        / "bfconvert"
422
        / (
423
            "XYC_-compression_JPEG-2000"
424
            "_-tilex_128_-tiley_128"
425
            "_-pyramid-scale_2"
426
            "_-merge.ome.tiff"
427
        )
428
    )
429
    out_path = tmp_path / (
430
        "XYC_-compression_JPEG-2000"
431
        "_-tilex_128_-tiley_128_"
432
        "-pyramid-scale_2_"
433
        "-merge.zarr"
434
    )
435
    writer = writers.ZarrWriter(
436
        path=out_path,
437
        shape=reader.shape,
438
        tile_size=reader.tile_shape[::-1],
439
        dtype=reader.dtype,
440
        pyramid_downsamples=[2, 4, 8],
441
        ome=True,
442
    )
443
    writer.transcode_from_reader(reader=reader)
444
445
    assert writer.path.exists()
446
    assert writer.path.is_dir()
447
    assert list(writer.path.iterdir())
448
449
    output = zarr.open(writer.path)
450
    original = reader[...]
451
    new = output[0][...]
452
453
    assert np.array_equal(original, new)
454
455
    assert "_creator" in writer.zarr.attrs
456
    assert "omero" in writer.zarr.attrs
457
    assert "multiscales" in writer.zarr.attrs
458
459
460
def test_transcode_jpeg_dicom_wsi_to_zarr(samples_path, tmp_path):
461
    """Test that we can transcode a JPEG compressed DICOM WSI to a Zarr."""
462
    reader = readers.DICOMWSIReader(samples_path / "CMU-1-Small-Region")
463
    writer = writers.ZarrWriter(
464
        path=tmp_path / "CMU-1.zarr",
465
        shape=reader.shape,
466
        tile_size=reader.tile_shape[::-1],
467
        dtype=reader.dtype,
468
    )
469
    writer.transcode_from_reader(reader=reader)
470
471
    assert writer.path.exists()
472
    assert writer.path.is_dir()
473
    assert list(writer.path.iterdir())
474
475
    output = zarr.open(writer.path)
476
    original = reader[...]
477
    new = output[0][...]
478
479
    assert original.shape == new.shape
480
481
    # Allow for some slight differences in the pixel values due to
482
    # different decoders.
483
    difference = original.astype(np.float16) - new.astype(np.float16)
484
    mse = (difference**2).mean()
485
486
    assert mse < 1.5
487
    assert np.percentile(np.abs(difference), 95) < 1
488
489
490
def test_transcode_j2k_dicom_wsi_to_zarr(samples_path, tmp_path):
491
    """Test that we can transcode a J2K compressed DICOM WSI to a Zarr."""
492
    reader = readers.Reader.from_file(samples_path / "CMU-1-Small-Region-J2K")
493
    writer = writers.ZarrWriter(
494
        path=tmp_path / "CMU-1.zarr",
495
        shape=reader.shape,
496
        tile_size=reader.tile_shape[::-1],
497
        dtype=reader.dtype,
498
    )
499
    writer.transcode_from_reader(reader=reader)
500
501
    assert writer.path.exists()
502
    assert writer.path.is_dir()
503
    assert list(writer.path.iterdir())
504
505
    output = zarr.open(writer.path)
506
    original = reader[...]
507
    new = output[0][...]
508
509
    assert original.shape == new.shape
510
511
    # Allow for some slight differences in the pixel values due to
512
    # different decoders.
513
    difference = original.astype(np.float16) - new.astype(np.float16)
514
    mse = (difference**2).mean()
515
516
    assert mse < 1.5
517
    assert np.percentile(np.abs(difference), 95) < 1
518
519
520
def test_tiff_res_tags(samples_path):
521
    """Test that we can read the resolution tags from a TIFF."""
522
    reader = readers.Reader.from_file(samples_path / "XYC-half-mpp.tiff")
523
    assert reader.microns_per_pixel == (0.5, 0.5)
524
525
526
def test_copy_from_reader_timeout(samples_path, tmp_path):
527
    """Check that Writer.copy_from_reader raises IOError when timed out."""
528
    reader = readers.TIFFReader(samples_path / "CMU-1-Small-Region.svs")
529
    writer = writers.ZarrWriter(
530
        path=tmp_path / "CMU-1-Small-Region.zarr",
531
        shape=reader.shape,
532
        tile_size=reader.tile_shape[::-1],
533
        dtype=reader.dtype,
534
    )
535
    warnings.simplefilter("ignore")
536
    with pytest.raises(IOError, match="timed out"):
537
        writer.copy_from_reader(reader=reader, timeout=0)
538
539
540
def test_block_downsample_shape():
541
    """Test that the block downsample shape is correct."""
542
    shape = (135, 145)
543
    block_shape = (32, 32)
544
    downsample = 3
545
    # (32, 32) / 3 = (10, 10)
546
    # (135, 145) / 32 = (4.21875, 4.53125)
547
    # floor((0.21875, 0.53125) * 10) = (2, 5)
548
    # ((4, 4) * 10) + (2, 5) = (42, 45)
549
    expected = (42, 45)
550
    result_shape, result_tile_shape = utils.block_downsample_shape(
551
        shape=shape, block_shape=block_shape, downsample=downsample
552
    )
553
    assert result_shape == expected
554
    assert result_tile_shape == (10, 10)
555
556
557
def test_thumbnail(samples_path):
558
    """Test generating a thumbnail from a reader."""
559
    # Compare with cv2 downsampling
560
    reader = readers.TIFFReader(samples_path / "XYC-half-mpp.tiff")
561
    thumbnail = reader.thumbnail(shape=(64, 64))
562
    cv2_thumbnail = _cv2.resize(reader[...], (64, 64), interpolation=_cv2.INTER_AREA)
563
    assert thumbnail.shape == (64, 64, 3)
564
    assert np.allclose(thumbnail, cv2_thumbnail, atol=1)
565
566
567
def test_thumbnail_pil(samples_path, monkeypatch):
568
    """Test generating a thumbnail from a reader without cv2 installed.
569
570
    This should fall back to Pillow.
571
    """
572
    from PIL import Image
573
574
    # Monkeypatch cv2 to not be installed
575
    monkeypatch.setitem(sys.modules, "cv2", None)
576
577
    # Sanity check that cv2 is not installed
578
    with pytest.raises(ImportError):
579
        import cv2  # noqa # skipcq
580
581
    reader = readers.TIFFReader(samples_path / "XYC-half-mpp.tiff")
582
    thumbnail = reader.thumbnail(shape=(64, 64))
583
    pil_thumbnail = Image.fromarray(reader[...]).resize(
584
        (64, 64),
585
        resample=Image.Resampling.BOX,
586
    )
587
    assert thumbnail.shape == (64, 64, 3)
588
589
    mse = np.mean((thumbnail - pil_thumbnail) ** 2)
590
    assert mse < 1
591
    assert np.allclose(thumbnail, pil_thumbnail, atol=1)
592
593
594
def test_thumbnail_no_cv2_no_pil(samples_path, monkeypatch):
595
    """Test generating a thumbnail from a reader without cv2 or Pillow installed.
596
597
    This should fall back to scipy.ndimage.zoom.
598
    """
599
    # Monkeypatch cv2 and Pillow to not be installed
600
    monkeypatch.setitem(sys.modules, "cv2", None)
601
    monkeypatch.setitem(sys.modules, "PIL", None)
602
603
    # Sanity check that cv2 and Pillow are not installed
604
    with pytest.raises(ImportError):
605
        import cv2  # noqa # skipcq
606
    with pytest.raises(ImportError):
607
        import PIL  # noqa # skipcq
608
609
    reader = readers.TIFFReader(samples_path / "XYC-half-mpp.tiff")
610
    thumbnail = reader.thumbnail(shape=(64, 64))
611
    zoom = np.divide((64, 64), reader.shape[:2])
612
    zoom = np.append(zoom, 1)
613
    cv2_thumbnail = _cv2.resize(reader[...], (64, 64), interpolation=_cv2.INTER_AREA)
614
    assert thumbnail.shape == (64, 64, 3)
615
    assert np.allclose(thumbnail, cv2_thumbnail, atol=1)
616
617
618
def test_thumbnail_no_cv2_no_pil_no_scipy(samples_path, monkeypatch):
619
    """Test generating a thumbnail with nearest neighbor subsampling.
620
621
    This should be the raw numpy fallaback.
622
    """
623
    # Monkeypatch cv2 and Pillow to not be installed
624
    monkeypatch.setitem(sys.modules, "cv2", None)
625
    monkeypatch.setitem(sys.modules, "PIL", None)
626
    monkeypatch.setitem(sys.modules, "scipy", None)
627
628
    # Sanity check that modules are not installed
629
    with pytest.raises(ImportError):
630
        import cv2  # noqa # skipcq
631
    with pytest.raises(ImportError):
632
        import PIL  # noqa # skipcq
633
    with pytest.raises(ImportError):
634
        import scipy  # noqa # skipcq
635
636
    reader = readers.TIFFReader(samples_path / "XYC-half-mpp.tiff")
637
    with pytest.warns(UserWarning, match="slower"):
638
        thumbnail = reader.thumbnail(shape=(64, 64))
639
    cv2_thumbnail = _cv2.resize(reader[...], (64, 64), interpolation=_cv2.INTER_AREA)
640
    assert thumbnail.shape == (64, 64, 3)
641
    assert np.allclose(thumbnail, cv2_thumbnail, atol=1)
642
643
644
def test_thumbnail_non_power_two(samples_path):
645
    """Test generating a thumbnail from a reader.
646
647
    Outputs a non power of two sized thumbnail.
648
    """
649
    # Compare with cv2 downsampling
650
    reader = readers.TIFFReader(samples_path / "CMU-1-Small-Region.svs")
651
    thumbnail = reader.thumbnail(shape=(59, 59))
652
    cv2_thumbnail = _cv2.resize(reader[...], (59, 59), interpolation=_cv2.INTER_AREA)
653
    assert thumbnail.shape == (59, 59, 3)
654
    mse = np.mean((thumbnail - cv2_thumbnail) ** 2)
655
    psnr = 10 * np.log10(255**2 / mse)
656
    assert psnr > 30
657
658
659
def test_write_rgb_jpeg_svs(samples_path, tmp_path):
660
    """Test writing an SVS file with RGB JPEG compression."""
661
    reader = readers.TIFFReader(samples_path / "CMU-1-Small-Region.svs")
662
    writer = writers.SVSWriter(
663
        path=tmp_path / "Neo-CMU-1-Small-Region.svs",
664
        shape=reader.shape,
665
        pyramid_downsamples=[2, 4],
666
        compression_level=70,
667
    )
668
    writer.copy_from_reader(reader=reader)
669
    assert writer.path.exists()
670
    assert writer.path.is_file()
671
672
    # Pass the tiffile is_svs test
673
    tiff = tifffile.TiffFile(str(writer.path))
674
    assert tiff.is_svs
675
676
    # Read and compare with OpenSlide
677
    import openslide
678
679
    with openslide.OpenSlide(str(writer.path)) as slide:
680
        new_svs_region = slide.read_region((0, 0), 0, (1024, 1024))
681
    with openslide.OpenSlide(str(samples_path / "CMU-1-Small-Region.svs")) as slide:
682
        old_svs_region = slide.read_region((0, 0), 0, (1024, 1024))
683
684
    # Check mean squared error
685
    # There will be some error due to JPEG compression
686
    mse = (np.subtract(new_svs_region, old_svs_region) ** 2).mean()
687
    assert mse < 10
688
689
690
def test_write_ycbcr_jpeg_svs(samples_path, tmp_path):
691
    """Test writing an SVS file with YCbCr JPEG compression."""
692
    reader = readers.TIFFReader(samples_path / "CMU-1-Small-Region.svs")
693
    writer = writers.SVSWriter(
694
        path=tmp_path / "Neo-CMU-1-Small-Region.svs",
695
        shape=reader.shape,
696
        pyramid_downsamples=[2, 4],
697
        compression_level=70,
698
        color_mode="YCbCr",
699
    )
700
    writer.copy_from_reader(reader=reader)
701
    assert writer.path.exists()
702
    assert writer.path.is_file()
703
704
    # Pass the tiffile is_svs test
705
    tiff = tifffile.TiffFile(str(writer.path))
706
    assert tiff.is_svs
707
708
    # Read and compare with OpenSlide
709
    import openslide
710
711
    with openslide.OpenSlide(str(writer.path)) as slide:
712
        new_svs_region = slide.read_region((0, 0), 0, (1024, 1024))
713
    with openslide.OpenSlide(str(samples_path / "CMU-1-Small-Region.svs")) as slide:
714
        old_svs_region = slide.read_region((0, 0), 0, (1024, 1024))
715
716
    # Check mean squared error
717
    mse = (np.subtract(new_svs_region, old_svs_region) ** 2).mean()
718
    assert mse < 10
719
720
721
def test_write_ycrcb_j2k_svs_fails(samples_path, tmp_path):
722
    """Test writing an SVS file with YCrCb JP2 compression fails."""
723
    reader = readers.TIFFReader(samples_path / "CMU-1-Small-Region.svs")
724
    with pytest.raises(ValueError, match="only supports JPEG"):
725
        writers.SVSWriter(
726
            path=tmp_path / "Neo-CMU-1-Small-Region.svs",
727
            shape=reader.shape,
728
            pyramid_downsamples=[2, 4],
729
            codec=Codec.JPEG2000,
730
            compression_level=70,
731
            photometric=ColorSpace.YCBCR,
732
        )
733
734
735
def test_write_jp2_resolution(samples_path, tmp_path):
736
    """Test writing a JP2 with capture resolution metadata."""
737
    reader = readers.TIFFReader(samples_path / "CMU-1-Small-Region.svs")
738
    out_path = tmp_path / "CMU-1-Small-Region.jp2"
739
    writer = writers.JP2Writer(
740
        path=out_path,
741
        shape=reader.shape,
742
        pyramid_downsamples=[2, 4, 8],
743
        compression_level=70,
744
        microns_per_pixel=(0.5, 0.5),
745
    )
746
    writer.copy_from_reader(reader=reader)
747
    jp2_reader = readers.JP2Reader(out_path)
748
    assert jp2_reader.microns_per_pixel == (0.5, 0.5)
749
750
751
def test_missing_imagecodecs_codec(samples_path, tmp_path):
752
    """Test writing an SVS file with YCrCb JP2 compression fails."""
753
    reader = readers.TIFFReader(samples_path / "CMU-1-Small-Region.svs")
754
    with pytest.raises(ValueError, match="Unknown"):
755
        writers.ZarrWriter(
756
            path=tmp_path / "test.zarr",
757
            shape=reader.shape,
758
            pyramid_downsamples=[2, 4],
759
            codec="foo",
760
            compression_level=70,
761
            color_space=ColorSpace.RGB,
762
        )
763
764
765
# Zarr tests for alternate stores
766
767
768
def test_write_read_sqlite_store_zarr(samples_path, tmp_path):
769
    """Test writing and reading a Zarr with an SQLite store."""
770
    reader = readers.TIFFReader(samples_path / "CMU-1-Small-Region.svs")
771
    writer = writers.ZarrWriter(
772
        shape=reader.shape,
773
        store=zarr.SQLiteStore(tmp_path / "test.zarr.sqlite"),
774
    )
775
    writer.copy_from_reader(reader=reader)
776
    writer.close()
777
778
    # SQLite doesn't have a standard file extension. Therefore it is
779
    # hard to infer that the file is a SQLite file. We must pass a
780
    # zarr.SQLiteStore instance to open it.
781
    readers.ZarrReader(zarr.SQLiteStore(tmp_path / "test.zarr.sqlite"))
782
783
784
def test_write_read_zip_store_zarr(samples_path, tmp_path):
785
    """Test writing and reading a Zarr with a Zip store."""
786
    reader = readers.TIFFReader(samples_path / "CMU-1-Small-Region.svs")
787
    writer = writers.ZarrWriter(
788
        path=tmp_path / "test.zarr.zip",
789
        shape=reader.shape,
790
    )
791
792
    writer.copy_from_reader(reader=reader)
793
    writer.close()  # Important for zip store!
794
795
    readers.ZarrReader(tmp_path / "test.zarr.zip")
796
797
798
def test_write_read_temp_store_zarr(samples_path):
799
    """Test writing and reading a Zarr with a Temp store."""
800
    reader = readers.TIFFReader(samples_path / "CMU-1-Small-Region.svs")
801
    writer = writers.ZarrWriter(
802
        shape=reader.shape,
803
        store=zarr.TempStore("test.zarr"),
804
    )
805
    writer.copy_from_reader(reader=reader)
806
807
    readers.ZarrReader(writer.zarr.store)
808
809
810
# Test Scenarios
811
812
WRITER_EXT_MAPPING = {
813
    ".zarr": writers.ZarrWriter,
814
    ".tiff": writers.TIFFWriter,
815
    ".dcm": writers.DICOMWSIWriter,
816
}
817
818
819
class TestTranscodeScenarios:
820
    """Test scenarios for the transcoding WSIs."""
821
822
    scenarios = [
823
        (
824
            "jpeg_svs_to_zarr",
825
            {
826
                "sample_name": "CMU-1-Small-Region.svs",
827
                "reader_cls": readers.TIFFReader,
828
                "out_reader": readers.ZarrReader,
829
                "out_ext": ".zarr",
830
            },
831
        ),
832
        (
833
            "jpeg_tiff_to_zarr",
834
            {
835
                "sample_name": "CMU-1-Small-Region.jpeg.tiff",
836
                "reader_cls": readers.TIFFReader,
837
                "out_reader": readers.ZarrReader,
838
                "out_ext": ".zarr",
839
            },
840
        ),
841
        (
842
            "webp_tiff_to_zarr",
843
            {
844
                "sample_name": "CMU-1-Small-Region.webp.tiff",
845
                "reader_cls": readers.TIFFReader,
846
                "out_reader": readers.ZarrReader,
847
                "out_ext": ".zarr",
848
            },
849
        ),
850
        (
851
            "jp2_tiff_to_zarr",
852
            {
853
                "sample_name": "CMU-1-Small-Region.jp2.tiff",
854
                "reader_cls": readers.TIFFReader,
855
                "out_reader": readers.ZarrReader,
856
                "out_ext": ".zarr",
857
            },
858
        ),
859
        (
860
            "jpeg_dicom_to_zarr",
861
            {
862
                "sample_name": "CMU-1-Small-Region",
863
                "reader_cls": readers.DICOMWSIReader,
864
                "out_reader": readers.ZarrReader,
865
                "out_ext": ".zarr",
866
            },
867
        ),
868
        (
869
            "j2k_dicom_to_zarr",
870
            {
871
                "sample_name": "CMU-1-Small-Region-J2K",
872
                "reader_cls": readers.DICOMWSIReader,
873
                "out_reader": readers.ZarrReader,
874
                "out_ext": ".zarr",
875
            },
876
        ),
877
        (
878
            "jpeg_dicom_to_tiff",
879
            {
880
                "sample_name": "CMU-1-Small-Region",
881
                "reader_cls": readers.DICOMWSIReader,
882
                "out_reader": readers.TIFFReader,
883
                "out_ext": ".tiff",
884
            },
885
        ),
886
        (
887
            "j2k_dicom_to_tiff",
888
            {
889
                "sample_name": "CMU-1-Small-Region-J2K",
890
                "reader_cls": readers.DICOMWSIReader,
891
                "out_reader": readers.TIFFReader,
892
                "out_ext": ".tiff",
893
            },
894
        ),
895
        (
896
            "webp_tiff_to_tiff",
897
            {
898
                "sample_name": "CMU-1-Small-Region.webp.tiff",
899
                "reader_cls": readers.TIFFReader,
900
                "out_reader": readers.TIFFReader,
901
                "out_ext": ".tiff",
902
            },
903
        ),
904
        (
905
            "jpeg_tiff_to_jpeg_dicom",
906
            {
907
                "sample_name": "CMU-1-Small-Region.jpeg.tiff",
908
                "reader_cls": readers.TIFFReader,
909
                "out_reader": readers.DICOMWSIReader,
910
                "out_ext": ".dcm",
911
            },
912
        ),
913
    ]
914
915
    @staticmethod
916
    def test_transcode_tiled(
917
        samples_path: Path,
918
        sample_name: str,
919
        reader_cls: readers.Reader,
920
        out_reader: readers.Reader,
921
        out_ext: str,
922
        tmp_path: Path,
923
    ):
924
        """Test transcoding a tiled WSI."""
925
        in_path = samples_path / sample_name
926
        out_path = (tmp_path / sample_name).with_suffix(out_ext)
927
        reader: Reader = reader_cls(in_path)
928
        writer_cls = WRITER_EXT_MAPPING[out_ext]
929
        writer: Writer = writer_cls(
930
            path=out_path,
931
            shape=reader.shape,
932
            tile_size=reader.tile_shape[::-1],
933
        )
934
        writer.transcode_from_reader(reader=reader)
935
        output_reader: Reader = out_reader(out_path)
936
937
        assert output_reader.shape == reader.shape
938
        assert output_reader.tile_shape == reader.tile_shape
939
940
        # Calculate error metrics
941
        reader_img = reader[...]
942
        output_reader_img = output_reader[...]
943
        squared_err = np.subtract(reader_img, output_reader_img) ** 2
944
        abs_err = np.abs(np.subtract(reader_img, output_reader_img))
945
946
        # A lot of the image should have zero error
947
        assert np.count_nonzero(abs_err) / abs_err.size < 0.25
948
949
        # Check mean squared error is low
950
        assert np.all(squared_err.mean() < 2.56)
951
952
        # Check mean absolute error is low
953
        assert np.all(abs_err.mean() < 25.6)
954
955
    def visually_compare_readers(
956
        self,
957
        in_path: Path,
958
        out_path: Path,
959
        reader: readers.Reader,
960
        output_reader: readers.Reader,
961
    ) -> Dict[str, bool]:
962
        """Compare two readers for manual visual inspection.
963
964
        Used for debugging.
965
966
        Args:
967
            in_path:
968
                Path to the input file.
969
            out_path:
970
                Path to the output file.
971
            reader:
972
                Reader for the input file.
973
            output_reader:
974
                Reader for the output file.
975
        """
976
        import inspect
977
978
        from matplotlib import pyplot as plt  # type: ignore
979
        from matplotlib.widgets import Button  # type: ignore
980
981
        current_frame = inspect.currentframe()
982
        class_name = self.__class__.__name__
983
        function_name = current_frame.f_back.f_code.co_name
984
        # Create a dictionary of arg names to values
985
        args, _, _, values = inspect.getargvalues(current_frame)
986
        args_dict = {arg: values[arg] for arg in args}
987
        function_arguments = ",\n  ".join(
988
            f"{k}={v}" if k not in ("self",) else k for k, v in args_dict.items()
989
        )
990
991
        # Display the function signature and arguments in axs[0]
992
        text_figure = plt.gcf()
993
        text_figure.canvas.set_window_title(f"{class_name} - {function_name}")
994
        text_figure.set_size_inches(8, 2)
995
        plt.suptitle(
996
            f"{function_name}(\n  {function_arguments}\n)",
997
            horizontalalignment="left",
998
            verticalalignment="top",
999
            x=0,
1000
        )
1001
        plt.show(block=False)
1002
1003
        # Plot the readers to compare
1004
        _, axs = plt.subplots(1, 3, sharex=True, sharey=True)
1005
        axs[0].imshow(reader[...])
1006
        axs[0].set_title(f"Input\n({in_path.name})")
1007
        axs[1].imshow(output_reader[...])
1008
        axs[1].set_title(f"Output\n({out_path.name})")
1009
        diff = np.abs(np.subtract(reader[...], output_reader[...], dtype=float))
1010
        axs[2].imshow(diff.mean(-1))
1011
        max_diff = diff.max(axis=(0, 1))
1012
        mean_diff = diff.mean(axis=(0, 1))
1013
        axs[2].set_title(
1014
            f"Difference\nChannel Max Diff {max_diff}\nChannel Mean Diff {mean_diff}"
1015
        )
1016
1017
        # Set the window title
1018
        plt.gcf().canvas.set_window_title(f"{class_name} - {function_name}")
1019
1020
        # Add Pass / Fail Buttons with function callbacks
1021
        visual_inspections_passed = {}
1022
1023
        def pass_callback(event):
1024
            """Callback for the pass button."""
1025
            visual_inspections_passed[function_name] = True
1026
            plt.close(text_figure)
1027
            plt.close()
1028
1029
        def fail_callback(event):
1030
            """Callback for the fail button."""
1031
            plt.close(text_figure)
1032
            plt.close()
1033
1034
        ax_pass = plt.axes([0.8, 0.05, 0.1, 0.075])
1035
        btn_pass = Button(ax_pass, "Pass", color="lightgreen")
1036
        btn_pass.on_clicked(pass_callback)
1037
        ax_fail = plt.axes([0.9, 0.05, 0.1, 0.075])
1038
        btn_fail = Button(ax_fail, "Fail", color="red")
1039
        btn_fail.on_clicked(fail_callback)
1040
1041
        # Set suptitle to the function name
1042
        plt.suptitle("\n".join([class_name, function_name]))
1043
        plt.tight_layout()
1044
        plt.show(block=True)
1045
1046
        return visual_inspections_passed  # noqa: R504
1047
1048
1049
class TestConvertScenarios:
1050
    """Test scenarios for converting between formats."""
1051
1052
    scenarios = [
1053
        (
1054
            "j2k_dicom_to_zarr",
1055
            {
1056
                "sample_name": "CMU-1-Small-Region-J2K",
1057
                "reader_cls": readers.DICOMWSIReader,
1058
                "writer_cls": writers.ZarrWriter,
1059
                "out_ext": ".zarr",
1060
                "codec": "blosc",
1061
            },
1062
        ),
1063
        (
1064
            "jpeg_dicom_to_blosc_zarr",
1065
            {
1066
                "sample_name": "CMU-1-Small-Region",
1067
                "reader_cls": readers.DICOMWSIReader,
1068
                "writer_cls": writers.ZarrWriter,
1069
                "out_ext": ".zarr",
1070
                "codec": "blosc",
1071
            },
1072
        ),
1073
        (
1074
            "jpeg_dicom_to_jpeg_zarr",
1075
            {
1076
                "sample_name": "CMU-1-Small-Region",
1077
                "reader_cls": readers.DICOMWSIReader,
1078
                "writer_cls": writers.ZarrWriter,
1079
                "out_ext": ".zarr",
1080
                "codec": "jpeg",
1081
            },
1082
        ),
1083
        (
1084
            "jp2_to_jpeg_tiff",
1085
            {
1086
                "sample_name": "XYC.jp2",
1087
                "reader_cls": readers.JP2Reader,
1088
                "writer_cls": writers.TIFFWriter,
1089
                "out_ext": ".tiff",
1090
                "codec": "jpeg",
1091
            },
1092
        ),
1093
        (
1094
            "jp2_to_zarr",
1095
            {
1096
                "sample_name": "XYC.jp2",
1097
                "reader_cls": readers.JP2Reader,
1098
                "writer_cls": writers.ZarrWriter,
1099
                "out_ext": ".zarr",
1100
                "codec": "blosc",
1101
            },
1102
        ),
1103
        (
1104
            "jp2_to_jpeg_svs",
1105
            {
1106
                "sample_name": "XYC.jp2",
1107
                "reader_cls": readers.JP2Reader,
1108
                "writer_cls": writers.SVSWriter,
1109
                "out_ext": ".svs",
1110
                "codec": "jpeg",
1111
            },
1112
        ),
1113
        (
1114
            "tiff_to_jp2",
1115
            {
1116
                "sample_name": "XYC-half-mpp.tiff",
1117
                "reader_cls": readers.TIFFReader,
1118
                "writer_cls": writers.JP2Writer,
1119
                "out_ext": ".jp2",
1120
                "codec": "jpeg2000",
1121
            },
1122
        ),
1123
        (
1124
            "jp2_to_zstd_tiff",
1125
            {
1126
                "sample_name": "XYC.jp2",
1127
                "reader_cls": readers.JP2Reader,
1128
                "writer_cls": writers.TIFFWriter,
1129
                "out_ext": ".tiff",
1130
                "codec": "zstd",
1131
            },
1132
        ),
1133
        (
1134
            "jp2_to_png_tiff",
1135
            {
1136
                "sample_name": "XYC.jp2",
1137
                "reader_cls": readers.JP2Reader,
1138
                "writer_cls": writers.TIFFWriter,
1139
                "out_ext": ".tiff",
1140
                "codec": "png",
1141
            },
1142
        ),
1143
        (
1144
            "jp2_to_jpegxr_tiff",
1145
            {
1146
                "sample_name": "XYC.jp2",
1147
                "reader_cls": readers.JP2Reader,
1148
                "writer_cls": writers.TIFFWriter,
1149
                "out_ext": ".tiff",
1150
                "codec": "jpegxr",
1151
            },
1152
        ),
1153
        (
1154
            "jp2_to_deflate_tiff",
1155
            {
1156
                "sample_name": "XYC.jp2",
1157
                "reader_cls": readers.JP2Reader,
1158
                "writer_cls": writers.TIFFWriter,
1159
                "out_ext": ".tiff",
1160
                "codec": "deflate",
1161
            },
1162
        ),
1163
        (
1164
            "jp2_to_jpegxl_tiff",
1165
            {
1166
                "sample_name": "XYC.jp2",
1167
                "reader_cls": readers.JP2Reader,
1168
                "writer_cls": writers.TIFFWriter,
1169
                "out_ext": ".tiff",
1170
                "codec": "jpegxl",
1171
            },
1172
        ),
1173
        (
1174
            "jp2_to_jpeg_dicom",
1175
            {
1176
                "sample_name": "XYC.jp2",
1177
                "reader_cls": readers.JP2Reader,
1178
                "writer_cls": writers.DICOMWSIWriter,
1179
                "out_ext": ".dcm",
1180
                "codec": "jpeg",
1181
            },
1182
        ),
1183
        (
1184
            "svs_to_jppeg_dicom",
1185
            {
1186
                "sample_name": "CMU-1-Small-Region.svs",
1187
                "reader_cls": readers.OpenSlideReader,
1188
                "writer_cls": writers.DICOMWSIWriter,
1189
                "out_ext": ".dcm",
1190
                "codec": "jpeg",
1191
            },
1192
        ),
1193
        (
1194
            "tiff_to_dicom",
1195
            {
1196
                "sample_name": "XYC-half-mpp.tiff",
1197
                "reader_cls": readers.TIFFReader,
1198
                "writer_cls": writers.DICOMWSIWriter,
1199
                "out_ext": ".dcm",
1200
                "codec": "jpeg",
1201
            },
1202
        ),
1203
        (
1204
            "zarr_to_jpeg_dicom",
1205
            {
1206
                "sample_name": "CMU-1-Small-Region-JPEG.zarr",
1207
                "reader_cls": readers.ZarrReader,
1208
                "writer_cls": writers.DICOMWSIWriter,
1209
                "out_ext": ".tiff",
1210
                "codec": "jpeg",
1211
            },
1212
        ),
1213
    ]
1214
1215
    @staticmethod
1216
    def test_convert(
1217
        samples_path: Path,
1218
        sample_name: str,
1219
        reader_cls: readers.Reader,
1220
        writer_cls: writers.Writer,
1221
        out_ext: str,
1222
        tmp_path: Path,
1223
        codec: str,
1224
    ):
1225
        """Test converting between formats."""
1226
        in_path = samples_path / sample_name
1227
        out_path = (tmp_path / sample_name).with_suffix(out_ext)
1228
        reader: Reader = reader_cls(in_path)
1229
        writer: Writer = writer_cls(
1230
            out_path,
1231
            shape=reader.shape,
1232
            codec=codec,
1233
            compression_level=4
1234
            if codec
1235
            in {
1236
                "blosc",
1237
            }
1238
            else 100,
1239
        )
1240
        writer.copy_from_reader(
1241
            reader,
1242
            num_workers=1,
1243
            timeout=1e32,
1244
        )
1245
1246
        # Check that the output file exists
1247
        assert out_path.exists()
1248
1249
        # Check that the output file has non-zero size
1250
        assert out_path.stat().st_size > 0
1251
1252
        # Check that the output looks the same as the input
1253
        output_reader = readers.Reader.from_file(out_path)
1254
        mse = np.mean(np.square(reader[...] - output_reader[...]))
1255
        assert mse < 100
1256
1257
1258
class TestWriterScenarios:
1259
    """Test scenarios for writing to formats with codecs."""
1260
1261
    scenarios = [
1262
        ("svs_jpeg", {"writer_cls": writers.SVSWriter, "codec": "jpeg"}),
1263
        # Unsupported by tifffile
1264
        # ("tiff_blosc", {"writer_cls": writers.TIFFWriter, "codec": "blosc"}),
1265
        # ("tiff_blosc2", {"writer_cls": writers.TIFFWriter, "codec": "blosc2"}),
1266
        # ("tiff_brotli", {"writer_cls": writers.TIFFWriter, "codec": "brotli"}),
1267
        ("tiff_deflate", {"writer_cls": writers.TIFFWriter, "codec": "deflate"}),
1268
        # Unsupported by tifffile
1269
        # ("tiff_j2k", {"writer_cls": writers.TIFFWriter, "codec": "j2k"}),
1270
        ("tiff_jp2", {"writer_cls": writers.TIFFWriter, "codec": "jpeg2000"}),
1271
        ("tiff_jpeg", {"writer_cls": writers.TIFFWriter, "codec": "jpeg"}),
1272
        # Unsupported by tifffile
1273
        # ("tiff_jpls", {"writer_cls": writers.TIFFWriter, "codec": "jpegls"}),
1274
        ("tiff_jpxl", {"writer_cls": writers.TIFFWriter, "codec": "jpegxl"}),
1275
        ("tiff_jpxr", {"writer_cls": writers.TIFFWriter, "codec": "jpegxr"}),
1276
        # Unsupported by tifffile
1277
        # ("tiff_lz4", {"writer_cls": writers.TIFFWriter, "codec": "lz4"}),
1278
        # Encode unsupported by imagecodecs
1279
        # ("tiff_lzw", {"writer_cls": writers.TIFFWriter, "codec": "lzw"}),
1280
        ("tiff_png", {"writer_cls": writers.TIFFWriter, "codec": "png"}),
1281
        ("tiff_webp", {"writer_cls": writers.TIFFWriter, "codec": "webp"}),
1282
        # Unsupported by tifffile
1283
        # ("tiff_zfp", {"writer_cls": writers.TIFFWriter, "codec": "zfp"}),
1284
        ("tiff_zstd", {"writer_cls": writers.TIFFWriter, "codec": "zstd"}),
1285
        ("zarr_blosc", {"writer_cls": writers.ZarrWriter, "codec": "blosc"}),
1286
        ("zarr_blosc2", {"writer_cls": writers.ZarrWriter, "codec": "blosc2"}),
1287
        ("zarr_brotli", {"writer_cls": writers.ZarrWriter, "codec": "brotli"}),
1288
        ("zarr_deflate", {"writer_cls": writers.ZarrWriter, "codec": "deflate"}),
1289
        ("zarr_j2k", {"writer_cls": writers.ZarrWriter, "codec": "j2k"}),
1290
        ("zarr_jp2", {"writer_cls": writers.ZarrWriter, "codec": "jpeg2000"}),
1291
        ("zarr_jpeg", {"writer_cls": writers.ZarrWriter, "codec": "jpeg"}),
1292
        ("zarr_jpls", {"writer_cls": writers.ZarrWriter, "codec": "jpegls"}),
1293
        ("zarr_jpxl", {"writer_cls": writers.ZarrWriter, "codec": "jpegxl"}),
1294
        ("zarr_jpxr", {"writer_cls": writers.ZarrWriter, "codec": "jpegxr"}),
1295
        ("zarr_lz4", {"writer_cls": writers.ZarrWriter, "codec": "lz4"}),
1296
        # Encode unsupported by imagecodecs
1297
        # ("zarr_lzw", {"writer_cls": writers.ZarrWriter, "codec": "lzw"}),
1298
        ("zarr_png", {"writer_cls": writers.ZarrWriter, "codec": "png"}),
1299
        ("zarr_webp", {"writer_cls": writers.ZarrWriter, "codec": "webp"}),
1300
        (
1301
            "zarr_zfp",
1302
            {"writer_cls": writers.ZarrWriter, "codec": "zfp"},
1303
        ),  # Wrong data type
1304
        ("zarr_zstd", {"writer_cls": writers.ZarrWriter, "codec": "zstd"}),
1305
    ]
1306
1307
    @staticmethod
1308
    def test_write(
1309
        samples_path: Path, tmp_path: Path, writer_cls: writers.Writer, codec: str
1310
    ):
1311
        """Test writing to a format does not error."""
1312
        reader = readers.Reader.from_file(samples_path / "CMU-1-Small-Region.svs")
1313
        writer = writer_cls(
1314
            tmp_path / "image",
1315
            shape=reader.shape,
1316
            codec=codec,
1317
            dtype=float if codec == "zfp" else np.uint8,
1318
        )
1319
        writer.copy_from_reader(reader)
1320
1321
1322
class TestReaderScenarios:
1323
    """Test scenarios for readers."""
1324
1325
    scenarios = [
1326
        (
1327
            "jpeg_svs_tifffile",
1328
            {
1329
                "sample_name": "CMU-1-Small-Region.svs",
1330
                "reader_cls": readers.TIFFReader,
1331
            },
1332
        ),
1333
        (
1334
            "jpeg_svs_openslide",
1335
            {
1336
                "sample_name": "CMU-1-Small-Region.svs",
1337
                "reader_cls": readers.OpenSlideReader,
1338
            },
1339
        ),
1340
        (
1341
            "j2k_dicom",
1342
            {
1343
                "sample_name": "CMU-1-Small-Region-J2K",
1344
                "reader_cls": readers.DICOMWSIReader,
1345
            },
1346
        ),
1347
        (
1348
            "jpeg_dicom",
1349
            {
1350
                "sample_name": "CMU-1-Small-Region",
1351
                "reader_cls": readers.DICOMWSIReader,
1352
            },
1353
        ),
1354
        (
1355
            "jpeg_zarr",
1356
            {
1357
                "sample_name": "CMU-1-Small-Region-JPEG.zarr",
1358
                "reader_cls": readers.ZarrReader,
1359
            },
1360
        ),
1361
    ]
1362
1363
    @staticmethod
1364
    def test_thumbnail_512_512_approx(
1365
        samples_path: Path,
1366
        sample_name: str,
1367
        reader_cls: readers.Reader,
1368
    ):
1369
        """Test creating a thumbnail."""
1370
        in_path = samples_path / sample_name
1371
        reader: readers.Reader = reader_cls(in_path)
1372
        reader.thumbnail(
1373
            shape=(512, 512),
1374
            approx_ok=True,
1375
        )
1376
1377
    @staticmethod
1378
    def test_mpp_found(
1379
        samples_path: Path,
1380
        sample_name: str,
1381
        reader_cls: readers.Reader,
1382
    ):
1383
        """Check that resolution/mpp is read from the file (not None)."""
1384
        in_path = samples_path / sample_name
1385
        reader: readers.Reader = reader_cls(in_path)
1386
        assert hasattr(reader, "microns_per_pixel")
1387
        # A plain zarr doesn't have a resolution attribute so skip this.
1388
        # Maybe in duture this could be done via the xarray metadata.
1389
        if (
1390
            not isinstance(reader, readers.ZarrReader)
1391
            or sample_name != "CMU-1-Small-Region-JPEG.zarr"
1392
        ):
1393
            assert reader.microns_per_pixel is not None