From 993ab79a21c716b358f6e8a50f6fc4af45e48041 Mon Sep 17 00:00:00 2001 From: Jan Eglinger Date: Wed, 13 Dec 2023 17:14:32 +0100 Subject: [PATCH] Make objects for plate and well acquisitions (#59) Introduce high content screening plate data structures for Cell Voyager and Molecular Devices systems. The `ConvertToNGFFPlate` converter consumes these plates and writes out ome-zarr NGFF plates. The converter uses dask to stitch the well-images and supports re-chunking and binning. --------- Co-authored-by: Jan Eglinger Co-authored-by: Tim-Oliver Buchholz --- .pre-commit-config.yaml | 2 +- .../CellVoyagerStackAcquisitionExample.py | 50 ++ ...me-zarr from CellVoyager acquisition.ipynb | 641 -------------- ...Single-Plane Multi-Field Acquisition.ipynb | 422 ---------- ...from Z-Stack Multi-Field Acquisition.ipynb | 787 ------------------ ...mageXpressSinglePlaneAcquisitionExample.py | 46 + .../ImageXpressStackeAcquisitionExample.py | 46 + examples/TileStitchingExample.py | 67 -- setup.cfg | 1 + src/faim_hcs/CellVoyagerUtils.py | 236 ------ src/faim_hcs/MetaSeriesUtils.py | 293 ------- src/faim_hcs/MetaSeriesUtils_dask.py | 249 ------ src/faim_hcs/MontageUtils.py | 225 ----- src/faim_hcs/Zarr.py | 461 +--------- src/faim_hcs/alignment/__init__.py | 1 + src/faim_hcs/alignment/alignment.py | 67 ++ src/faim_hcs/hcs/__init__.py | 0 src/faim_hcs/hcs/acquisition.py | 285 +++++++ .../cellvoyager/CellVoyagerWellAcquisition.py | 89 ++ .../hcs/cellvoyager/StackAcquisition.py | 151 ++++ src/faim_hcs/hcs/cellvoyager/__init__.py | 1 + src/faim_hcs/hcs/converter.py | 256 ++++++ .../ImageXpressPlateAcquisition.py | 127 +++ .../imagexpress/ImageXpressWellAcquisition.py | 83 ++ .../hcs/imagexpress/MixedAcquisition.py | 80 ++ .../hcs/imagexpress/SinglePlaneAcquisition.py | 61 ++ .../hcs/imagexpress/StackAcquisition.py | 102 +++ src/faim_hcs/hcs/imagexpress/__init__.py | 5 + src/faim_hcs/hcs/plate.py | 54 ++ src/faim_hcs/io/ChannelMetadata.py | 17 + src/faim_hcs/io/MetaSeriesTiff.py | 37 +- .../io/MolecularDevicesImageXpress.py | 135 --- src/faim_hcs/io/YokogawaCellVoyager.py | 77 -- src/faim_hcs/roitable/FractalROITable.py | 117 +++ src/faim_hcs/roitable/__init__.py | 0 src/faim_hcs/stitching/DaskTileStitcher.py | 22 +- src/faim_hcs/stitching/Tile.py | 69 +- src/faim_hcs/stitching/stitching_utils.py | 126 ++- tests/alignment/test_alignment.py | 83 ++ tests/hcs/cellvoyager/files.csv | 33 + .../test_CellVoyagerWellAcquisition.py | 135 +++ .../hcs/cellvoyager/test_StackAcquisition.py | 153 ++++ tests/hcs/imagexpress/files.csv | 43 + tests/hcs/imagexpress/test_ImageXpress.py | 267 ++++++ .../test_ImageXpressWellAcquisition.py | 125 +++ tests/hcs/test_acquisition.py | 413 +++++++++ tests/hcs/test_converter.py | 207 +++++ tests/hcs/test_plate.py | 86 ++ tests/io/test_MetaSeriesTiff.py | 2 +- tests/io/test_MolecularDevicesImageXpress.py | 83 -- tests/io/test_YokogawaCellVoyager.py | 22 - tests/roitable/test_FractalROITable.py | 150 ++++ tests/stitching/test_Tile.py | 78 ++ tests/stitching/test_TileStitcher.py | 44 +- tests/stitching/test_stitching_utils.py | 151 +++- tests/test_CellVoyagerUtils.py | 121 --- tests/test_MetaSeriesUtils.py | 162 ---- tests/test_MetaSeriesUtils_dask.py | 301 +------ tests/test_Zarr.py | 471 ----------- tests/test_utils.py | 13 + tox.ini | 5 + 61 files changed, 3822 insertions(+), 4814 deletions(-) create mode 100644 examples/CellVoyagerStackAcquisitionExample.py delete mode 100644 examples/Create ome-zarr from CellVoyager acquisition.ipynb delete mode 100644 examples/Create ome-zarr from Single-Plane Multi-Field Acquisition.ipynb delete mode 100644 examples/Create ome-zarr from Z-Stack Multi-Field Acquisition.ipynb create mode 100644 examples/ImageXpressSinglePlaneAcquisitionExample.py create mode 100644 examples/ImageXpressStackeAcquisitionExample.py delete mode 100644 examples/TileStitchingExample.py delete mode 100644 src/faim_hcs/CellVoyagerUtils.py delete mode 100644 src/faim_hcs/MetaSeriesUtils.py delete mode 100644 src/faim_hcs/MontageUtils.py create mode 100644 src/faim_hcs/alignment/__init__.py create mode 100644 src/faim_hcs/alignment/alignment.py create mode 100644 src/faim_hcs/hcs/__init__.py create mode 100644 src/faim_hcs/hcs/acquisition.py create mode 100644 src/faim_hcs/hcs/cellvoyager/CellVoyagerWellAcquisition.py create mode 100644 src/faim_hcs/hcs/cellvoyager/StackAcquisition.py create mode 100644 src/faim_hcs/hcs/cellvoyager/__init__.py create mode 100644 src/faim_hcs/hcs/converter.py create mode 100644 src/faim_hcs/hcs/imagexpress/ImageXpressPlateAcquisition.py create mode 100644 src/faim_hcs/hcs/imagexpress/ImageXpressWellAcquisition.py create mode 100644 src/faim_hcs/hcs/imagexpress/MixedAcquisition.py create mode 100644 src/faim_hcs/hcs/imagexpress/SinglePlaneAcquisition.py create mode 100644 src/faim_hcs/hcs/imagexpress/StackAcquisition.py create mode 100644 src/faim_hcs/hcs/imagexpress/__init__.py create mode 100644 src/faim_hcs/hcs/plate.py create mode 100644 src/faim_hcs/io/ChannelMetadata.py delete mode 100644 src/faim_hcs/io/MolecularDevicesImageXpress.py delete mode 100644 src/faim_hcs/io/YokogawaCellVoyager.py create mode 100644 src/faim_hcs/roitable/FractalROITable.py create mode 100644 src/faim_hcs/roitable/__init__.py create mode 100644 tests/alignment/test_alignment.py create mode 100644 tests/hcs/cellvoyager/files.csv create mode 100644 tests/hcs/cellvoyager/test_CellVoyagerWellAcquisition.py create mode 100644 tests/hcs/cellvoyager/test_StackAcquisition.py create mode 100644 tests/hcs/imagexpress/files.csv create mode 100644 tests/hcs/imagexpress/test_ImageXpress.py create mode 100644 tests/hcs/imagexpress/test_ImageXpressWellAcquisition.py create mode 100644 tests/hcs/test_acquisition.py create mode 100644 tests/hcs/test_converter.py create mode 100644 tests/hcs/test_plate.py delete mode 100644 tests/io/test_MolecularDevicesImageXpress.py delete mode 100644 tests/io/test_YokogawaCellVoyager.py create mode 100644 tests/roitable/test_FractalROITable.py delete mode 100644 tests/test_CellVoyagerUtils.py delete mode 100644 tests/test_MetaSeriesUtils.py delete mode 100644 tests/test_Zarr.py create mode 100644 tests/test_utils.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8aeec44b..1edd219d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,7 +25,7 @@ repos: rev: 5.12.0 hooks: - id: isort - args: ["--profile", "black"] + args: ["--profile", "black", --skip=__init__.py, --filter-files] - repo: https://github.com/psf/black rev: 23.9.1 hooks: diff --git a/examples/CellVoyagerStackAcquisitionExample.py b/examples/CellVoyagerStackAcquisitionExample.py new file mode 100644 index 00000000..2b4c8432 --- /dev/null +++ b/examples/CellVoyagerStackAcquisitionExample.py @@ -0,0 +1,50 @@ +import shutil +from pathlib import Path + +from faim_hcs.hcs.acquisition import TileAlignmentOptions +from faim_hcs.hcs.cellvoyager import StackAcquisition +from faim_hcs.hcs.converter import ConvertToNGFFPlate, NGFFPlate +from faim_hcs.hcs.plate import PlateLayout +from faim_hcs.stitching import stitching_utils + + +def main(): + # Remove existing zarr. + shutil.rmtree("cv-stack.zarr", ignore_errors=True) + + # Parse CV plate acquisition. + plate = StackAcquisition( + acquisition_dir=Path(__file__).parent.parent + / "resources" + / "CV8000" + / "CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839" + / "CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack", + alignment=TileAlignmentOptions.GRID, + ) + + # Create converter. + converter = ConvertToNGFFPlate( + ngff_plate=NGFFPlate( + root_dir=".", + name="cv-stack", + layout=PlateLayout.I384, + order_name="order", + barcode="barcode", + ), + yx_binning=2, + dask_chunk_size_factor=2, + warp_func=stitching_utils.translate_tiles_2d, + fuse_func=stitching_utils.fuse_mean, + ) + + # Run conversion. + converter.run( + plate_acquisition=plate, + well_sub_group="0", + chunks=(2, 1000, 1000), + max_layer=2, + ) + + +if __name__ == "__main__": + main() diff --git a/examples/Create ome-zarr from CellVoyager acquisition.ipynb b/examples/Create ome-zarr from CellVoyager acquisition.ipynb deleted file mode 100644 index cb760928..00000000 --- a/examples/Create ome-zarr from CellVoyager acquisition.ipynb +++ /dev/null @@ -1,641 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "id": "0816df3d-8723-490e-9835-07a0a14af02a", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "from faim_hcs.io.YokogawaCellVoyager import parse_files, parse_metadata\n", - "from faim_hcs.CellVoyagerUtils import get_well_image_CZYX\n", - "from faim_hcs.Zarr import build_zarr_scaffold, write_czyx_image_to_well, PlateLayout, write_roi_table\n", - "from os.path import join, exists\n", - "from tqdm.notebook import tqdm\n", - "\n", - "import numpy as np\n", - "import shutil" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "69c9d9af-ea2a-47b9-9212-29f82bdf4f3e", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "directory = \"../resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/\"\n", - "zarr_root = './zarr-files'" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "3fa3d3d0-e2b1-4672-b3c3-01dd6211dd98", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "files = parse_files(acquisition_dir=directory)" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "6ed18c32-66de-4b71-a96f-e4ba2d34c46d", - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
TimeTimePointFieldIndexZIndexTimelineIndexActionIndexActionXYZChpathwell
02023-09-18T13:58:44.438+02:00111113D-567.1402.90.01../resources/CV8000/CV8000-Minimal-DataSet-2C-...D08
12023-09-18T13:58:44.647+02:00112113D-567.1402.93.01../resources/CV8000/CV8000-Minimal-DataSet-2C-...D08
22023-09-18T13:58:44.863+02:00113113D-567.1402.96.01../resources/CV8000/CV8000-Minimal-DataSet-2C-...D08
32023-09-18T13:58:45.080+02:00114113D-567.1402.99.01../resources/CV8000/CV8000-Minimal-DataSet-2C-...D08
42023-09-18T13:58:45.568+02:00111123D-567.1402.90.02../resources/CV8000/CV8000-Minimal-DataSet-2C-...D08
..........................................
912023-09-18T13:59:13.797+02:00144113D82.8-247.09.01../resources/CV8000/CV8000-Minimal-DataSet-2C-...F08
922023-09-18T13:59:14.300+02:00141123D82.8-247.00.02../resources/CV8000/CV8000-Minimal-DataSet-2C-...F08
932023-09-18T13:59:14.501+02:00142123D82.8-247.03.02../resources/CV8000/CV8000-Minimal-DataSet-2C-...F08
942023-09-18T13:59:14.704+02:00143123D82.8-247.06.02../resources/CV8000/CV8000-Minimal-DataSet-2C-...F08
952023-09-18T13:59:14.914+02:00144123D82.8-247.09.02../resources/CV8000/CV8000-Minimal-DataSet-2C-...F08
\n", - "

96 rows × 13 columns

\n", - "
" - ], - "text/plain": [ - " Time TimePoint FieldIndex ZIndex TimelineIndex \\\n", - "0 2023-09-18T13:58:44.438+02:00 1 1 1 1 \n", - "1 2023-09-18T13:58:44.647+02:00 1 1 2 1 \n", - "2 2023-09-18T13:58:44.863+02:00 1 1 3 1 \n", - "3 2023-09-18T13:58:45.080+02:00 1 1 4 1 \n", - "4 2023-09-18T13:58:45.568+02:00 1 1 1 1 \n", - ".. ... ... ... ... ... \n", - "91 2023-09-18T13:59:13.797+02:00 1 4 4 1 \n", - "92 2023-09-18T13:59:14.300+02:00 1 4 1 1 \n", - "93 2023-09-18T13:59:14.501+02:00 1 4 2 1 \n", - "94 2023-09-18T13:59:14.704+02:00 1 4 3 1 \n", - "95 2023-09-18T13:59:14.914+02:00 1 4 4 1 \n", - "\n", - " ActionIndex Action X Y Z Ch \\\n", - "0 1 3D -567.1 402.9 0.0 1 \n", - "1 1 3D -567.1 402.9 3.0 1 \n", - "2 1 3D -567.1 402.9 6.0 1 \n", - "3 1 3D -567.1 402.9 9.0 1 \n", - "4 2 3D -567.1 402.9 0.0 2 \n", - ".. ... ... ... ... ... .. \n", - "91 1 3D 82.8 -247.0 9.0 1 \n", - "92 2 3D 82.8 -247.0 0.0 2 \n", - "93 2 3D 82.8 -247.0 3.0 2 \n", - "94 2 3D 82.8 -247.0 6.0 2 \n", - "95 2 3D 82.8 -247.0 9.0 2 \n", - "\n", - " path well \n", - "0 ../resources/CV8000/CV8000-Minimal-DataSet-2C-... D08 \n", - "1 ../resources/CV8000/CV8000-Minimal-DataSet-2C-... D08 \n", - "2 ../resources/CV8000/CV8000-Minimal-DataSet-2C-... D08 \n", - "3 ../resources/CV8000/CV8000-Minimal-DataSet-2C-... D08 \n", - "4 ../resources/CV8000/CV8000-Minimal-DataSet-2C-... D08 \n", - ".. ... ... \n", - "91 ../resources/CV8000/CV8000-Minimal-DataSet-2C-... F08 \n", - "92 ../resources/CV8000/CV8000-Minimal-DataSet-2C-... F08 \n", - "93 ../resources/CV8000/CV8000-Minimal-DataSet-2C-... F08 \n", - "94 ../resources/CV8000/CV8000-Minimal-DataSet-2C-... F08 \n", - "95 ../resources/CV8000/CV8000-Minimal-DataSet-2C-... F08 \n", - "\n", - "[96 rows x 13 columns]" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "files" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "a11a69c6-dcfe-45e3-9bbd-34cc9cc029db", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "name, channel_metadata = parse_metadata(acquistion_dir=directory)" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "a32add85-469c-439d-af6f-4b494f168398", - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
ChHorizontalPixelDimensionVerticalPixelDimensionCameraNumberInputBitDepthInputLevel_xHorizontalPixelsVerticalPixelsFilterWheelPositionFilterPosition...BinningColorMinLevelMaxLevelCSUIDPinholeDiameterKindCameraTypeInputLevel_yFluorophore
010.3250.325316100002000200061...1#FF002FFF00.59884668421712428250ConfocalFluorescenceDCAM10000
120.3250.325316100002000200062...1#FF00FFA100.078600977811206979250ConfocalFluorescenceDCAM10000
230.3250.325416100002000200071...1#FFFF820000.16008524507960364250ConfocalFluorescenceDCAM10000
340.3250.325416100002000200072...1#FFFF1B0000.078600977811207250ConfocalFluorescenceDCAM10000
\n", - "

4 rows × 33 columns

\n", - "
" - ], - "text/plain": [ - " Ch HorizontalPixelDimension VerticalPixelDimension CameraNumber \\\n", - "0 1 0.325 0.325 3 \n", - "1 2 0.325 0.325 3 \n", - "2 3 0.325 0.325 4 \n", - "3 4 0.325 0.325 4 \n", - "\n", - " InputBitDepth InputLevel_x HorizontalPixels VerticalPixels \\\n", - "0 16 10000 2000 2000 \n", - "1 16 10000 2000 2000 \n", - "2 16 10000 2000 2000 \n", - "3 16 10000 2000 2000 \n", - "\n", - " FilterWheelPosition FilterPosition ... Binning Color MinLevel \\\n", - "0 6 1 ... 1 #FF002FFF 0 \n", - "1 6 2 ... 1 #FF00FFA1 0 \n", - "2 7 1 ... 1 #FFFF8200 0 \n", - "3 7 2 ... 1 #FFFF1B00 0 \n", - "\n", - " MaxLevel CSUID PinholeDiameter Kind \\\n", - "0 0.59884668421712428 2 50 ConfocalFluorescence \n", - "1 0.078600977811206979 2 50 ConfocalFluorescence \n", - "2 0.16008524507960364 2 50 ConfocalFluorescence \n", - "3 0.078600977811207 2 50 ConfocalFluorescence \n", - "\n", - " CameraType InputLevel_y Fluorophore \n", - "0 DCAM 10000 \n", - "1 DCAM 10000 \n", - "2 DCAM 10000 \n", - "3 DCAM 10000 \n", - "\n", - "[4 rows x 33 columns]" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "channel_metadata" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "029d3f74-f7b2-4647-ad27-cae8cd3bfd54", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "files[\"name\"] = \"CV8000_sample\"" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "b60a5e04-6fb2-41c2-988e-2e24177824bf", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "if exists(join(zarr_root, \"CV8000_sample.zarr\")):\n", - " # Remove zarr if it already exists.\n", - " shutil.rmtree(join(zarr_root, \"CV8000_sample.zarr\"))\n", - "\n", - "# Build empty zarr plate scaffold.\n", - "plate = build_zarr_scaffold(root_dir=zarr_root,\n", - " files=files,\n", - " name='CV8000_sample',\n", - " layout=96,\n", - " order_name=\"example-order\",\n", - " barcode=\"example-barcode\")" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "625f9dfa-cb7d-4a08-a40a-08af702b38d4", - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "245f36252d494442a1954b2e205dc8fc", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - " 0%| | 0/3 [00:00 {name}\n", - " └── 2023-02-21 --> {date}\n", - " └── 1334 --> {acquisition id}\n", - " ├── Projection-Mix_E07_s1_w1E94C24BD-45E4-450A-9919-257C714278F7.tif\n", - " ├── Projection-Mix_E07_s1_w1_thumb4BFD4018-E675-475E-B5AB-2E959E6B6DA1.tif\n", - " ├── Projection-Mix_E07_s1_w2B14915F6-0679-4494-82D1-F80894B32A66.tif\n", - " ├── Projection-Mix_E07_s1_w2_thumbE5A6D08C-AF44-4CA9-A6E8-7DC9BC9565F2.tif\n", - " ├── Projection-Mix_E07_s1_w3BB87F860-FC67-4B3A-A740-A9EACF8A8F5F.tif\n", - " ├── Projection-Mix_E07_s1_w3_thumbD739A948-8CA4-4559-910C-79D42CEC98A0.tif\n", - " ├── Projection-Mix_E07_s2_w1DCFD1526-D063-4F8B-9E51-F1BD2EBD9F1A.tif\n", - " ├── Projection-Mix_E07_s2_w1_thumbE4903920-91AA-405E-A39B-6EB284EA48E5.tif\n", - " ├── Projection-Mix_E07_s2_w2607EE13F-AB5E-4E8C-BC4B-52E1118E7723.tif\n", - " ├── Projection-Mix_E07_s2_w2_thumb15675A28-B45B-42E9-802C-833005E72657.tif\n", - " ├── Projection-Mix_E07_s2_w3B0A47337-5945-4B26-9F5F-4EBA468CDBA9.tif\n", - " ├── Projection-Mix_E07_s2_w3_thumbE3E91A92-3DD7-4EF5-BC32-8F5EF95B2CCA.tif\n", - " ├── Projection-Mix_E08_s1_w117654C10-92F1-4DFD-98AA-6A01BBD77557.tif\n", - " ├── Projection-Mix_E08_s1_w1_thumbD59B8F66-7743-4113-B6A2-8899FD53C3C8.tif\n", - " ├── Projection-Mix_E08_s1_w281928711-999D-41F6-B88C-999513D4C092.tif\n", - " ├── Projection-Mix_E08_s1_w2_thumb6D866DBC-F5F2-41D3-BEDD-28BEEB281FDD.tif\n", - " ├── Projection-Mix_E08_s1_w3DD77D22D-07CB-4529-A1F5-DCC5473786FA.tif\n", - " ├── Projection-Mix_E08_s1_w3_thumb615741CD-A218-4C30-BC79-E62A9D675DF5.tif\n", - " ├── Projection-Mix_E08_s2_w1B38C01F5-0D36-4A29-9F5A-BE62B6F7F73F.tif\n", - " ├── Projection-Mix_E08_s2_w1_thumbC7F53295-5DB8-4989-9E25-DD1068A2E266.tif\n", - " ├── Projection-Mix_E08_s2_w266923EBB-9960-4952-8955-D1721D112EE2.tif\n", - " ├── Projection-Mix_E08_s2_w2_thumb49A20B6B-1B86-47F1-B5FA-C22B47D2590D.tif\n", - " ├── Projection-Mix_E08_s2_w3CCE83D85-0912-429E-9F18-716A085BB5BC.tif\n", - " └── Projection-Mix_E08_s2_w3_thumb4D88636E-181E-4AF6-BC53-E7A435959C8F.tif\n", - "\n", - "```\n", - "\n", - "* Image data is stored in `{name}_{well}_{field}_w{channel}{md_id}.tif`.\n", - "* The `*_thumb*.tif` files are used by Molecular Devices as preview.\n", - "\n", - "__Note:__ For this example we ignore the `ZStep_#` data." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "919ac7a2", - "metadata": {}, - "outputs": [], - "source": [ - "from faim_hcs.io.MolecularDevicesImageXpress import parse_single_plane_multi_fields\n", - "from faim_hcs.Zarr import build_zarr_scaffold, write_cyx_image_to_well, PlateLayout, write_roi_table\n", - "from faim_hcs.MetaSeriesUtils import get_well_image_CYX, montage_grid_image_YX\n", - "from faim_hcs.UIntHistogram import UIntHistogram\n", - "import shutil\n", - "from tqdm.notebook import tqdm\n", - "from os.path import join, exists" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "a5472407", - "metadata": {}, - "outputs": [], - "source": [ - "acquisition_dir = '../resources/Projection-Mix/'\n", - "zarr_root = './zarr-files'" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "9c4b5bd6", - "metadata": {}, - "outputs": [], - "source": [ - "files = parse_single_plane_multi_fields(acquisition_dir)" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "fe2936b0", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
dateacq_idnamewellfieldchannelmd_idextpath
02023-02-211334Projection-MixE08s2w1B38C01F5-0D36-4A29-9F5A-BE62B6F7F73F.tif../resources/Projection-Mix/2023-02-21/1334/Pr...
12023-02-211334Projection-MixE08s1w281928711-999D-41F6-B88C-999513D4C092.tif../resources/Projection-Mix/2023-02-21/1334/Pr...
22023-02-211334Projection-MixE08s2w3CCE83D85-0912-429E-9F18-716A085BB5BC.tif../resources/Projection-Mix/2023-02-21/1334/Pr...
32023-02-211334Projection-MixE07s2w3B0A47337-5945-4B26-9F5F-4EBA468CDBA9.tif../resources/Projection-Mix/2023-02-21/1334/Pr...
42023-02-211334Projection-MixE07s1w2B14915F6-0679-4494-82D1-F80894B32A66.tif../resources/Projection-Mix/2023-02-21/1334/Pr...
52023-02-211334Projection-MixE07s2w2607EE13F-AB5E-4E8C-BC4B-52E1118E7723.tif../resources/Projection-Mix/2023-02-21/1334/Pr...
62023-02-211334Projection-MixE08s1w3DD77D22D-07CB-4529-A1F5-DCC5473786FA.tif../resources/Projection-Mix/2023-02-21/1334/Pr...
72023-02-211334Projection-MixE08s1w117654C10-92F1-4DFD-98AA-6A01BBD77557.tif../resources/Projection-Mix/2023-02-21/1334/Pr...
82023-02-211334Projection-MixE07s1w1E94C24BD-45E4-450A-9919-257C714278F7.tif../resources/Projection-Mix/2023-02-21/1334/Pr...
92023-02-211334Projection-MixE07s2w1DCFD1526-D063-4F8B-9E51-F1BD2EBD9F1A.tif../resources/Projection-Mix/2023-02-21/1334/Pr...
102023-02-211334Projection-MixE08s2w266923EBB-9960-4952-8955-D1721D112EE2.tif../resources/Projection-Mix/2023-02-21/1334/Pr...
112023-02-211334Projection-MixE07s1w3BB87F860-FC67-4B3A-A740-A9EACF8A8F5F.tif../resources/Projection-Mix/2023-02-21/1334/Pr...
\n", - "
" - ], - "text/plain": [ - " date acq_id name well field channel \\\n", - "0 2023-02-21 1334 Projection-Mix E08 s2 w1 \n", - "1 2023-02-21 1334 Projection-Mix E08 s1 w2 \n", - "2 2023-02-21 1334 Projection-Mix E08 s2 w3 \n", - "3 2023-02-21 1334 Projection-Mix E07 s2 w3 \n", - "4 2023-02-21 1334 Projection-Mix E07 s1 w2 \n", - "5 2023-02-21 1334 Projection-Mix E07 s2 w2 \n", - "6 2023-02-21 1334 Projection-Mix E08 s1 w3 \n", - "7 2023-02-21 1334 Projection-Mix E08 s1 w1 \n", - "8 2023-02-21 1334 Projection-Mix E07 s1 w1 \n", - "9 2023-02-21 1334 Projection-Mix E07 s2 w1 \n", - "10 2023-02-21 1334 Projection-Mix E08 s2 w2 \n", - "11 2023-02-21 1334 Projection-Mix E07 s1 w3 \n", - "\n", - " md_id ext \\\n", - "0 B38C01F5-0D36-4A29-9F5A-BE62B6F7F73F .tif \n", - "1 81928711-999D-41F6-B88C-999513D4C092 .tif \n", - "2 CCE83D85-0912-429E-9F18-716A085BB5BC .tif \n", - "3 B0A47337-5945-4B26-9F5F-4EBA468CDBA9 .tif \n", - "4 B14915F6-0679-4494-82D1-F80894B32A66 .tif \n", - "5 607EE13F-AB5E-4E8C-BC4B-52E1118E7723 .tif \n", - "6 DD77D22D-07CB-4529-A1F5-DCC5473786FA .tif \n", - "7 17654C10-92F1-4DFD-98AA-6A01BBD77557 .tif \n", - "8 E94C24BD-45E4-450A-9919-257C714278F7 .tif \n", - "9 DCFD1526-D063-4F8B-9E51-F1BD2EBD9F1A .tif \n", - "10 66923EBB-9960-4952-8955-D1721D112EE2 .tif \n", - "11 BB87F860-FC67-4B3A-A740-A9EACF8A8F5F .tif \n", - "\n", - " path \n", - "0 ../resources/Projection-Mix/2023-02-21/1334/Pr... \n", - "1 ../resources/Projection-Mix/2023-02-21/1334/Pr... \n", - "2 ../resources/Projection-Mix/2023-02-21/1334/Pr... \n", - "3 ../resources/Projection-Mix/2023-02-21/1334/Pr... \n", - "4 ../resources/Projection-Mix/2023-02-21/1334/Pr... \n", - "5 ../resources/Projection-Mix/2023-02-21/1334/Pr... \n", - "6 ../resources/Projection-Mix/2023-02-21/1334/Pr... \n", - "7 ../resources/Projection-Mix/2023-02-21/1334/Pr... \n", - "8 ../resources/Projection-Mix/2023-02-21/1334/Pr... \n", - "9 ../resources/Projection-Mix/2023-02-21/1334/Pr... \n", - "10 ../resources/Projection-Mix/2023-02-21/1334/Pr... \n", - "11 ../resources/Projection-Mix/2023-02-21/1334/Pr... " - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "files" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "948904be", - "metadata": {}, - "outputs": [], - "source": [ - "if exists(join(zarr_root, \"Single-Plane.zarr\")):\n", - " # Remove zarr if it already exists.\n", - " shutil.rmtree(join(zarr_root, \"Single-Plane.zarr\"))\n", - "\n", - "# Build empty zarr plate scaffold.\n", - "plate = build_zarr_scaffold(root_dir=zarr_root,\n", - " files=files,\n", - " name='Single-Plane',\n", - " layout=96,\n", - " order_name=\"example-order\",\n", - " barcode=\"example-barcode\")" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "37f65e50", - "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "cca2a3f86a72410ca69527e3a9b46e26", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - " 0%| | 0/2 [00:00 {name}\n", - " └── 2023-02-21 --> {date}\n", - " └── 1334 --> {acquisition id}\n", - " ├── Projection-Mix_E07_s1_w1E94C24BD-45E4-450A-9919-257C714278F7.tif\n", - " ├── Projection-Mix_E07_s1_w1_thumb4BFD4018-E675-475E-B5AB-2E959E6B6DA1.tif\n", - " ├── Projection-Mix_E07_s1_w2B14915F6-0679-4494-82D1-F80894B32A66.tif\n", - " ├── Projection-Mix_E07_s1_w2_thumbE5A6D08C-AF44-4CA9-A6E8-7DC9BC9565F2.tif\n", - " ├── Projection-Mix_E07_s1_w3BB87F860-FC67-4B3A-A740-A9EACF8A8F5F.tif\n", - " ├── Projection-Mix_E07_s1_w3_thumbD739A948-8CA4-4559-910C-79D42CEC98A0.tif\n", - " ├── Projection-Mix_E07_s2_w1DCFD1526-D063-4F8B-9E51-F1BD2EBD9F1A.tif\n", - " ├── Projection-Mix_E07_s2_w1_thumbE4903920-91AA-405E-A39B-6EB284EA48E5.tif\n", - " ├── Projection-Mix_E07_s2_w2607EE13F-AB5E-4E8C-BC4B-52E1118E7723.tif\n", - " ├── Projection-Mix_E07_s2_w2_thumb15675A28-B45B-42E9-802C-833005E72657.tif\n", - " ├── Projection-Mix_E07_s2_w3B0A47337-5945-4B26-9F5F-4EBA468CDBA9.tif\n", - " ├── Projection-Mix_E07_s2_w3_thumbE3E91A92-3DD7-4EF5-BC32-8F5EF95B2CCA.tif\n", - " ├── Projection-Mix_E08_s1_w117654C10-92F1-4DFD-98AA-6A01BBD77557.tif\n", - " ├── Projection-Mix_E08_s1_w1_thumbD59B8F66-7743-4113-B6A2-8899FD53C3C8.tif\n", - " ├── Projection-Mix_E08_s1_w281928711-999D-41F6-B88C-999513D4C092.tif\n", - " ├── Projection-Mix_E08_s1_w2_thumb6D866DBC-F5F2-41D3-BEDD-28BEEB281FDD.tif\n", - " ├── Projection-Mix_E08_s1_w3DD77D22D-07CB-4529-A1F5-DCC5473786FA.tif\n", - " ├── Projection-Mix_E08_s1_w3_thumb615741CD-A218-4C30-BC79-E62A9D675DF5.tif\n", - " ├── Projection-Mix_E08_s2_w1B38C01F5-0D36-4A29-9F5A-BE62B6F7F73F.tif\n", - " ├── Projection-Mix_E08_s2_w1_thumbC7F53295-5DB8-4989-9E25-DD1068A2E266.tif\n", - " ├── Projection-Mix_E08_s2_w266923EBB-9960-4952-8955-D1721D112EE2.tif\n", - " ├── Projection-Mix_E08_s2_w2_thumb49A20B6B-1B86-47F1-B5FA-C22B47D2590D.tif\n", - " ├── Projection-Mix_E08_s2_w3CCE83D85-0912-429E-9F18-716A085BB5BC.tif\n", - " ├── Projection-Mix_E08_s2_w3_thumb4D88636E-181E-4AF6-BC53-E7A435959C8F.tif\n", - " ├── ZStep_1\n", - " │   ├── Projection-Mix_E07_s1_w1E78EB128-BD0D-4D94-A6AD-3FF28BB1B105.tif\n", - " │   ├── ...\n", - " │   └── Projection-Mix_E08_s2_w4_thumbD2785594-4F49-464F-9F80-1B82E30A560A.tif\n", - " ├── ...\n", - " └── ZStep_10\n", - "    ├── Projection-Mix_E07_s1_w11D01380B-E7DA-4C09-A343-EDA3D235D808.tif\n", - "    ├── Projection-Mix_E07_s1_w1_thumb16F6E9D3-12E4-426F-A4BF-9AD5D23A000F.tif\n", - "    ├── Projection-Mix_E07_s1_w27FF63331-0307-4A31-89AC-B363F8BCED7A.tif\n", - "    ├── Projection-Mix_E07_s1_w2_thumb55E0167A-3CFE-4E67-AC0B-A1A900FC9CF6.tif\n", - "    ├── Projection-Mix_E07_s2_w12BFD0E01-D7F8-4596-8904-3209AD741437.tif\n", - "    ├── Projection-Mix_E07_s2_w1_thumb6F13EC7B-89B8-4720-9E33-5D56699B2ED0.tif\n", - "    ├── Projection-Mix_E07_s2_w20DB41161-451B-4A8B-9DA6-8E9C7D56D30E.tif\n", - "    ├── Projection-Mix_E07_s2_w2_thumbAF5EB66F-8476-4520-865B-D74FE5AE5CE2.tif\n", - "    ├── Projection-Mix_E08_s1_w1F11777E5-3795-4B08-BB89-F854F3414F88.tif\n", - "    ├── Projection-Mix_E08_s1_w1_thumbE57B9E91-5584-4A7A-8D1B-9E4C0F263742.tif\n", - "    ├── Projection-Mix_E08_s1_w2D6F71AC1-8418-4C24-94A2-A273E706891B.tif\n", - "    ├── Projection-Mix_E08_s1_w2_thumb5F0241E5-6AE0-45A8-A464-65E6B71F2FB3.tif\n", - "    ├── Projection-Mix_E08_s2_w1E84FADEE-6DCE-4646-8329-F841B2D45415.tif\n", - "    ├── Projection-Mix_E08_s2_w1_thumbED8582E8-6F5A-4639-86F1-E98C869814C2.tif\n", - "    ├── Projection-Mix_E08_s2_w2BB584250-EC8C-49E1-98F3-93113F70C320.tif\n", - "    └── Projection-Mix_E08_s2_w2_thumb912362B1-19FE-40C6-A7DC-DAE2CC83872B.tif\n", - "\n", - "```\n", - "\n", - "\n", - "* Z-Stack data is stored in `ZStep_{z}/{name}_{well}_{field}_w{channel}{md_id}.tif`\n", - "* Projection data is stored in `{name}_{well}_{field}_w{channel}{md_id}.tif`.\n", - "* The `*_thumb*.tif` files are used by Molecular Devices as preview.\n", - "\n", - "## Acquisition Protocol\n", - "\n", - "In total four channels were acquired:\n", - "* Z-stacks were only acquired for for channels 1 and 2. \n", - " * For channels 1 and 2 the maximum and best-focus projections were saved by the microscope as well. \n", - "* For channel 3 only a maximum projection was saved. \n", - "* For channel 4 only a single z-plane was acquired and saved." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "919ac7a2", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "from faim_hcs.io.MolecularDevicesImageXpress import parse_files\n", - "from faim_hcs.Zarr import build_zarr_scaffold, write_czyx_image_to_well, write_cyx_image_to_well, write_roi_table\n", - "from faim_hcs.MetaSeriesUtils import get_well_image_CYX, get_well_image_CZYX, montage_grid_image_YX\n", - "from faim_hcs.UIntHistogram import UIntHistogram\n", - "import shutil\n", - "from tqdm.notebook import tqdm\n", - "from os.path import join, exists\n", - "\n", - "import zarr" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "a5472407", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "acquisition_dir = '../resources/Projection-Mix/'\n", - "zarr_root = './zarr-files'" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "9c4b5bd6", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "files = parse_files(acquisition_dir)" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "fe2936b0", - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
dateacq_idznamewellfieldchannelmd_idextpath
02023-02-211334NoneProjection-MixE08s2w1B38C01F5-0D36-4A29-9F5A-BE62B6F7F73F.tif../resources/Projection-Mix/2023-02-21/1334/Pr...
12023-02-211334NoneProjection-MixE08s1w281928711-999D-41F6-B88C-999513D4C092.tif../resources/Projection-Mix/2023-02-21/1334/Pr...
22023-02-211334NoneProjection-MixE08s2w3CCE83D85-0912-429E-9F18-716A085BB5BC.tif../resources/Projection-Mix/2023-02-21/1334/Pr...
32023-02-211334NoneProjection-MixE07s2w3B0A47337-5945-4B26-9F5F-4EBA468CDBA9.tif../resources/Projection-Mix/2023-02-21/1334/Pr...
42023-02-211334NoneProjection-MixE07s1w2B14915F6-0679-4494-82D1-F80894B32A66.tif../resources/Projection-Mix/2023-02-21/1334/Pr...
.................................
912023-02-2113349Projection-MixE07s1w1091EB8A5-272A-466D-B8A0-7547C6BA392B.tif../resources/Projection-Mix/2023-02-21/1334/ZS...
922023-02-2113349Projection-MixE07s2w10961945B-7AF1-4182-85E2-DCE08A54F6E6.tif../resources/Projection-Mix/2023-02-21/1334/ZS...
932023-02-2113349Projection-MixE08s1w2B8405A0A-E6F1-49F5-876D-6ECCE85CBFE0.tif../resources/Projection-Mix/2023-02-21/1334/ZS...
942023-02-2113349Projection-MixE08s2w1E6876931-5F26-4F52-8CE6-94C2008473A8.tif../resources/Projection-Mix/2023-02-21/1334/ZS...
952023-02-2113349Projection-MixE08s2w2980ECD4E-B03B-4051-A4BE-5EF45BDA5266.tif../resources/Projection-Mix/2023-02-21/1334/ZS...
\n", - "

96 rows × 10 columns

\n", - "
" - ], - "text/plain": [ - " date acq_id z name well field channel \\\n", - "0 2023-02-21 1334 None Projection-Mix E08 s2 w1 \n", - "1 2023-02-21 1334 None Projection-Mix E08 s1 w2 \n", - "2 2023-02-21 1334 None Projection-Mix E08 s2 w3 \n", - "3 2023-02-21 1334 None Projection-Mix E07 s2 w3 \n", - "4 2023-02-21 1334 None Projection-Mix E07 s1 w2 \n", - ".. ... ... ... ... ... ... ... \n", - "91 2023-02-21 1334 9 Projection-Mix E07 s1 w1 \n", - "92 2023-02-21 1334 9 Projection-Mix E07 s2 w1 \n", - "93 2023-02-21 1334 9 Projection-Mix E08 s1 w2 \n", - "94 2023-02-21 1334 9 Projection-Mix E08 s2 w1 \n", - "95 2023-02-21 1334 9 Projection-Mix E08 s2 w2 \n", - "\n", - " md_id ext \\\n", - "0 B38C01F5-0D36-4A29-9F5A-BE62B6F7F73F .tif \n", - "1 81928711-999D-41F6-B88C-999513D4C092 .tif \n", - "2 CCE83D85-0912-429E-9F18-716A085BB5BC .tif \n", - "3 B0A47337-5945-4B26-9F5F-4EBA468CDBA9 .tif \n", - "4 B14915F6-0679-4494-82D1-F80894B32A66 .tif \n", - ".. ... ... \n", - "91 091EB8A5-272A-466D-B8A0-7547C6BA392B .tif \n", - "92 0961945B-7AF1-4182-85E2-DCE08A54F6E6 .tif \n", - "93 B8405A0A-E6F1-49F5-876D-6ECCE85CBFE0 .tif \n", - "94 E6876931-5F26-4F52-8CE6-94C2008473A8 .tif \n", - "95 980ECD4E-B03B-4051-A4BE-5EF45BDA5266 .tif \n", - "\n", - " path \n", - "0 ../resources/Projection-Mix/2023-02-21/1334/Pr... \n", - "1 ../resources/Projection-Mix/2023-02-21/1334/Pr... \n", - "2 ../resources/Projection-Mix/2023-02-21/1334/Pr... \n", - "3 ../resources/Projection-Mix/2023-02-21/1334/Pr... \n", - "4 ../resources/Projection-Mix/2023-02-21/1334/Pr... \n", - ".. ... \n", - "91 ../resources/Projection-Mix/2023-02-21/1334/ZS... \n", - "92 ../resources/Projection-Mix/2023-02-21/1334/ZS... \n", - "93 ../resources/Projection-Mix/2023-02-21/1334/ZS... \n", - "94 ../resources/Projection-Mix/2023-02-21/1334/ZS... \n", - "95 ../resources/Projection-Mix/2023-02-21/1334/ZS... \n", - "\n", - "[96 rows x 10 columns]" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "files" - ] - }, - { - "cell_type": "markdown", - "id": "5cb9501d", - "metadata": {}, - "source": [ - "## Zarr Organization\n", - "\n", - "We store for each channel a ZYX volume in the zarr-file. Channel 3 contains only zeros and channel 4 contains only zeros except for a single plane. We make use of the zarr-option to __not__ write empty chunks to disk. \n", - "\n", - "The projections for channel 1 and 2 are saved in an extra group `projections` next to the field. While this is not official NGFF ome-zarr spec it follows the logic of how `labels` are described by the NGFF ome-zarr spec.\n", - "\n", - "__Note:__ We montage the individual fields of the well into a single image and then save this in the zeroth field of the well in the ome-zarr." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "948904be", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "if exists(join(zarr_root, \"Projection-Mix.zarr\")):\n", - " # Remove zarr if it already exists.\n", - " shutil.rmtree(join(zarr_root, \"Projection-Mix.zarr\"))\n", - "\n", - "# Build empty zarr plate scaffold.\n", - "plate = build_zarr_scaffold(root_dir=zarr_root,\n", - " files=files,\n", - " layout=96,\n", - " order_name=\"example-order\",\n", - " barcode=\"example-barcode\")" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "37f65e50", - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "99e72a7ad9ad46648742783314b515f4", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - " 0%| | 0/2 [00:00" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "plt.figure(figsize=(10, 6))\n", - "plt.subplot(4, 1, 1)\n", - "plt.imshow(e07_stack[0, ..., :100, 512])\n", - "plt.title(\"C01, ZY-slice\");\n", - "plt.subplot(4, 1, 2)\n", - "plt.imshow(e07_stack[1, ..., :100, 512])\n", - "plt.title(\"C02, ZY-slice\");\n", - "plt.subplot(4, 1, 3)\n", - "plt.imshow(e07_stack[2, ..., :100, 512])\n", - "plt.title(\"C03, ZY-slice\");\n", - "plt.subplot(4, 1, 4)\n", - "plt.imshow(e07_stack[3, ..., :100, 512])\n", - "plt.title(\"C04, ZY-slice\");\n", - "\n", - "plt.suptitle(\"Slices of CZYX stacks\");" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "id": "303802a5", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# row/column/field/projections/resolution-level\n", - "e07_projections = plate['E/7/0/projections/0']" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "79ceae37", - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "text/plain": [ - "(4, 512, 1024)" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "e07_projections.shape" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "id": "ab2bbfdc", - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "image/png": "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jeu1THcTjoSEBL7++muEEGHfV1dX4/P5evX1TkxMJCIigmeffbbH78Hvc67rOpWVlb364B8ocXFxqKpKRUVFl+8CwRh6a7dEIjk4fgh59Mknn3DOOecwZcoU3n777QMeu7+PPAolsHK/YcMGYN8Ycscdd/Qop/Lz8wFwOBzcd9993HfffVRVVQWtSrNmzWL79u29Xvfee+/lV7/6VfDvqKioXssnJCTg8/moqakJU5SEEFRWVnLssccC+/b6lJaW9rqPrDcZFjqOmkwmbrnlFm655RYaGxv59NNPufPOOznttNMoKSnBbrf32u7MzMz9yijoKqcCykNFRUWX9668vHy/MkpRFL744otu97gGjoX2VV+RkJAgZdQRirQkHSXccccdCCG45ppr8Hg8Xb73er188MEHgH8AmzVrFu+88w4tLS3BMsXFxSxbtqzHQf67EFiBC3w6r4Dsj+uvv55Zs2Zx991396i4KYqCEKLLwPb00093645wzz338MUXX/Df//6X119/nQ0bNhyUNem7kpubS3V1dXDVEfwBEj755JNDfu2+YPr06bS2tvLee++FHX/xxReD3/fEzJkz2bNnDwkJCV3eiXHjxgXdKM844wwA/vWvf/XaFqvVekCrZg6HgwkTJvDOO++ElTcMg5dffpnMzEwGDRq033okEsmBcyjl0aJFizjnnHM4/vjjee+993oN2tKZ7yuPAqxfvx4gGCgiPz+fgQMHsmHDhm7Ht3HjxnWr0KSkpDBnzhx+8YtfsGPHDtrb24GeLQO5ublhdQYUr54IjMkvv/xy2PG3336btra24Pennnoqmqbtd9zNzc1l48aNYcd27tzZxWU5lNjYWGbPns0NN9xAfX39IU3QPm3aNKDr/a5evZpt27btV0YJISgrK+v2+Y0YMQKAyZMnExMTw5NPPtmru+bBWHemT5/O1q1bWbt2bdjxF198EUVRmDp16n7rkBwapCXpKGHSpEn861//Yu7cuYwdO5brr7+eYcOG4fV6WbduHU899RTDhw9n1qxZANx3330ce+yxzJw5k9tvvx2Xy8Xdd99NYmIiv/nNb8LqXrNmTXBga25uRggRzHB+7LHHkpOTc8jua9SoUV0m5Z2Jjo7mxBNP5C9/+QuJiYnk5uayfPlynnnmmS5uGosXL2bevHn84Q9/CA6Y8+bN49Zbb+Wkk07iZz/72SG6E7jgggu4++67ufDCC/ntb3+Ly+Xin//85379yo8ULrvsMp544gkuv/xyCgsLGTFiBCtWrODBBx/kzDPP7DY0e4CbbrqJt99+mxNPPJGbb76ZkSNHYhgGxcXFLFq0iN/85jdMmDCBE044gUsvvZQHHniAqqoqZs6cidVqZd26ddjt9uC+gxEjRvDaa6/x+uuvk5eXh81mCwqxzsybN49TTjmFqVOncuutt2KxWJg/fz6bN2/m1Vdf7TO/colE4udQyaMVK1ZwzjnnkJqayp133hlUVgIMHTq0i5vw96WxsTEYXtnr9bJt2zYefPBBrFZr2B6ef//735xxxhmcdtppzJkzh4yMDOrr69m2bRtr167lzTffBGDChAnMnDmTkSNHEhcXx7Zt23jppZeYNGlS0MISGMseeughzjjjDDRNY+TIkQft7XDKKadw2mmncdttt9Hc3Mxxxx0XjG43evRoLr30UsCv/Nx555388Y9/xOl08otf/IKYmBi2bt1KbW0t9913HwCXXnopl1xyCXPnzuW8886jqKiIhx9+uIs736xZsxg+fDjjxo0jKSmJoqIiHn30UXJychg4cOB3eAoHRn5+Ptdeey2PPfYYqqpyxhlnBKPbZWVlcfPNN/d47nHHHce1117LFVdcwZo1azjxxBNxOBxUVFSwYsUKRowYwfXXX09kZCR/+9vfuPrqqzn55JO55pprSElJYffu3WzYsIHHH38cOLhnePPNN/Piiy8yY8YM7r//fnJycliwYAHz58/n+uuvlwt5h5PDES1CcuhYv369uPzyy0V2drawWCzC4XCI0aNHi7vvvjssc7kQQqxZs0ZMnz5d2O12ER0dLc4555xus0AHop119wmNItYX9JS8L5TuotuVlpaK8847T8TFxYmoqChx+umni82bN4dFkSkvLxfJycli2rRpYQlyDcMQs2bNErGxscFodj1Ftxs2bNgBtxkQN9xwQ9ixjz76SIwaNUpERESIvLw88fjjj/cY3a7zuT0l2lu2bJkAxJtvvtljn4XeU+cIc53pLkpSgLq6OvHLX/5SpKWlCZPJJHJycsQdd9zRJYljTk6OmDNnTtix1tZW8fvf/17k5+cLi8UiYmJixIgRI8TNN98cjKwnhD958SOPPCKGDx8eLDdp0iTxwQcfBMsUFhaKU089VURFRQkgGKWou+h2Qgjx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g1NPPXWXr1taWkpCQgLDGY/Lcu/p6YscfJrLWqfgSPajOlPL57xLSUkJ8fHxbX06jahtEhE5vOyLdmmvcjOWlJQAkJSUBEBubi55eXmMGTMmWMbj8TBs2DDmzZsHwMKFC6mtrQ0rk5mZSb9+/YJlGvJ6vZSWloY9RA5LDYcyaFiDSCNqm0REZG/tcZBkjOGmm27ixBNPpF+/fgDk5eUBkJ6eHlY2PT09uC8vL4+IiAgSExObLdPQlClTSEhICD6ysrL29LRFDh0KjkQaUdskIiKtYY+DpOuuu47FixfzxhtvNNrXcGFLY0zjxS4b2FmZO+64g5KSkuBjw4YNe3raIiJyCFPbJCIirWGPgqTrr7+e9957j9mzZ9OxY8fg9oyMDIBGv7rl5+cHf8HLyMigpqaGoqKiZss05PF4iI+PD3uIiIiEUtskIiKtpUVBkjGG6667jnfeeYdZs2aRnZ0dtj87O5uMjAxmzpwZ3FZTU8OcOXMYOnQoAIMGDcLtdoeV2bJlC0uXLg2WERER2V1qm0REpLW1aDHZa6+9ltdff513332XuLi44K9yCQkJREVFYVkWOTk5TJ48mR49etCjRw8mT55MdHQ0EyZMCJa96qqruPnmm0lOTiYpKYlbbrmF/v37M2rUqNa/QhEROaSpbRIRkdbWoiDpqaeeAmD48OFh21944QWuuOIKAG699VaqqqqYOHEiRUVFDBkyhE8++YS4uLhg+cceewyXy8UFF1xAVVUVI0eO5MUXX8TpdO7d1YiIyGFHbZOIiLS2vVonqa1oLQoRkbZzoK+T1FbUNomItI0Dbp0kERERERGRQ42CJBERERERkRAKkkREREREREIoSBIREREREQmhIElERERERCSEgiQREREREZEQCpJERERERERCKEgSEREREREJoSBJREREREQkhIIkERERERGREAqSREREREREQihIEhERERERCaEgSUREREREJISCJBERERERkRAKkkREREREREIoSBIREREREQmhIElERERERCSEgiQREREREZEQCpJERERERERCKEgSEREREREJoSBJREREREQkhIIkERERERGREAqSREREREREQihIEhERERERCdGiIOmpp55iwIABxMfHEx8fz/HHH8+HH34Y3G+MYdKkSWRmZhIVFcXw4cNZtmxZ2DG8Xi/XX389KSkpxMTEcNZZ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SWOqk8ZUJ+FUH421RZ0ctMPtRERERERE9oWDtidJRERERERkX1CQJCIiIiIiEkJBkoiIiIiISAgFSSIiIiIiIiEUJImIiIiIiIQ4KIOkJ598kuzsbCIjIxk0aBBffvllW5/SPjFlyhSOOeYY4uLiSEtL4+yzz2blypVhZYwxTJo0iczMTKKiohg+fDjLli0LK+P1ern++utJSUkhJiaGs846i40bN+7PS9lnpkyZgmVZ5OTkBLcdjnWyadMmLr30UpKTk4mOjubII49k4cKFwf2HU53U1dVx9913k52dTVRUFF27duX+++/Htu1gmUO9Pr744gvOPPNMMjMzsSyL//73v2H7W+v6i4qKuOyyy0hISCAhIYHLLruM4uLifXx1By61TTsc6n9jO6N2yU/tUji1TQdh22QOMtOmTTNut9s8++yzZvny5eaGG24wMTExZt26dW19aq3u1FNPNS+88IJZunSpWbRokTnjjDNMp06dTHl5ebDMgw8+aOLi4szbb79tlixZYi688ELTvn17U1paGizz+9//3nTo0MHMnDnTfP/992bEiBFm4MCBpq6uri0uq9V8++23pkuXLmbAgAHmhhtuCG4/3Opk+/btpnPnzuaKK64w33zzjcnNzTWffvqpWb16dbDM4VQnf/nLX0xycrL54IMPTG5urvn3v/9tYmNjzd/+9rdgmUO9PmbMmGHuuusu8/bbbxvATJ8+PWx/a13/aaedZvr162fmzZtn5s2bZ/r162fGjRu3vy7zgKK2SW2TMWqXAtQuNaa26eBrmw66IOnYY481v//978O29e7d29x+++1tdEb7T35+vgHMnDlzjDHG2LZtMjIyzIMPPhgsU11dbRISEszTTz9tjDGmuLjYuN1uM23atGCZTZs2GYfDYT766KP9ewGtqKyszPTo0cPMnDnTDBs2LNgYHY51ctttt5kTTzyx2f2HW52cccYZ5sorrwzbds4555hLL73UGHP41UfDhqi1rn/58uUGMF9//XWwzPz58w1gVqxYsY+v6sCjtkltk9qlHdQuNaa2KdzB0DYdVMPtampqWLhwIWPGjAnbPmbMGObNm9dGZ7X/lJSUAJCUlARAbm4ueXl5YfXh8XgYNmxYsD4WLlxIbW1tWJnMzEz69et3UNfZtddeyxlnnMGoUaPCth+OdfLee+8xePBgzj//fNLS0jjqqKN49tlng/sPtzo58cQT+eyzz/j5558B+PHHH5k7dy6nn346cPjVR0Otdf3z588nISGBIUOGBMscd9xxJCQkHPR11FJqm9Q2gdqlUGqXGlPbtHMHYtvk2psL2t8KCgrw+Xykp6eHbU9PTycvL6+Nzmr/MMZw0003ceKJJ9KvXz+A4DU3VR/r1q0LlomIiCAxMbFRmYO1zqZNm8b333/PggULGu07HOvkl19+4amnnuKmm27izjvv5Ntvv+UPf/gDHo+HX//614ddndx2222UlJTQu3dvnE4nPp+PBx54gIsvvhg4PD8joVrr+vPy8khLS2t0/LS0tIO+jlpKbZPaJrVL4dQuNaa2aecOxLbpoAqSAizLCvu3MabRtkPNddddx+LFi5k7d26jfXtSHwdrnW3YsIEbbriBTz75hMjIyGbLHU51Yts2gwcPZvLkyQAcddRRLFu2jKeeeopf//rXwXKHS528+eabvPrqq7z++uscccQRLFq0iJycHDIzM7n88suD5Q6X+mhOa1x/U+UPpTpqKbVN4Q6XvzG1S42pXWpMbdPuOZDapoNquF1KSgpOp7NRJJifn98o8jyUXH/99bz33nvMnj2bjh07BrdnZGQA7LQ+MjIyqKmpoaioqNkyB5OFCxeSn5/PoEGDcLlcuFwu5syZw9///ndcLlfwmg6nOmnfvj19+/YN29anTx/Wr18PHH6fkz/+8Y/cfvvtXHTRRfTv35/LLruMG2+8kSlTpgCHX3001FrXn5GRwdatWxsdf9u2bQd9HbWU2qbDu21Su9SY2qXG1Dbt3IHYNh1UQVJERASDBg1i5syZYdtnzpzJ0KFD2+is9h1jDNdddx3vvPMOs2bNIjs7O2x/dnY2GRkZYfVRU1PDnDlzgvUxaNAg3G53WJktW7awdOnSg7LORo4cyZIlS1i0aFHwMXjwYC655BIWLVpE165dD7s6OeGEExql3/3555/p3LkzcPh9TiorK3E4wr/anE5nMM3q4VYfDbXW9R9//PGUlJTw7bffBst88803lJSUHPR11FJqmw7vtkntUmNqlxpT27RzB2Tb1KI0DweAQJrV5557zixfvtzk5OSYmJgYs3bt2rY+tVZ3zTXXmISEBPP555+bLVu2BB+VlZXBMg8++KBJSEgw77zzjlmyZIm5+OKLm0yX2LFjR/Ppp5+a77//3pxyyikHTbrI3RGaRciYw69Ovv32W+NyucwDDzxgVq1aZV577TUTHR1tXn311WCZw6lOLr/8ctOhQ4dgmtV33nnHpKSkmFtvvTVY5lCvj7KyMvPDDz+YH374wQDm0UcfNT/88EMwHXVrXf9pp51mBgwYYObPn2/mz59v+vfvf9inAFfb5Heo/43titoltUsNqW06+Nqmgy5IMsaYf/7zn6Zz584mIiLCHH300cG0o4caoMnHCy+8ECxj27a59957TUZGhvF4PObkk082S5YsCTtOVVWVue6660xSUpKJiooy48aNM+vXr9/PV7PvNGyMDsc6ef/9902/fv2Mx+MxvXv3Ns8880zY/sOpTkpLS80NN9xgOnXqZCIjI03Xrl3NXXfdZbxeb7DMoV4fs2fPbvK74/LLLzfGtN71FxYWmksuucTExcWZuLg4c8kll5iioqL9dJUHHrVNLwTLHOp/Y7uidkntUkNqmw6+tskyxpiW9T2JiIiIiIgcug6qOUkiIiIiIiL7moIkERERERGREAqSREREREREQihIEhERERERCaEgSUREREREJISCJBERERERkRAKkkREREREREIoSBIREREREQmhIElERERERCSEgiQREREREZEQCpJERERERERCKEgSEREREREJoSBJREREREQkhIIkERERERGREAqSREREREREQihIEhERERERCaEgSUREREREJISCJBERERERkRAKkkREREREREIoSBIREREREQmhIElERERERCSEgiQREREREZEQCpJERERERERCKEgSEREREREJoSBJREREREQkhIIkERERERGREAqSREREREREQihIEhERERERCaEgSUREREREJISCJBERERERkRAKkkREREREREIoSBIREREREQmhIElERERERCSEgiQREREREZEQCpIOM4sXL+Y3v/kN2dnZREZGEhsby9FHH83UqVPZvn17WNnvv/+eUaNGERsbS7t27TjnnHP45ZdfwspUVFRw0UUX0atXL+Li4oiJieGII47gL3/5CxUVFa1+/l26dMGyLIYPH97k/pdffhnLsrAsi88//7zVXz/gxRdfxLIs1q5du89eo6WuuOKK4LVbloXH46FXr17ce++9VFdXt9rrrF27FsuyePHFF1vtmKEqKyuZNGlSk+/fgVjvIvtba3+PN7R8+XI8Hg+WZfHdd9+1+vkHvsebejT33b6vzZgxg0mTJrXJawcEvt8iIyNZt25do/3Dhw+nX79+rfZ6kyZNCqv7iIgIsrOzueGGGyguLm611wGwLGuf1u/kyZP573//22j7559/vs/vB3bHOeecg2VZXHfddW16Hg3t7zZ1X38OWpurrU9A9p9nn32WiRMn0qtXL/74xz/St29famtr+e6773j66aeZP38+06dPB2DFihUMHz6cI488krfeeovq6mruueceTjrpJBYtWkRqaioAtbW1GGO46aabyM7OxuFw8MUXX3D//ffz+eef8+mnn7b6dcTFxfHFF1+wZs0aunXrFrbv+eefJz4+ntLS0lZ/3VBnnHEG8+fPp3379vv0dVoqKiqKWbNmAVBUVMQbb7zB/fffz4oVK3jzzTdb5TXat2/P/PnzG9V9a6msrOS+++4DaHTDdKDWu8j+si++x0P5fD6uvPJKUlJS2Lx58z67jhNOOIG//vWvjbbHx8fvs9fcmRkzZvDPf/7zgLiB83q93H333bzyyiv75fU++ugjEhISKCsrY8aMGTz++ON8++23zJs3D8uyWuU15s+fT8eOHVvlWE2ZPHky5513HmeffXbY9qOPPpr58+fTt2/fffbau5Kfn88HH3wAwGuvvcZf//pXIiMj2+x8QqlN3QUjh4V58+YZp9NpTjvtNFNdXd1ov9frNe+++27w3+eff75JSUkxJSUlwW1r1641brfb3Hrrrbt8vVtvvdUAZs2aNa1zAfU6d+5sxo4dazp27GjuvPPOsH2rV682lmWZ3/3udwYws2fPbtXXPtBdfvnlJiYmptH2k046yQBm48aNzT63srJyX55ai2zbts0A5t57723rUxE5oOyP7/GHH37YdOjQwTz++OMGMAsWLGj16+jcubM544wzWv24e+Paa681bX1L9MILLxjAnHbaacbhcJhFixaF7R82bJg54ogjWu317r33XgOYbdu2hW2/7LLLDGDmzp3b7HMrKipa7TxaQ0xMjLn88svb+jSa9PDDDxvAnHHGGQYwr732WlufUou11vt9sLXtGm53mJg8eTKWZfHMM8/g8Xga7Y+IiOCss84CoK6ujg8++IBzzz037Fe9zp07M2LEiOCvlDsT+IXS5Wr9zkqHw8Gvf/1rXnrpJWzbDm5//vnnycrKYtSoUY2e891333HRRRfRpUsXoqKi6NKlCxdffHHYkAZjDKeffjrJycmsX78+uL2yspIjjjiCPn36BIcQNtVF3aVLF6644opGrz18+PCwHpFA9//rr7/ObbfdRvv27YmNjeXMM89k69atlJWV8X//93+kpKSQkpLCb37zG8rLy/e4vo477jiA4LV26dKFcePG8c4773DUUUcRGRkZ7LlZunQp48ePJzExkcjISI488kheeumlsOM1N9xu1apVTJgwgbS0NDweD3369OGf//xno/MpLi7m5ptvpmvXrng8HtLS0jj99NNZsWIFa9euDX527rvvvuAwkEC9Njc04Pnnn2fgwIFERkaSlJTEr371K3766aewMldccQWxsbGsXr2a008/ndjYWLKysrj55pvxer17VLci+9O+/h5ftWoV99xzD08++WSb9eiECgwHW7x4Meeffz4JCQkkJSVx0003UVdXx8qVKznttNOIi4ujS5cuTJ06Nez5ge/aV199lZtuuomMjAyioqIYNmwYP/zwQ7DcFVdcEfyuCh1+tnbtWkaOHEnv3r0xxoQd2xhD9+7dOeOMM1r9um+99VaSk5O57bbbdlm2urqaO+64g+zsbCIiIujQoQPXXnvtXg2Xa9hmBIb5ffHFFwwdOpTo6GiuvPJKANavX8+ll14a9r3/yCOPhLXN0PQwq7y8PK6++mo6duwYHOp33333UVdXF1bO6/Vy//3306dPHyIjI0lOTmbEiBHMmzcveOyKigpeeumlRsM2mxtu995773H88ccTHR1NXFwco0ePZv78+WFlAp+/ZcuWcfHFF5OQkEB6ejpXXnklJSUlu12fzz//POnp6bz00ktERUXx/PPPN1num2++4cwzzyQ5OZnIyEi6detGTk5OWJn//e9/HHnkkXg8HrKzs/nrX/8aPM+AnQ2Jb/g+NNWm7uz9Li0t5ZZbbgn7vOXk5DSaXlFaWsrvfvc7kpOTiY2N5bTTTuPnn3/e7To7UGi43WHA5/Mxa9YsBg0aRFZW1i7Lr1mzhqqqKgYMGNBo34ABA5g5cybV1dVh3cXGGHw+H5WVlcybN49HHnmEiy++mE6dOrXqtQRceeWVTJkyhY8//pixY8fi8/l46aWXuOqqq3A4Gsf+a9eupVevXlx00UUkJSWxZcsWnnrqKY455hiWL19OSkoKlmXxyiuvcOSRR3LBBRfw5Zdf4na7mThxIrm5uXzzzTfExMS02jXceeedjBgxghdffJG1a9dyyy23cPHFF+NyuRg4cCBvvPEGP/zwA3feeSdxcXH8/e9/36PXWb16NUDY0Jrvv/+en376ibvvvpvs7GxiYmJYuXIlQ4cOJS0tjb///e8kJyfz6quvcsUVV7B161ZuvfXWZl9j+fLlDB06lE6dOvHII4+QkZHBxx9/zB/+8AcKCgq49957ASgrK+PEE09k7dq13HbbbQwZMoTy8nK++OILtmzZwtChQ/noo4847bTTuOqqq/jtb3/b6NwbmjJlCnfeeScXX3wxU6ZMobCwkEmTJnH88cezYMECevToESxbW1vLWWedxVVXXcXNN9/MF198wZ///GcSEhK455579qh+RfaHff09bozht7/9LePGjeOss87aZ3MOA4wxjW6GAZxOZ6MhXhdccAGXXnopV199NTNnzmTq1KnU1tby6aefMnHiRG655Zbgj07du3fnnHPOCXv+nXfeydFHH82//vUvSkpKmDRpEsOHD+eHH36ga9eu/OlPf6KiooL//Oc/YTfK7du354YbbmD8+PF89tlnYT/Affjhh6xZs2aPv5d3Ji4ujrvvvpsbbriBWbNmccoppzRZzhjD2WefzWeffcYdd9zBSSedxOLFi7n33nuZP38+8+fPbzKY3pWm2owtW7Zw6aWXcuuttzJ58mQcDgfbtm1j6NCh1NTU8Oc//5kuXbrwwQcfcMstt7BmzRqefPLJZl8jLy+PY489FofDwT333EO3bt2YP38+f/nLX1i7di0vvPAC4A/2x44dy5dffklOTg6nnHIKdXV1fP3116xfv56hQ4cyf/58TjnlFEaMGMGf/vQnYOfDNl9//XUuueQSxowZwxtvvIHX62Xq1KkMHz6czz77jBNPPDGs/LnnnsuFF17IVVddxZIlS7jjjjsAmg12Qs2bN4+ffvqJP/7xjyQnJ3Puuefy2muvkZubS3Z2drDcxx9/zJlnnkmfPn149NFH6dSpE2vXruWTTz4Jlvnss88YP348xx9/PNOmTcPn8zF16lS2bt26y/Noqabe78rKSoYNG8bGjRu58847GTBgAMuWLeOee+5hyZIlfPrpp1iWFfxczps3j3vuuYdjjjmGr776irFjx7b6ee5zbdeJJftLXl6eAcxFF120W+W/+uorA5g33nij0b7JkycbwGzevDls+xtvvGGA4OM3v/mNqa2tbZXzDxU6TGPYsGHmvPPOM8YY87///c9YlmVyc3PNv//9710Ot6urqzPl5eUmJibGPP7442H75s6da1wul8nJyTHPP/+8Acy//vWvsDKBYRG5ublh59ZUd/+wYcPMsGHDgv+ePXu2AcyZZ54ZVi4nJ8cA5g9/+EPY9rPPPtskJSU1ey0BgeF2tbW1pra21mzbts08/vjjxrIsc8wxx4Sdp9PpNCtXrgx7/kUXXWQ8Ho9Zv3592PaxY8ea6OhoU1xcbIwxJjc31wDmhRdeCJY59dRTTceOHcOG9RhjzHXXXWciIyPN9u3bjTHG3H///QYwM2fObPY6djbcrmG9FxUVmaioKHP66aeHlVu/fr3xeDxmwoQJYfUDmLfeeius7Omnn2569erV7PmIHAj29ff4P/7xD5OYmGjy8vKMMTv+1vbVcLvQ9iL08ec//zlYLjAc7JFHHgl7/pFHHmkA88477wS31dbWmtTUVHPOOecEtwW+a48++mhj23Zwe2DI4W9/+9vgtuaG2/l8PtO1a1czfvz4sO1jx4413bp1Czvu3gqtc6/Xa7p27WoGDx4cfI2Gw+0++ugjA5ipU6eGHefNN980gHnmmWd2+nqB+s3LyzO1tbWmqKjIvPrqqyYqKspkZWWZqqqq4OsC5rPPPgt7/u23324A880334Rtv+aaa4xlWWFtTMPv9KuvvtrExsaadevWhT33r3/9qwHMsmXLjDHGvPzyywYwzz777E6vpbnhdoHPQOB+wOfzmczMTNO/f3/j8/mC5crKykxaWpoZOnRoo/ppWL8TJ040kZGRu/XeX3nllQYwP/30U9j5/OlPfwor161bN9OtW7dgnTdlyJAhJjMzM6xMaWmpSUpKCvvsNtVGBzR8H5q6l2nu/Z4yZYpxOByNvhP+85//GMDMmDHDGGPMhx9+aIBG91YPPPCAhtvJoWNnEzYb7jv11FNZsGABs2bN4oEHHuDtt9/m3HPPbdTl3lBdXV3YwzQY0rAzV155Je+99x6FhYU899xzjBgxgi5dujRZtry8PPgro8vlwuVyERsbS0VFRaNhWSeccAIPPPAAf/vb37jmmmu49NJLueqqq3b7vHbXuHHjwv7dp08fgEbDN/r06cP27dt3a8hdRUUFbrcbt9tNamoqOTk5jB07ttHQmgEDBtCzZ8+wbbNmzWLkyJGNfqW+4oorqKysbDQUIaC6uprPPvuMX/3qV0RHR4e9n6effjrV1dV8/fXXgP/X1549ezY5JHJPzJ8/n6qqqkbDHLOysjjllFP47LPPwrZblsWZZ54Ztm3AgAFNZpISORTszvf4unXruOOOO3j44YdJT09v8Wvsyff4iSeeyIIFCxo9mvqubeq70rKssF+mXS4X3bt3b/JvecKECWH10LlzZ4YOHcrs2bN3eZ4Oh4PrrruODz74IDgMe82aNXz00UdMnDhxp/Vr23ZYvfh8vl2+XkBERAR/+ctf+O6773jrrbeaLBNI0tPw++/8888nJiam0fdfczIyMnC73SQmJnLppZdy9NFH89FHH4WNFklMTGzUozVr1iz69u3LscceG7b9iiuuwBgTPL+mfPDBB4wYMYLMzMywOgq8p3PmzAH8bUZkZGRwuNfeWrlyJZs3b+ayyy4LG3USGxvLueeey9dff01lZWXYcwJDWAMGDBhAdXU1+fn5O32t8vJy3nrrLYYOHUrv3r0BGDZsGN26dePFF18M3h/9/PPPrFmzhquuuqrZhA4VFRUsWLCAc845J6xMXFxcozatNTT1fn/wwQf069ePI488Muw9O/XUU8OGNAb+ri655JKw50+YMKHVz3NfU5B0GEhJSSE6Oprc3NzdKp+cnAxAYWFho33bt2/HsizatWsXtj0xMZHBgwczYsQI7rzzTp555hnee+893n333Z2+VuCGPvBoOP9lZ8477zwiIyN57LHHeP/993cayEyYMIEnnniC3/72t3z88cd8++23LFiwgNTUVKqqqhqVv+SSS4iIiMDr9fLHP/5xt8+pJZKSksL+HRERsdPtu5PGOyoqKnizsXjxYoqLi/nf//5Hhw4dwso1lcmmsLCwye2ZmZnB/U0pLCykrq6Of/zjH43ez9NPPx2AgoICALZt29aqGY4C59TceTc85+jo6EaNkMfjadUU6SL7wr78Hr/22mvp168f5557LsXFxRQXFwdvFMvLy3c5/2JPvscTEhIYPHhwo0dTf8tNfSc29bccERHR5N9yRkZGk9ua+05r6MorryQqKoqnn34agH/+859ERUXt8sb9/vvvD6uXlmYEveiiizj66KO56667qK2tbbS/sLAQl8vVaDiyZVktur5PP/2UBQsWsGjRIgoKCpg7d26jbHCt2WYAbN26lffff7/RZ+eII44AwtuMzMzMJofR74ldtRm2bVNUVBS2PfC3FBAYwtjUvUOoN998k/Lyci644ILg31VJSQkXXHABGzZsYObMmYD/GoGdto1FRUXYtt3sZ7m1NVU/W7duZfHixY3es7i4OIwxwfcs8LlsWG/74jz3Nc1JOgw4nU5GjhzJhx9+yMaNG3d5k9qtWzeioqJYsmRJo31Lliyhe/fuu0xfGfhlaVcT9RYsWBD279AxursSHR3NRRddxJQpU4iPj280Dj2gpKSEDz74gHvvvZfbb789uN3r9TZaUwT8Y/8vueQSEhMT8Xg8XHXVVXz11VfBYKU5kZGRTSYAKCgoICUlZbeva284HA4GDx68y3JN/fqZnJzMli1bGm0PpAFu7hoSExNxOp1cdtllXHvttU2WCbyvqampbNy4cZfnt7sCX8LNnff+qneRfW1ffo8vXbqUdevWkZiY2KjsiBEjSEhI2GkigL35Ht8f8vLymtzW8CauOQkJCVx++eX861//4pZbbuGFF15gwoQJjX4sbOj//u//wnrBWjo/yLIsHnroIUaPHs0zzzzTaH9ycjJ1dXVs27YtLFAyxpCXl8cxxxyzW68zcODAXX5XtmabEdg3YMAAHnjggSb3BwKt1NRU5s6di23brRIo7arNcDgcTf4d7InnnnsOgJycnEYJGAL7Tz311OB7t7O2MTExEcuymv0shwr8XTe8H9ndoBmafr9TUlJ2mngi8H4HPpeFhYVhf2NNnfuBTj1Jh4k77rgDYwy/+93vqKmpabS/traW999/H/APWzjzzDN55513KCsrC5ZZv349s2fPbjYYCRXobu3evftOyzX8FXF3G62Aa665hjPPPJN77rmn2cAtMJGwYQP1r3/9q8nhD/feey9ffvklr732Gm+++SY//vjjbvUmdenShcWLF4dt+/nnn1m5cmULrqjtjBw5klmzZjVaG+Xll18mOjo6mPGooejoaEaMGMEPP/zAgAEDmvx1OPC+jh07lp9//nmnwzB291c6gOOPP56oqCheffXVsO0bN24MDh8UOVTsq+/xadOmMXv27LBHILPa008/HVzjpTl7+z2+r73xxhthQwDXrVvHvHnzwrKO7up7J5CE5rzzzqO4uHi3FgXNzMwMq5f+/fu3+NxHjRrF6NGjuf/++xsNuQ58vzX8/nv77bepqKjY599/I0eOZPny5Xz//fdh2wOLuo8YMaLZ544bN46lS5fSrVu3JtuMQJA0duxYqqurd5lIxOPx7Fab0atXLzp06MDrr78e9pmoqKjg7bffDma821s//fQT8+fP59xzz230tzV79mxGjhzJu+++S2FhIT179qRbt248//zzzWZajYmJ4dhjj+Wdd94J6y0tKysL/s0HpKenExkZ2eh+ZFcje3Zl3LhxrFmzhuTk5Cbfs8B0h8D7/tprr4U9//XXX9+r128L6kk6TBx//PE89dRTTJw4kUGDBnHNNddwxBFHUFtbyw8//MAzzzxDv379gmNb77vvPo455hjGjRvH7bffHlyEMCUlhZtvvjl43P/3//4fX375JWPGjCErK4uKigq+/PJL/vGPfzB06FDGjx+/T6/ryCOPbHKV7VDx8fGcfPLJPPzww6SkpNClSxfmzJnDc8891+iXwJkzZzJlyhT+9Kc/BRuYKVOmcMsttzB8+HB+9atfNfs6l112GZdeeikTJ07k3HPPZd26dUydOnWnmdkOJPfee29wnPg999xDUlISr732Gv/73/+YOnUqCQkJzT738ccf58QTT+Skk07immuuoUuXLpSVlbF69Wref//9YFCUk5PDm2++yfjx47n99ts59thjqaqqYs6cOYwbN44RI0YQFxdH586deffddxk5ciRJSUnB962hdu3a8ac//Yk777yTX//611x88cUUFhZy3333ERkZGcyqJ3Io2Fff4039ABJICTxo0KDd6p1uqeLi4uBcxVAej4ejjjqqVV8rPz+fX/3qV/zud7+jpKSEe++9l8jIyGCWMiAYwDz00EOMHTsWp9PJgAEDgiMIevbsyWmnncaHH37IiSeeyMCBA1v1HHfmoYceYtCgQeTn5weHowGMHj2aU089ldtuu43S0lJOOOGEYHa7o446issuu2yfnteNN97Iyy+/zBlnnMH9999P586d+d///seTTz7JNddc02jea6j777+fmTNnMnToUP7whz/Qq1cvqqurWbt2LTNmzODpp5+mY8eOXHzxxbzwwgv8/ve/Z+XKlYwYMQLbtvnmm2/o06cPF110EeB//z7//HPef/992rdvT1xcHL169Wr0ug6Hg6lTp3LJJZcwbtw4rr76arxeLw8//DDFxcU8+OCDrVI3gV6kW2+9tdGcLfAHN5999hmvvvoqN9xwA//85z8588wzOe6447jxxhvp1KkT69ev5+OPPw4GG3/+85857bTTGD16NDfffDM+n4+HHnqImJiYsFExlmVx6aWX8vzzz9OtWzcGDhzIt99+u9dBSk5ODm+//TYnn3wyN954IwMGDMC2bdavX88nn3zCzTffzJAhQxgzZgwnn3wyt956KxUVFQwePJivvvpqvy2O3KraKmOEtI1FixaZyy+/3HTq1MlERESYmJgYc9RRR5l77rnH5Ofnh5X97rvvzMiRI010dLSJj483Z599tlm9enVYma+++sqMGzfOZGZmmoiICBMdHW0GDhxo/vznP++TxeZ2ZxHCprLbbdy40Zx77rkmMTHRxMXFmdNOO80sXbo0LCPd5s2bTVpamjnllFPCst7Ytm3OPPNM065du2AGmKYywti2baZOnWq6du1qIiMjzeDBg82sWbOazW7373//O+y8m8sm1dyCfw01t5hsQzurwyVLlpgzzzzTJCQkmIiICDNw4MBGGXICmXNefPHFRtuvvPJK06FDB+N2u01qaqoZOnSo+ctf/hJWrqioyNxwww2mU6dOxu12m7S0NHPGGWeYFStWBMt8+umn5qijjjIej8cAwfeoqXo3xph//etfZsCAASYiIsIkJCSY8ePHB7Mj7ap+AvUrcrBo7e/xprRVdrsOHToEyzX33dfc33LD7G+B79pXXnnF/OEPfzCpqanG4/GYk046yXz33Xdhz/V6vea3v/2tSU1NNZZlNfk98+KLLxrATJs2rRVqobGd1fmECRMM0Ggx2aqqKnPbbbeZzp07G7fbbdq3b2+uueYaU1RUtMvX2922ZWeL2K5bt85MmDDBJCcnG7fbbXr16mUefvjhsDbUGH9WtUmTJoVt27Ztm/nDH/5gsrOzjdvtNklJSWbQoEHmrrvuMuXl5WHXeM8995gePXqYiIgIk5ycbE455RQzb968YJlFixaZE044wURHRxsg2OY2zG4X8N///tcMGTLEREZGmpiYGDNy5Ejz1Vdf7Vb9NNcOBdTU1Ji0tDRz5JFHNrnfGH+G3Y4dO5r+/fsHt82fP9+MHTvWJCQkGI/HY7p162ZuvPHGsOe99957wbauU6dO5sEHH2yyDSspKTG//e1vTXp6uomJiTFnnnmmWbt27W5nt2vu/S4vLzd333236dWrV7C97d+/v7nxxhuDmTGNMaa4uNhceeWVpl27diY6OtqMHj3arFix4qDLbmcZ04J0YiJy2Pvxxx858sgjef/99xtlnRIROVB8/vnnjBgxgn//+9+cd955e328QPaztWvX4na7W+EMDw8lJSW0a9eOf/zjH7s1TFFaZtKkSdx3330tyg4su0fD7URkt82ePZt//etfREREcPTRR7f16YiI7FNer5fvv/+eb7/9lunTp/Poo48qQGqBr7/+mjfffBPwDxcVOZgoSBKR3TZ69Giys7N54YUXghNrRUQOVVu2bGHo0KHEx8dz9dVXc/3117f1KR1UJkyYgM/n45FHHmHQoEFtfToiLaLhdiIiIiIiIiHaNAX4k08+SXZ2NpGRkQwaNIgvv/yyLU9HREQOc2qXREQE2jBIevPNN8nJyeGuu+7ihx9+4KSTTmLs2LGsX7++rU5JREQOY2qXREQkoM2G2w0ZMoSjjz6ap556KritT58+nH322UyZMiWsrNfrDVtgy7Zttm/fTnJycpOrAouIyL5jjKGsrIzMzEwcjkNnTfKWtEugtklE5ECxL9qlNkncUFNTw8KFC7n99tvDto8ZM4Z58+Y1Kj9lyhTuu+++/XV6IiKyGzZs2EDHjh3b+jRaRUvbJVDbJCJyoGnNdqlNgqSCggJ8Ph/p6elh29PT08nLy2tU/o477uCmm24K/rukpIROnTpxIqfj4iBOxRn4pdFyYDmdONun8ssVHUg+Mp/8Fan0eHYrvo15GJ8PjO0vu6uOv9BjOiwslxOrQ3tqOiTg2VCEvWUrps6Hsc3uH7OJ42I5wFG/rf5Yxg4/juXY8Zyg+tcMlm3JORyImqgXy2mBw4HdJ5u8W3z8qsuPfLu9C5WPZ4IFece7sDtW46t0kZJZggND2fxU4tfbpF+Vy8CEjXywoR+27aB9fCm5X3Smy7vbwbbBslj5+wT+cvLbRDpqeHrDcDbMy8LXvRKfz8Gv+3/N4OhfeK/oaHLLkrmkwzcATF0+hpjIGk5I/4UvtnSjeHssPbPyWPlzBzK+dFB+fikjOqzi/e+OIusjw4axFkTYJCyO4PTL5zI6dhnbfLHc9tX5YFtgW0QkVvHM0a+S4Kjh4bzRrP9rT3y/K+SvPf6D17i4cu5v6PVYCZSUYaqqwOfD+Gz/f0M/f2H16aj/T8hnJ/DZCi2/Pz8vlhV8fx1Jifx8fwceHPIfnJbN1NWnkXRDFb78wh1/pwfrZ7kF6qhlLjOIi4tr61NpNS1tl+AQbptERA4y+6JdatMU4A2HIxhjmhyi4PF48Hg8jba7cOOyDuKGKPQGGwvyS+jxtIF2cSQVbcAuK8eFE2M5gPqbr12N4Ai7aXfi6J7NL/dGcF7PRby14ii63xeLvXodxvj8xwT8C53vxnGDN7BOnMmJlB3XmTqPg8R5G/Hl5YPV4Ka3PpAKvqcOh/84gZvl0Jtfi4P35jJwEx2sIwvLWFi5BbR/NpsZvceQ8EstCUs2gGXhcmaRPXYjv0peyP+KBrJ8ygBSl24Dl5PiZ3ozM64vZUcZHjj9TbLchbyZPoSvigdT0tPgqLVI/hmmdhlPQlQ1W1ancc4F33BawmLW1KTz4Cdn8Z9tDmI3GCIu3krXduXUGCdxiQ5Kf+jA+9syiNri4toLP2Zo9CpWdc/gr11G86cjZtPFXUDn5EqejD+Fq4d8Qbq7hMkJY/lg6zF0TSpnfkk3snuVM7HLbJwYHv1lFHdsvITM2BKW5Heg7ldu3LXJ3J13MQXlMTjcUWyeaqhd3pHuz23Gzi8A48MYu8HnL1CP9Z8vpxPL7cKKiYG6OuzKyiYCqzYIkiwLh9cmcVkCcwcfSXJEOaUrOpJavgwr9O90f55bW6m/xENxSNnutktwCLdNIiIHm33QLrVJkJSSkoLT6Wz061x+fn6jX/EOacb4b8CMjbEdWPiwi0uguKR+fxO/oO+OQC+S00HRgET+2P+/HOHZRMcB23ml3zjiflmPZSyMb3ePt+MDZzksrEgPGy/uxjlXfE6Cq5In3xtL96kV2OUV4U9zu3DEx4FlYaq92N06sOXEeDxFhtRPcvEVbAd29yQOcIH30GGD7QCnhampIWLhajJ+sMDpxFj+/9oRFp2itpPsLKdfzGYWRw4Ey2L9Wal0GLOe7cUJmC2xVBs31cZNWV0k8edt5oHsj/Bhcd2sy2j/ZjwFfdrhBjp4iohzVBNp1RD3i4PM2dvxpsWwaUE6T8cNp7zWg2UZbjz3Pdo5K3kidwS28QckZb4oKvJj+KV7GqmuUpaUdSC9QxGDo38h0lHLwM4bWf1uDx6pHYW1IpaoI7fTwVUEQE2di3YPxbAtNZkht6zk/zI+55eaNB55/jwS1tqce9eXnBX/A8v6ZvL33PNJfWs7+Pzvt+WwMLZjx2c7pAfJivRQelpftpxZi1UYQc/nijGrcrHw7XiOZbVJUG17vWT+Zw3LVg/AOKH797n4qqr9O4PX0jbnJntH7ZKIiIRqkyApIiKCQYMGMXPmTH71q18Ft8+cOZPx48e3xSm1nQaBUqOgYU+GxAUPbYjd6GXm9r60S61k1vbexGyqrj/snt/EWZ4Iyo6q5tS4JQD856itWNFRUFnpH3qHP0CqGnEEGyfUYWxI/5+HvLE1XDPoY/K8CcyKPY6M54p3XHdLA8F9qaU3uYH3sClOp/+/tvHnkvT5iF1VzCs/HIf7aB8vLz+WrqvKqUuOZcBZP/H79rMpsyO5ruhS7vtiPBlZ2ymflU7GaRvIdJVQbZx07boVT486bu4wlx8qO/Pcz0P5Lr0L323MInOZl02jkxhywY/0dviY/f7RtP+6hqprKhkavQYnhh7ttvH/Zp/C/3r1I/+bDLrMq+NZexivZwwm+t14auItZqQPJMVdzuINHRl63lKuTJvL7N59eOWLE3ksaQyJEVUUbEnA6uYiosymW8w2Uh2VVLpKqIv214nHqsOJIcLyYbvBcjowttNfX4HPutVgcqXlwMpIxfnbfJ7u9gHbfbFMKruI7Ic2YGyD5Wijz0vIDxm+wiI8s4sB8DUz1FQOPmqXREQkVJsNt7vpppu47LLLGDx4MMcffzzPPPMM69ev5/e//31bnVLbCQmUmt2/J2yD+8c15D3Qi0lH9CZ5aS1RS1bs9Q2d8dbQbp6Hfx9xDMnuCgq/ziC+bOmOAg4LKy6W9Rf4uGfQ//AZB4/FjKR3YhHprhKiHTW8l2X8AURd3V6dy15rLrhpbntT70VTZZ1Oqk/sw6bhbqI3W3T4YBOmuBQiXJT3aIdjm5OXZ4wg+51ynFuLISWBbdWx1Bonxb4YohOr6J+xhaPiN/D/sk5h6/+y+Ms5Z1BaE8n6hR24dtyHdHEX4IyxeX3TUH6OSGVU9s/879yB9Om9ljszPsYGvj+uI+abdlSujefLXj1wWz5yS5MZMHAt6VGlFHjbs+5MB3eMeJ9KO4LnUk+n40fbmV98DN52FvG1hmFH/0xXdym+2BVMXzuM4nc68eNYD9ef/jGeE2p5ZNbpvP7Ryaw6MY2tVXHU9awkr7vFKyuP5du0Lvy8LZWMX2rB5QKfjWVZGEx9b9KO+gzOQ3I4iHJV47Rs3JYP2+XfhsMK/w2hDXpsAoFa4DwUHB1a1C6JiEhAmwVJF154IYWFhdx///1s2bKFfv36MWPGDDp37txWp9S2THAwZeNtLdHgl3nb6yVq1hI6fu7A1M/rML6QO80W9ZjU/4pf7SXjPz+zaNVR2G4HXRetwef1BnuRAv81dTvOpaIkkl+WdOG/I2rZWhlHhy/qMG0VIDUMahrUWcOb96DAMC/YZb1ZnTKpzini/q6zWFyZxUe1J5DxZikmPYktF9YwuPN6iqqjKV/YgXabCnFuL6X02Q5cM/5Saks8uBO8TGw/i2RHFQUnxvLDO0dROicDbOjmLOPp7JNw9/PxYX4/jMswtffbZLrKGDB8A+9tHUiJ7abauGgXWUXknWX8ut0GnvzpZCI/iafmtBL+3nMabmwYD7FOLyOjf6baOHly8MlUrojHvrCQoenr+fC7ATyybBSVfTy8s/lIUpbU4N5WiS8DTon5Cbdl878j+lN7Tzr5H2aTe66bR097jThHFVPXjmXxj1047fgfWXVbKmX/7E7cpz9hHA5//TU30nJLPttfO4Jbxp9P8bZYek0vB5/PH1xZjvDepH0dKIXM8Qto9rPR1PMalVNQdSBTuyQiIgFtmrhh4sSJTJw4sS1P4cCzpzdRzd2U2QaDgfpECTtepwXDlRoOJzM2dkkZ7rn+3iNfw3lTlgNTVk7X1w331Z4NBrq9VYdn1ToqPmxPQpkXs3a5P2DbkzlXeyr0GhpmUWu43RmyLZiRz7Hj3zsLlhwWvoQouiWsJ9rhpYOniOpUf3njcRMd7SUlooIEdxXfJWb5s9b5LCwb2sVXEpNSTH5pLBtqk6l1FfNlXjcSi6txFJUFr6PLlDhe7346UdtqSeztZNOIROIc1Syt6EDeK104v2eO/1S6VPDKsc/RzlGDt6uLb1YfQ+76eIoHRgLw3dYsigrjGDJ0Ddvq4nEtiyXvOMPzfaeR4ayk84mFvPWPUTxWMZJ2iRVY2W7SNxlilkYy8+i+xDmq2ViSQNmVDqhzEJVYQZyjikirlk6xRXQ8tpgrUr6kOtnN5af/lj5fuJvsPdzRi+Sfy5X6znKsz+LJrCnGVFT653MFgqvQoXqt+dnZm8megXNpIpNjs6+hgOmApHZJRESgjYMk2Tca3fiHCPsVfE/m3YTOnWqiJ8By1JeprSNi/k/0+THK//SqamxjcGwvwth2MLvdftOgR6BRevKQOgvNjGKCN+X1gVPw+gkPlkLZBteGAr77tA/2SIufCtLp8EUVpq4O58ZtOD7pymcjelJdHEmvhaX+uo1ws/Xsal7q8yZuy8f9687igVcupC7GkPG1D6uioD7Vuv99cBaU0K6oDBMdiaOHhweXn4rP54Af47E7G9ylFh1nV7DxlFg2H52I7Szhm8IuuEtr6P5mHb+xf48dX0fWew5SN1Uyde4EfBEWCUU2hQMt8ura0c7hZUVFBuWdYPJx0+nv2czLWcczp+I4qpMN724cSN7SNFL6FvDC8KeoNU7+lHs2tyw9n3bRVeTP6kDdkeVcmPINZXYUEXnu8M9cfa9Q6L+D9V5bB0Ul/vfFYWG56zOIVVVj7Lrw3r696U1qdrjlHixE1+CzFfychAr7MWH3eiVFRERk/1OQdChocq0eh/+X96YCkj0NThrOnbJCgoX6fwduXC2Hjamp8d/shgZtIT1HLV6raU81FSA1kZ7cinBjRUZiamsxNbX+zS6XP/10TU0wYArc2IcFSw3Yhdvp9qSPonfak1laiSnYjAHs8grav7kCPksCXzlWeSW12RlUZEXhdlfgxGAbB2u3J9HlnQIquySw7lyD47wEOrztJm7hZkx0JDgdWFVe1o1P4frL3iUropAbF1wIfSt4YcgL+HBwRebV9PxXCXf5fk11uk3Hz2w8Rduw0+KxY3x06LCd4m4ZxC6vJG1eDSsmJnLGifPYVhPLrd+ci13mJv0rB65OFl3cBSQ5fBwRtZH3zy7nn0e+STtnJZOjxgEQafmIturYUhJPxykOqHPSuTwP7zeJXH3Bb4hJqyDlx/qgclc9No76z667/uspOZG1F6RT1bGOzM8s4j9ajqn2sleZEXcx5HLH5gbBdMN1vhqWDfztQePeW5oInNowU5+IiIg0T0HSocZy4IiPpWRkT4q7O0j7vpbIr1b4bypbo+cmdO5Uw+OF/DtwMxg6yX3HvlZaSHZ35n00swiu5XTu2Odw4IiJZuOF3agYUol7RTTZ07ZS2T2JDaOdeLY76PKfbbBuE1g2xlgQmMBP0z0Gxhh/OvfSMuzAULGA2jrYug0sB5XHdsNz2xYuSl/K/7b249ffXEmd10XW206o2cb6C2xePflZYqxark29mO0vd6T03DISY6qoeK8D9tFlnBS9mmjLx3m9f6C4Lpoe7ipqjCEtuxBHhaHLmxX4EmKw6utl07AY/jr8VTJcxbycdAK/fNsLnBanD1nE2PgfqbA9fLptIH3+tZ3yHu3wRVk8uOF0usQW8u63R2PF1NHOWUmk5SPZU8HX7w4gZ9QFeOtcRH0QT3VKHTggerUXV2UtGZ2Lua37x7yVfgxbt3clYv5PO3lLLSyXi8oTe7HlBBcxG6EmweKuy/xrRj3a51RqlmdiVq9tWRr7Zl+wmWGXIfsttwtHarI/ffu2Qky1t35eVIOkE5YDR1I7ikd0pbqdRfrcIli91v8jRaBcYBhn6BBOBUoiIiIHHAVJh4qQtZHKT+rOqLu+5OTYFTx7xjCKc7rA4p/Dg5W9vSHb2fNDAqhdDjlq7QCpqX2hvQQOC0enDmwZnYHDZ0iftRUKiqg4qhMjfv0tp8QvZ3G/TrxVfgqeUwqY0vNjtta24ynOoMsTBZiaGiyfD/8yQ86wYKnRa4Wdkn/YGLbxL06J/9/5R0cwJWs2XdzbqU1zMv0fo4n/bhPU1kKkB1dkLW7Lhw+LxMgqVo2v5PWjXyDS8nFH1K/4aX42SwZkEues4o0fjiVyXQSdzyukzBdJ1SdpWFXr2XZKFkmXr8ft9LH5lWywINJRg9vyUWO7sGyDs7iaLzZ1ZUTCT2yoTSJ+tYOy3okMumshpyf8yDNbhvHt1MF0qrAp6B/Jnzr8ik6xRcxe2ZOISCj8d0dSvy+nZKjF6Ou/IcFVxQvTR5G81PCbLp8yMCKPuIwvuPmIPrSfX18pDeaxBXocTXYHYm/dyLOdPmRJdRavrz+GDFcx7RzV9IjLZ0lUavjzWryOWDM9rwSGV5rgZ8VyOqk++Qi2/76cpJhKyqb1I3XaYozX2yiwsiLc5P2qG2de8wUprnIeO24Ufe5OxretYMfHz+PBiovFVFZhV1YeEGs/iYiISGMKkg41DgelWS4GReeS6qxgeOJKXu/QnejF+/Ecdtbb1LDMnmgi41jj4zdepBTLgSMullW/Tec3Yz+j0o7g7fYn0fX/VWJcFraxqDUuPI5afJGQHF1BhOUj1VWKN9nGio7CahcPVdXYZf6Ma6HBUlBgGJ/DAZaFIy2FTWd2oDYWOs4qx7l8rb+cbUjItVlc1YlIq5YPtx5BzPpyTFWVf3+FTdq/23NDwkXERtRQ8E4WNf38SQ9qjYO88ji6vVnK33+4kLooiz7fFmKVVfLprBPBGDquXYeJjKD2nCL+2e1N3Bb85qIJeJ7J5I+LziMhporqD9LJ3LQB6nyk/zWNSUMvJSrfkPnFZtadn8mEpPl0cdUwLnUxD/btid2rkqT4SlZtSGfb11kM/fVPXHPSLGaX9+X9v46gakgFZ7dbSKTl48uTu1M5twP/+uUE0nqW8m7hUSQv8/oTVTSXOtvhoK5dJCck5ZLsqOIIzyY2rz+N68omcETGFpZ/3JMuuSuw9zTpR8PPjuXAmZxI0fBsqhMt0r8qgp/X+gNZy8LyeFh3poPn+7+Jw7K54/xzcHyahG/z1kavbbldlPQ0HBW9DrdVx+Duaylrl4pVUOjfHxfHxst6YJ1cRMXqTvR8eiv2uk0cMgsqi4iIHEIUJB1ijM9H2veVPJo7hnM7/MA/lgyj+9JtBG/n9meyhH06z6jB/KLga9YPgwrZFjbMLioKq3MFHSMKsXHg7VoNkR5iVhfx4azBrB2SzNKlnek5s4zN3s788/QRbC2LpcPnNhsu7IJreCFFm9Lp9UwF1s9r/UPYbLs+WKp/vZDhdZbbxdqLOnD9Ze8S6ajl/l7j6HN3PKbMn6ku8fNcPvQN480up5C+oBrHunX+uSwA2MTP+hnr+3iMy0370tWkf5vO+fb1EFNH1n+cONavpt3azTsu39i4l1b4s8TVn0tpaTt8WNTYFgkRVVRfmc+olA188PXR9J65FWpqwRjcqzbTabWFMQYDpCyp5eWCExmXuIinfzmZ2jib1PhK2kVWEdOxhs1pHRmTtJSurkrccUt4rcspOH6OYe2gFGIcXpat6kjfn7YQcU8CU/peRvzaaiIW/ezvrWn4XgYCS8siYm0Bz88eTvUwN5/n9aDrNJvI3FIqIxPpkr8CU1HZKp9jy2FhuV3kje/KuIlf0N5dzNTjT6XPbe2wi4qDnyd3sYNiO5poy0tBSSztvFtDPm5mR6KGmlrSv4HXjx1Cn7g8Fn7bg15b12AAHA5q+3Zk+IQFjE/8nlU9M3hmzVmkv7BlRzKQA2lBZRERkcOcgqRDRf2NloUP56JVRN2UxfTM0fRYXYC9Oc/f63EoLHwZMpTOclhYERFYHdtT0yEBz9pCfJvz6ocwmWCZ0JtwU15B3Kws3so4hli3l3bzPWDb1KbFYmVVAhC10YmzoJSsd0rxzYqnY3UNtWm1DDjvZ36V8j3ruqbw3PrT6Lw2wr/Wk2UF5/uEnafDAe4IqtNtUl1luK06OrXfjomNgvIKwL8wb8KnP5MA/pvkkPcoEEyYgu3BbY6V6+h9r9t/TV5v44ADADs4vM9UVpL9nMWpVTlYETZYhidPeI1UZxmxJ3j59u3BeH4qDw4zMyGTfGK+X8+KPx7Bdx2PIuHnCuoGWvh6Ooh21bC2OImUJT4e/mkMEX0/5L8FR9H+6xoic7fz0KYJ+CItes/ajikrwyouIWmFDaZ+ja7meoDqEzaY8gp6/2Mr37wzmLjCKqwtuZjaOv9zfb6wOT47+3yEaZjGPlDc5aK0G/SJ3Eycs4q+nbZgt4vDbC/yf15qaun6RhG31/2auhib7P9W++eamR1zkgKBkqmto93HP1G8vgtfxmXTe9l67PpguP5tocoXga8+orYUE4mIiBywFCQdYoxtwOuFlb8QscoKrmG03zLJ7Q+BuSROJ6ZvV7beU8uZnb/h1SXH0us+J/bajeHzhEKYujoyPt7IL6nZdBq5jqKBPlIWJ7NhZCS3HPkuMQ4vb5xqUTMnDfeGQpz5Rf4nJsew3RtNrXHisGzsCCA1CWt7MdTU1gcZIa/pcPgDFdtH5heGx3qPole7fNbnJZFwSiTp8yJwrN0cfE+syEjs9CSsSi9sLQhf8Dd48v4enkbrDAVeN5AcwrIwDkf9TbiPiCVr6XtfJES42Ty2A5uOTcRt1fF9URbOqjqq+3akvEMEiT+V4Vi7pX4tIoPx1RDxYy5JS/zBRfq2ODZ7OrA0M5mMb3zEfbOW2DWJPNn1fKLyqolcsw5sH+n/LvJ/5nw2xrbrj2fvmO/TxFykQC+SIzWZDWdn4k02dPysBmvTVn8gatv+AMk08Xmuv+adf2aa3m/q6kj/1mbakGPpFruNVV91oVv+Sv8+n+3v1VuVS/ZfN2FZFrbXG1yUOew49XPT7PIKHN+vIIL69cNC3jv3io0seHUgX57UFXt1LD0+3oQvEPCpF0lEROSAoiDpUBCamjuYhtuHZQLrtezHBVv3I8vpJH9QHNf2mE6WuxD3AB+f9D2Z6PWb/NM8QtZACh0CZyfE0HV0LjdmfcK2DvHcW3gRrnKLSjuCeId/PtCGUTHUJEaR9i0kzd1IxMbt5L3RmT8NT8Ny2CQcU0DeIBfM6EnG26uxjI0VFeWfT1RT609fnZIEQPw3G/CtS+TrUwdw/vnzSD+6lKdPOpHu9yRBfgF4PKy7oht9x60ktziZ+Md6NJ0Bzg5/D8N6kerXUAqmNDcG43T6AyXL+BMN+Hy0/3Qrf484h4osm/ZfGRyphs53rmBc8o88uno0ibenYq3fEv669fGaKSqm/Rtl4HCC7Q8UrE35xG7K39EDVt8bFgwaAwFSSDAQOkQt7P10u1h/TiY5V71DnLOKP/c9g043xWPyC3YESLszF6mpuWpNlDe2gdo64j/5iep1nfkxpgPdV/yCr7wiPJjzgamqwoQ+r8nj1fcQNTXHyHZgl5aS+dJSrP9EY6rW46uqDuuREhERkQOHgqRDRcM1jKBxeuSDvQepIdsmdpOPuSU9ODXRy7zCrkRv8g+Za/JG2aofcudwBJM0ABgLUn+s5e8dTsVE+XCUuvjNebPoE7mZ1wYNoTQvC/f2KlIXlJI2z8eq37Tj5nHvE+3w8s+YU6hd1pFNJ0cRdVwBZUuz6P5yAYWDU/BcmkeNz4nr+SwS5m+gLiaebM822jkr+V2/r3i/z0hi87ZBSiLdx67hzg4zyMuI5w+jrqTbt05/hrsQjYKi4I7699x2YDD+oLC+fozD4Q/g4uIpG5CO02vo+O4m/2fBGDacl8U9aV+R6Szjd9lzebHbeOLWb/Ef3+mEjBSMx421aRumuhrLNkDI2lcNP2PNBUgNgoFGgZLTCe4IqtrbdHAXEWnV0id1K+XRKf6Ar6leqEbvbxPz1BpmWWy4bpHPh11ZCYtW4gpczk7WQmrqGA23m9AAPXhq/lT4dmUl1AdHwdc4VHp4RUREDiEKkg4loVnlmtt3KAhMdPfZxMxfzZoH+zC5W39Sf/AS8dOK4C/+gRv50F4kAGtLAfmvd+PWM86hLD+W+G0W1o359Ivwsu6/XcGGFFcZcY4qesRtY/qY7nQYXIjPdlD5VgaucotSO4pIRy1VdW4q+kRy4QWfMyJuOd9ld+WlrafhPKWQ+7rNoML2cMupF5LwtUXKIpv3hg7k6MQNfJ7Xg5gt/l4rq7KaVQUp5HWIZ01NGtFbrGCvUaM5Rw3XmGpQL8Gbc9vekTwiMpJV/5fJxLM+pKguhveeHUbmjM3UdEjEUQOflPbn5LgVvL7pWGLWlgdfo+LEHrhuyKNr3GbmTx9Ip2fr19tyOMAX0msVqOvMdKo6JRC1rhizfnPY/KOd9ZZYloUVG4P3iCyitjj42/pRpEWVsWxGLzrnrdjRg7az44QGSE4njuhocLkwFRWY2rod6xoFUoY3WgB5Jxnmmqzr5s4j0JO3s0VkfaE7dn48ERERaRMKkg5FB8IN187miOzN+YVOwDc2dmk5MR8tJsbh8M8VAf+NeX2A5IiKxO6ehS/KTcTqLcHhcGnvrcH6NAoTUcuKu+J5OHsGAHePiiPm7wlM/fY0Bndfy3c/diexXyG3ZX+ED4ubxlxApyed/NU9ntrUOjI/cRDtsyn3ebDrJ+Q7aqGoII5iXzQ1xolru8ufoOGLXGrzOvBFckfiVpdibVzvP+eC7WQ+lkjOmCuJyrfo8O567Po5ScFAxO32DznD13z67Ibqn2viYug79BdOiv6ZWuPkzZOPZn1kJseet5iOWPxn9nF8snooqYsqcKxd709/7Ylg/Vj4T7f/EGn58I53UvBOKmzK2xHABYIwy8J06UDefTa/7fERz685nrS7O8GKXxq8d02vJ2VFRLD5wu4Mv/xb3JaP9z48DvNuOZ3XrcZUVYevXbSz4wQCpM4dWXVlGlbnSuI/jSbt38uwq6obB0oNj7U7dvXZbWr/zlLh784xRUREZL9TkCStLzRAapCeO2z/XgVL9b1J+Jq/aXa5KD6tD52u/5mUiApmvTeILs+s9ic+8Pkw5ZVYkR6sghS2+eJxW3UUFMSRujKfPvc7KItLo3d1ISv+kExejwTclo/agigifl5H9+X1vQF1dVjxsXw07Xg+PK4vdUsS6PpJHilLE7gn7yKc1RY93i6E2hqwDa5Fq3HVB0DBq6+txfXdCrJ/cIIx2KFzj5xOTJ9sNoyOx10OHT7YhG9zHjSVMro+CYIjLhY7OxPLW4u1KR+rspoff+rM2vYpFPpiMaticZ9cyJVpX+DDwVeZ2WQ+WogpL/cPjbNtqKvDU+Ck0BdDhquMnwozSPNWNerZcsREQ3QUW49N4L6+L3FERD5d+mzj/oG/IWmlhcHscs6NFRNN7bASzkn8jlrjZEbvvrieLMIur9ixplIz77H/nyGLw7pcbDojnYfPfZlMVxFPZZ/Clu87wfLV/jrbnTWJWjtoURAkIiJy0FGQJK2niUVeQ+edNJoXYll7dgPZYP5VcMK8Y0fyCgAr0sOW4Ta3p31NhOVj6Unt4a14KNiO5XJR1ymNutgIOn9Ux33VF2Bc0H16JaakDIyNtd2f2a7ni1E8lH8exmno9Xax/+Y99HS2F5P1bDnWKx5MbR4YQ0R5Jd23xWNVeTHFpTsSF4QGQCH/b4xplLXOsiwcie1YeaObR499kTI7ivvTLqDbI8XYFVXhPSL1HNHRrL+qF8PPW0hBTQzr/9GTdh+vpM/jRUz+/hJc1dBtQQErfp/Mpt6J/nlZuTGY6k07AiRj/KmvX9vGDXW/ozbBpsv7NdgFm8KG8dG5A8uvi6dDlwK2r69jYWU2Ga4SZpX0JW6DN/y93klPiqmqom55PMv7dACg7qd4TOXmRum+mwq2QgOkQO+hzwORVi0R2MS5qtni8mcaNNSn6m6QdU5ERESkIQVJ0jpCAqSwG1cImRtkQnqAqA9o9jJQChwHf7AUlgzA5yNmrYuV3vakuspYtz6FvpV5GMuiaHg2adfkkhldwmefHEWP/7fRv0hpIJ03BIMYa/kvZK90+l8jEMiEBguAsW1MXZ1/eFxUJJvO60rMGXlsyUuhx9MJOJblhiUz2HEZgdcKqYPA4qTGYGKiOLbLWjq5ioAi7G5V4I4AR7X/OaG9Kg4LUhLpMjaX/0udg20szhnbhXafuWDTVtKnbQ2W6/3/LB7IvQRHHXT/OA9TVR12bsYYWLeRLo9txXI6MDW1wbTklm2D08nG05J4fOSLdHIV8VracUx/aRj/jhpG6uI6Yn5Yib2L9zWYNruqmu7PbeKFn88CoPsXG7GrqsIy4oVq9PkKO6gh68Mirul8Oemdt1M1M40Oa1f5hwYas1sdSSIiIiIKkqT1hN60Wg4sp39xUCwLKzoaAFNWBnV1rZP2uGGiisCCug4bcGJqaun81mbeKB6DN8mi69de//A4j4e8Ew03t5+P26pj7dAkzOtxmO3FOwIZCAtm7IZrEwE0nDdUn3bbZKbS9fxVXJX5JZs6J/FY7tlkL7fC1lJqNlNd6L8dFhRs57s5vfly/C9srEkk4bMo8Hrrs/Q1fq5VWc3q/BSqOzsp9kXj3uiB2pod6y4FXnftRjKf21Kffc7XZLBmMODz0vCdMg6wjMFZA9XGDUBBTSzt55bhXLURU1eHqa3d5dypYLBjG3xbtpL073x/tTbIZBf6WQlkrrMiPViRkZjqakxNTfiBV6+n7/1xEOmB6l8wgWDcf4D6uUlNDFcUERERqacgSfZeyBykwOR5KyICK8J/A+0dmE3u5QaHy9DxNRdRs5bUB0qOvetNCmgqq59twLKxtxWS9l0sK/8viu1DKmBmVzLfXU/MWic/VWfS3l3MinXt6VO8xX9jHzzkToKYhpnLAgui2v5rsWp9bK2MC6YYd1bv7nXsyLgWeF1TUUmPf+QyfdYonF6btBWr/efmcPiDNIe14/xsg11UTOe/JvPrcX/AXW7RbXoepqa26eDM1yAJRKOMbE2sZ2Q5/OeFj8wZW7g7YwKmSxXtPo0idc1Kf8ASGCbX1KKvzbENJhCOmZ0MsbMcOJLaseGy7tQcU45zcSzZz/+Cb3tR/eK9/h4+u7wC0hL55beZ1MYast+rJWL+Txi7QbC7t589EREROSQpSJLWZTlwxMWy9ZyebD/aR/xKF2VHVfPsCS/hMw6utS6hx4I4THEJ+2Tsk/H3IoE/IHBYFutHx3PDCTOIdtTwQbsBVP3Ynk7vbObdwhHUxFv0+mw7duH2nWdRC3uJBr03Dtu/RlF9D4u1aSs834sbx1yEe5ub7u9t2xGcOBxg21hOJ454f2+H2V6M7fXuqI6QeVXG+AMfz/wyLKfTH4xFuDE1/mFvDRMpmNo6HItXkb3MXwfBHrBdBXrN7A9PXV1/reBfHHXDZrpP3Q4eD6ayClNbG7aeUbMBUsNhcs0t9NroaRY4LKqO6sz4S79kVNwyFhyRzbsrRhH3v2J/Vr76QMmKcPPLOYncde5btHNWckv6efRYHo+vYHvj8xERERFpQEGStJrgTezgrgy/+htGJyxj9pA+fFvQmVrjwoHdaOhWq6qfpxSY64LtTwvuqoSiuhjiIvxdOo5aH/a2QlL+U+jveQhNELCLXg/TMIiqX2cn8HpYNqamhoRZq4jZlEV5lgs7OgKH04EJDOVzu6kd1IPcqwx9svLY8H4/Oj63zJ+MITTRQaDXpj74wu2iZGRPtgwzxKx10un1tfgKCsPPB/yLmTZzPU0Oc9xpeuqm1/sJLo5aUQUVVY2es6sAKWxeUXPnuJOMdpGOWtxWHW7LhwlNoFgfNFpAXYxNvLOaGIeXmKgacLnqe94aDLlTb5KIiIg0oCBJWkfg5teyqI11kBW5nWiHl44RRbw/ZyjXbLgMh9tHp9dc/nlJ+3I+SMjcJFNXR4dPtvFG+5Oxs6pJ/jiS5BXLg0PrzE7mv/gvq5n1nhpkbTO2A8vpP55lDHZ2JmV3lzGh02xezh1C8n2dsJb71w1yJMSz5nKLN0/8f2Q5vfzt0hP58fMjYOnq+ozmplGmPmwbOzuT7JwVPJP5IStq0plceglpL233D1MLHaLma6aHbm/W6mkiSYafr0EGw50sdhs4VGBekdsFTifU1oYPz2vq+YEeQtsQvWg9b7xxCm8eezR1P7aj67y1+HwNAq1qL9nve7kp7QJi46uIeTMBu3hTMMtdyMlobpKIiIg0oiBJWkcga53PJuH7rTzx0Wl0P2oDq3/Ioue0LVgVVWAMdll5oyFirXseIVnvwD9P55f1dH+oAFwuTEVFcKHWnQVHEDK/ylk/fM/nw8LXfNIJU9+b5ITtfWO5svMsenu2cHEXJ2/1GkPicvznVleHVe7CiaHawJbqeKxaX1gvW1igVD+Uzxfjpn/cJqItH6muUqqTrPq5SU1cQ3M3/nta96HPa7A4aqPU7g2f0zDzoeXA0S6BgjO6U9IDMub7iJ69zD93aidDMAM9dr7CIjr/cynWi9GYio34vN5GvU7GZ+NasJI+P8dDhBtTtMnfkxfIchfsTQo5R/UmiYiISD0FSdK6jI1v0xZ6PlgB7eLoWbzGn4EsNP11a2S2243zCCw2axkLu6xs10O7Gq7v5HTizMxg8+kdqIuCjjOLMCt+qQ+UmumBMP61huLXefkgfwBx7av5orAH8bk7sjfYFZX0eq6M8x3XE5leQdx7cSSvXdYo+AoLlHASsXIzz340ivxT4vhkfW+yZpb6e4121gMDrX/z3zAQbS44aobldFAyvCtjb/yC42NW8/SJw6ne1AWWr8Yy1k6D0EAPoV1ZCVXVIdvrh9mF9MCZ2jrsouJghkVHajK1HZJwbS3BbNyCqVMPkoiIiDRNQZLsvaZ6b0pLscrKwtYSoolEA8HApTV/yW9msdkmeylCb/AbZJdzeDzkXtKB31z0MW7Lx+M9R9PnTwnYhU1M/g/ewFsYn03E96upur8Hj/S8gOSfqnEtXr3jvGwb1myg970R4HRiKteFZdZr6pj4fPiKiun50M8sf6UnnUrKsLcVNt97tK97Rfby+JWpTo6I2kiSs5zBieuYldyRiOCxm7iOBmne/ULez5D5YVA/Z8py+Ic/2jaOzAyW357KOYMW8tHaPnT6c1dYtlrpwEVERKRJTazGuHNffPEFZ555JpmZmViWxX//+9+w/cYYJk2aRGZmJlFRUQwfPpxly5aFlfF6vVx//fWkpKQQExPDWWedxcaNG/fqQuQAEDph3zY71gUKPBponF7aCg+29upczI4bbGPv5LGTm32Ph6qOdaS7SshwlZCRtR0rOqrpRUzrXydw/XZVNe6vlpL+4g+4vvkJQoIgKyICy+XyL1xbXuGfk2MaZIQLmfNkbOPf7vNhl5RhVubi27wVU1sX3osUeu0HMGMMGfOKuXvh2Ty37WSenz2cqJ+2+D8zYUFfg//f2fsZuh0afQ4reqVy1XFfcnq7H7mp72dsOyYhuGivHPzULomISGtrcZBUUVHBwIEDeeKJJ5rcP3XqVB599FGeeOIJFixYQEZGBqNHj6asrCxYJicnh+nTpzNt2jTmzp1LeXk548aNw9fchHM58DW8MQ+5QbWiojB9ukGvbBweT1ixwByV8IVoW/HmNXhj3cQNd+i+JgILU1lJxhcOXt88hBnb+1MxMx1TWLTz4WUNA0VfSO+ZZVF3dE9W/qkXK+/pReVJvYLznXY6BLHhjX/9ELuDpvcjJLgJBHus+IUedxSxbmI3+kzJ9Wfp253raea9ahREER4oRW6rYs62HhT6YvmhvDOxm0LWSzpY6lGapXZJRERam2X2Yha9ZVlMnz6ds88+G/D/WpeZmUlOTg633XYb4P91Lj09nYceeoirr76akpISUlNTeeWVV7jwwgsB2Lx5M1lZWcyYMYNTTz11l69bWlpKQkICwxmPy3Lv6elLa2tqgn5UJJuv6MeAi5dSWRfBhmd7kPSfH4O9IAFtNmysodBrcDpxREVisjtgXA4cv2z2L1LaMMV1w6QGgeeHprl2WDjTUlkxOZUHjv0vWe5C/pU/jLyrO2JW/NJ4baFmjun/TxPZ5Pz/CPn/A6w3qalrCA2Md1anrfBaVlQU3iE9yR/sod0qH/GfrcCuqArO6drr1zzM1JlaPuddSkpKiI+Pb+vTCdNW7RKobRIRaSv7ol1qcU/SzuTm5pKXl8eYMWOC2zweD8OGDWPevHkALFy4kNra2rAymZmZ9OvXL1imIa/XS2lpadhDDkBN3GRaiQm4xxRwdfrn/F/7ORSeWu0fsgY7epDqAxLL6Wx889yavUotZHw+7MpKzPI1sHjVrgOk0H83kUzBeNzEx1cR6aglzlHDkPhfqEmODi+/s8QLIT0koYkKwoKxQN215tDF1tBgqJyxDcbn2/Fo7tr39rWor6+qKiK+XErW3xcR+8Gi8DWp5JC2r9olUNskInIoa9UgKS8vD4D09PSw7enp6cF9eXl5REREkJiY2GyZhqZMmUJCQkLwkZWV1ZqnLa0tJEAw1V4KNrYjr64d232xWBsioabWPx+k/mFFenBmZeLMTPfP12kYKO3Xc2/iBru5m/nmeh6aCpRsAwXbcb6fyN9yR/HK9uN56MvTiVy1tfnn70TD+VyNgqXgjlYIlAIBV+hjTzQYltjkvKKG5fZUU0PvfD5MTU394sG7+V7KQW9ftUugtklE5FC2T7LbWQ1uoowxjbY1tLMyd9xxBzfddFPw36WlpWqMDgbGxpSU0vupMu5deylWHfR4N89/o1rP4fGQf/4RRF+QR0lVLMlPphMxZ8nO02zv8/MOz47XbJndOUbw3/5kDmn/WQ5zklkS25e+2zZjFxTueUr0wIKsllV/42/7s7oBYWsXBTPD7cHrNPd3u6fZCBtmq2tqX2tpmOXQR9OLxypAOiy0drsEaptERA5lrRokZWRkAP5f5dq3bx/cnp+fH/wVLyMjg5qaGoqKisJ+tcvPz2fo0KFNHtfj8eBpMOFfDlAhN6bGdkBdHaz4hazV/o+aXVe3IyhwWFhJ7XCdu417u79Hhe0h56zL6P21x78OTlvaFzfztsGuqsbasBnL4cBuKiX6bghdkLVwbHdKukPG/DqivljuX5DV2P5U5LYJDwpaEtg0dd1WSODV0uM1tL8Ck52t6bQ/zmFnN+EKzvaLfdUugdomEZFDWauOacrOziYjI4OZM2cGt9XU1DBnzpxgQzNo0CDcbndYmS1btrB06dKdNkZycAoOc/J6MV7vjsxs4A+W6nwUFMdSbdyU2VG4ix17FDjsM6GZ8JrLrLbT5zeRoht2pERvmLBhN1lOB8Uju3HerZ/w8mV/p9M9KzF9srGcDnA6g4kj9nqOV+icsdY4Xlto6j3c3wFSyPy7JvfLPqN2SURE9kSLe5LKy8tZvXp18N+5ubksWrSIpKQkOnXqRE5ODpMnT6ZHjx706NGDyZMnEx0dzYQJEwBISEjgqquu4uabbyY5OZmkpCRuueUW+vfvz6hRo1rvyqTtNLuY6w6BxT7t7UVkP5XGDZt+g7vCotub27ADw/EOtYn1DgtHVCRWXBymshJTsRe9ZQ4HFe0dDIleQ4bTy7B2P/NKWi+iHA4s28Y4ADuQ3a2FC6Y2zA7ndPqDL7fbv96Tz/YPXTvU3p/WtNOMhHvYuyfNUrskIiKtrcVB0nfffceIESOC/w6Mx7788st58cUXufXWW6mqqmLixIkUFRUxZMgQPvnkE+Li4oLPeeyxx3C5XFxwwQVUVVUxcuRIXnzxRZyBNWPk4Bc6XK3hzbTlwNjGf/NeU4NrwU/0/DHCn02urs7f87Sn83QOVJYDZ2oKa/6vE4nH5JO3vj09XqrBsXAF+HaU2d3Aw/h8ZHxVxq2jz+OCTt/zj29Poe9P+dgADgcOlwsrPg5TVYVdXhE+x6sFN+aWw8KREEf+r3pS1MeQuhCS3l/uz/q3B8c7LDSRCt9yu/w9fLW1UFfXdvPtDlFql0REpLXt1TpJbUVrURykGg4vauJXduDQyToWerPsdlE5diAj7vuKY2PWEGPVcOXcK+idk7t7qcUbHs/pxHK7sDpkUJcWj3t9AXbhdv/u6Cg2T+hFxKnbKFibRO+/F2L/st7/GoGFMXdWrw3Ou3zckWTkrKFz9HYWFHQm6t44rIUrtMZQcxqutRUfS8G4XhQdAakLDe0+DASZB+/n/EBeJ6ktqW0SEWkb+6Jd2ifZ7USa1DAZQlPD8Q7kBVH3kqPWUFgTiy/agcNh44rw+dOgh7J2s3fG2JjaOszajTjWW9iBOnU4sLMzGTRhMVenz2ZDj2TuW3MpHZ7ciKmzd//4IYvhVrdz0DG6mFiXl17t8lndLgVNVd81qz7FfeVx3Tju+u8YFr+Sd086ki2bu+H8ehk7uhBFRETkQKMgSfa/0Jvz5lJtH2IBErYh5vsNfPnqID4a0gdflYus9xyYqmr/fstRP3doZ4FRg8yB+Pw34rYDg/FnC7RtsG1s/AGODwurqUPuMoW5/zUsy5A2r4B3hx7JUT3WseiHbvRZugGfhortFsuyqE500jVqG3GOKnrG5LM2qTfRbX1iIiIislMKkqRtHWrBUKiQ9NPG58NXsJ3M5yuwXosAYzDVXv+QNYcVvlbSznp7QpNihMztAoLBkmPNRpY8248rxnam9pc4enyQh73Hqbpt7F/W0/fuRKpSk+m9ZQ2+4pI9O9Zhxp/Z0Sbp2208PmcMR/XLZdGPXenz/Sb1IYmIiBzgFCSJ7GuBgMbng6oqTLXXvz10qF19j1DYYrC7EyixY7hi4Ll2RRWp0xZjfRAD3g3YVdU7kmG0pAco0JuED9+2AijYjglNaX6QzqfZr4yNWb+Jvn+uoLpdO3oX/IKvtPSQHlYqIiJyKFCQJLIvNdfzYzn8vUcOC8uysKKiwOXCVFRgauvC03Y3FyhBs8GS8Xr9C8v6d7QsSUDoAqzBoX0QNodGw+2a18SCyr6C7VCwvX5/y9fFEhERkf1LQZLIvrazQAmwOnfk59+lENm1jKj340l9e5m/92d3BmXtJFjaq6CmQaC0y9eXcE3MHwvfr144ERGRA5mCJJH9oUGgFGA5naw/O403z/0bnV21PJp9PN8v6g9LV+3Z8aH1gpqG2Qj35BiHswbDIpvcLyIiIgckx66LiEirMDvm8gSHW1kWdgREW3U4sYh1ejHupteP2q3jB1+jieF5e5y8wYQ/ZPc1VWeqRxERkQOeepJE2pCpq6Pzu9sZ1/NaBnbeyMoPe9B5zQps2Hk68J0etJlASdqO3gsREZGDioIkkf2pwVwVCx/8vJZeN8XhjfTQuWSpfz6SJvWLiIiItBkFSSJtyNgG6uowoWsPtTQbnYiIiIi0KgVJIvtbE9nulP1MRERE5MChIEmkLewq81mgjIiIiIjsdwqSRNpKcym2Q/c11FTZXT1HRERERFpEQZJIW9vd4CY0QApZaynYG2VZCpREREREWoGCJJGDQSBAshqvoWRsR+B/FCiJiIiItAIFSSIHugZD7CyH5Q+W6gMlyzLBlOIKlERERET2noIkkYOF5cByWFgREVjZWRQNSCRufTXOxWsw1V4aZcgTERERkT3i2HUREWlzgTlIlgOrY3s2PeDg7j+/SN+/L6XqxN7gsHb0MImIiIjIXtEdlchBwnJY4LCo6prEH3rNZmBEARclfsO2I91YTmdbn56IiIjIIUNBksjBwNj+RWdtQ9S6Yl7ZcBzr6qJ5r+QoUhfXYnwaaiciIiLSWjQnSeRgYmxM7gYib+vGDUdfS8LaGqIXrMS2jT+I2tnitCIiIiKyWxQkiRzojKnPWFefwa6uDpavJvUnC2MMdsPgSJntRERERPZKi4bbTZkyhWOOOYa4uDjS0tI4++yzWblyZVgZYwyTJk0iMzOTqKgohg8fzrJly8LKeL1err/+elJSUoiJieGss85i48aNe381IocBYxvw+TB1df7/+nzqRZLDmtomERFpbS0KkubMmcO1117L119/zcyZM6mrq2PMmDFUVFQEy0ydOpVHH32UJ554ggULFpCRkcHo0aMpKysLlsnJyWH69OlMmzaNuXPnUl5ezrhx4/BpXoVI0wK9Q8b29yjVB0bB4CgQIKkXSQ5DaptERKS1Wcbs+V3Vtm3bSEtLY86cOZx88skYY8jMzCQnJ4fbbrsN8P8yl56ezkMPPcTVV19NSUkJqampvPLKK1x44YUAbN68maysLGbMmMGpp566y9ctLS0lISGB4YzHZbn39PRFDj4NFpYNowBJ9pM6U8vnvEtJSQnx8fFtfTqNqG0SETm87It2aa+y25WUlACQlJQEQG5uLnl5eYwZMyZYxuPxMGzYMObNmwfAwoULqa2tDSuTmZlJv379gmUa8nq9lJaWhj1EDkvGNP8QEUBtk4iI7L09DpKMMdx0002ceOKJ9OvXD4C8vDwA0tPTw8qmp6cH9+Xl5REREUFiYmKzZRqaMmUKCQkJwUdWVtaenraIiBzC1DaJiEhr2OMg6brrrmPx4sW88cYbjfZZDYYEGWMabWtoZ2XuuOMOSkpKgo8NGzbs6WmLiMghTG2TiIi0hj0Kkq6//nree+89Zs+eTceOHYPbMzIyABr96pafnx/8BS8jI4OamhqKioqaLdOQx+MhPj4+7CEiIhJKbZOIiLSWFgVJxhiuu+463nnnHWbNmkV2dnbY/uzsbDIyMpg5c2ZwW01NDXPmzGHo0KEADBo0CLfbHVZmy5YtLF26NFhGRERkd6ltEhGR1taixWSvvfZaXn/9dd59913i4uKCv8olJCQQFRWFZVnk5OQwefJkevToQY8ePZg8eTLR0dFMmDAhWPaqq67i5ptvJjk5maSkJG655Rb69+/PqFGjWv8KRUTkkKa2SUREWluLgqSnnnoKgOHDh4dtf+GFF7jiiisAuPXWW6mqqmLixIkUFRUxZMgQPvnkE+Li4oLlH3vsMVwuFxdccAFVVVWMHDmSF198EafTuXdXIyIihx21TSIi0tr2ap2ktqK1KERE2s6Bvk5SW1HbJCLSNg64dZJEREREREQONQqSREREREREQihIEhERERERCaEgSUREREREJISCJBERERERkRAKkkREREREREIoSBIREREREQmhIElERERERCSEgiQREREREZEQCpJERERERERCKEgSEREREREJoSBJREREREQkhIIkERERERGREAqSREREREREQihIEhERERERCaEgSURERERE4lOCNQAAFqpJREFUJISCJBERERERkRAKkkREREREREIo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Rk/i0Q+3VPQkbd9uBwAAAACDIW2vJAEAAADAYGBIAgAAAIAYDEkAAAAAEIMhCQAAAABiMCQBAAAAQIy0HJKeeeYZVVRUqKCgQDU1NfrrX/+a6lMaFI2NjfrGN76h4uJijRw5Urfccov+8Y9/xNWYmZYtW6ZgMKjCwkJNnz5d27dvj6uJRCJatGiRRowYoaKiIt18883at2/fhVzKoGlsbJTH41F9fb2zLxt7sn//fs2bN0+lpaUaNmyYvv71r6u1tdU5nk096evr089+9jNVVFSosLBQ48aN089//nNFo1GnJtP78Ze//EU33XSTgsGgPB6Pfv/738cdT9b6u7u7NX/+fPn9fvn9fs2fP1+HDx8e5NUNXWTT5zL9OXY25NIJ5FI8sikNs8nSTFNTk+Xl5dmKFStsx44dVldXZ0VFRbZnz55Un1rSXX/99bZq1Srbtm2btbW12axZs2zMmDH22WefOTXLly+34uJie+2116y9vd1uv/12GzVqlIXDYafmnnvusfLycmtubrYtW7bYd77zHfva175mfX19qVhW0mzatMkuvfRSu+KKK6yurs7Zn209+fe//21jx461733ve/b+++/b7t27bd26dfbRRx85NdnUk8cee8xKS0vtj3/8o+3evdt+97vf2Ve+8hV76qmnnJpM78ebb75pS5cutddee80k2euvvx53PFnrv+GGG6yqqso2bNhgGzZssKqqKps9e/aFWuaQQjaRTWbk0inkkhvZlH7ZlHZD0je/+U2755574vaNHz/eHnrooRSd0YXT1dVlkqylpcXMzKLRqAUCAVu+fLlTc+zYMfP7/fbcc8+Zmdnhw4ctLy/PmpqanJr9+/dbTk6OvfXWWxd2AUl05MgRq6ystObmZps2bZoTRtnYkwcffNCmTp16xuPZ1pNZs2bZ97///bh9t956q82bN8/Msq8fA4MoWevfsWOHSbL33nvPqdm4caNJsr///e+DvKqhh2wim8ilz5FLbmRTvHTIprR6u11vb69aW1s1c+bMuP0zZ87Uhg0bUnRWF04oFJIklZSUSJJ2796tzs7OuH74fD5NmzbN6Udra6uOHz8eVxMMBlVVVZXWPfvRj36kWbNm6dprr43bn409eeONNzRx4kTddtttGjlypK688kqtWLHCOZ5tPZk6dar+9Kc/6cMPP5Qk/e1vf9P69ev13e9+V1L29WOgZK1/48aN8vv9mjRpklMzefJk+f3+tO9Rosgmskkil2KRS25k09kNxWzyfpkFXWgHDx5Uf3+/ysrK4vaXlZWps7MzRWd1YZiZ7r//fk2dOlVVVVWS5Kz5dP3Ys2ePU5Ofn6+LLrrIVZOuPWtqatKWLVu0efNm17Fs7MmuXbv07LPP6v7779dPf/pTbdq0Sffdd598Pp/uuuuurOvJgw8+qFAopPHjxys3N1f9/f16/PHHNXfuXEnZ+TMSK1nr7+zs1MiRI11ff+TIkWnfo0SRTWQTuRSPXHIjm85uKGZTWg1Jp3g8nri/m5lrX6ZZuHChPvjgA61fv9517Iv0I1171tHRobq6Oq1du1YFBQVnrMumnkSjUU2cOFENDQ2SpCuvvFLbt2/Xs88+q7vuusupy5aevPrqq1q9erVeeeUVTZgwQW1tbaqvr1cwGNSCBQucumzpx5kkY/2nq8+kHiWKbIqXLc8xcsmNXHIjm87PUMqmtHq73YgRI5Sbm+uaBLu6ulyTZyZZtGiR3njjDb3zzju65JJLnP2BQECSztqPQCCg3t5edXd3n7EmnbS2tqqrq0s1NTXyer3yer1qaWnR008/La/X66wpm3oyatQoXX755XH7vvrVr2rv3r2Ssu/n5Cc/+Ykeeugh3XHHHaqurtb8+fP14x//WI2NjZKyrx8DJWv9gUBAn3zyievrf/rpp2nfo0SRTdmdTeSSG7nkRjad3VDMprQakvLz81VTU6Pm5ua4/c3NzZoyZUqKzmrwmJkWLlyoNWvW6M9//rMqKirijldUVCgQCMT1o7e3Vy0tLU4/ampqlJeXF1dz4MABbdu2LS17NmPGDLW3t6utrc3ZJk6cqNraWrW1tWncuHFZ15Nvfetbrtvvfvjhhxo7dqyk7Ps5OXr0qHJy4n+15ebmOrdZzbZ+DJSs9V999dUKhULatGmTU/P+++8rFAqlfY8SRTZldzaRS27kkhvZdHZDMpsSus3DEHDqNqsrV660HTt2WH19vRUVFdnHH3+c6lNLuh/+8Ifm9/vt3XfftQMHDjjb0aNHnZrly5eb3++3NWvWWHt7u82dO/e0t0u85JJLbN26dbZlyxa75ppr0uZ2kecj9i5CZtnXk02bNpnX67XHH3/cdu7cab/5zW9s2LBhtnr1aqcmm3qyYMECKy8vd26zumbNGhsxYoQtWbLEqcn0fhw5csS2bt1qW7duNUn2xBNP2NatW53bUSdr/TfccINdccUVtnHjRtu4caNVV1dn/S3AyaYTMv05di7kErk0ENmUftmUdkOSmdmvf/1rGzt2rOXn59tVV13l3HY000g67bZq1SqnJhqN2iOPPGKBQMB8Pp99+9vftvb29riv09PTYwsXLrSSkhIrLCy02bNn2969ey/wagbPwDDKxp784Q9/sKqqKvP5fDZ+/Hh7/vnn445nU0/C4bDV1dXZmDFjrKCgwMaNG2dLly61SCTi1GR6P955553T/u5YsGCBmSVv/YcOHbLa2lorLi624uJiq62tte7u7gu0yqGHbFrl1GT6c+xcyCVyaSCyKf2yyWNmlti1JwAAAADIXGn1mSQAAAAAGGwMSQAAAAAQgyEJAAAAAGIwJAEAAABADIYkAAAAAIjBkAQAAAAAMRiSAAAAACAGQxIAAAAAxGBIAgAAAIAYDEkAAAAAEIMhCQAAAABi/D/s8xcoMSqe/AAAAABJRU5ErkJggg==", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "plt.figure(figsize=(10,6))\n", - "plt.subplot(2, 2, 1)\n", - "plt.imshow(e07_projections[0])\n", - "plt.title(\"C01 - Maximum Projection\");\n", - "plt.subplot(2, 2, 2)\n", - "plt.imshow(e07_projections[1])\n", - "plt.title(\"C02 - Best-focus Projection\");\n", - "plt.subplot(2, 2, 3)\n", - "plt.imshow(e07_projections[2])\n", - "plt.title(\"C03 - Maxium Projection\");\n", - "plt.subplot(2, 2, 4)\n", - "plt.imshow(e07_projections[3])\n", - "plt.title(\"C04 - Empty - No Projection Acquired\");\n", - "plt.suptitle(\"Projections\");" - ] - }, - { - "cell_type": "markdown", - "id": "dfa4c7b0-0518-46f1-9b48-3236f89cd71f", - "metadata": {}, - "source": [ - "### Add labels / segmentations" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "id": "0422b396-7944-41e5-89ad-28c32c724abf", - "metadata": {}, - "outputs": [], - "source": [ - "import numpy as np\n", - "from skimage.measure import label\n", - "from faim_hcs.Zarr import write_labels_to_group" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "id": "470b5f17-a20d-4b6f-9f34-96568958b347", - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "text/plain": [ - "25232" - ] - }, - "execution_count": 14, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "np.max(e07_projections[0])" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "id": "ae7f09e4-62ba-4e4e-8344-1b3ea4c48c3c", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "labels = label(e07_projections[0]>10000)\n", - "labels = labels[np.newaxis, :]" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "id": "c1ebd174-71f6-4701-9500-21c8792bcead", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "write_labels_to_group(labels=labels, labels_name=\"test\", parent_group=plate['E/7/0/projections'])" - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "id": "877b65cb-ae0f-4475-95ad-0086a4539adf", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "segmentation = plate['E/7/0/projections/labels/test']" - ] - }, - { - "cell_type": "code", - "execution_count": 18, - "id": "83fcb620-bac6-4de2-94c0-d9227332d578", - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 18, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "segmentation[0]" - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "id": "b0615dbf-0e5e-4a30-801d-6bf259e4d915", - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 19, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "plt.imshow(segmentation[0][0])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0bb740af-4756-43cd-843f-e861d1ed35d3", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.16" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/examples/ImageXpressSinglePlaneAcquisitionExample.py b/examples/ImageXpressSinglePlaneAcquisitionExample.py new file mode 100644 index 00000000..9b9170d2 --- /dev/null +++ b/examples/ImageXpressSinglePlaneAcquisitionExample.py @@ -0,0 +1,46 @@ +import shutil +from pathlib import Path + +from faim_hcs.hcs.acquisition import TileAlignmentOptions +from faim_hcs.hcs.converter import ConvertToNGFFPlate, NGFFPlate +from faim_hcs.hcs.imagexpress import SinglePlaneAcquisition +from faim_hcs.hcs.plate import PlateLayout +from faim_hcs.stitching import stitching_utils + + +def main(): + # Remove existing zarr. + shutil.rmtree("md-single-plane.zarr", ignore_errors=True) + + # Parse MD plate acquisition. + plate = SinglePlaneAcquisition( + acquisition_dir=Path(__file__).parent.parent / "resources" / "Projection-Mix", + alignment=TileAlignmentOptions.GRID, + ) + + # Create converter. + converter = ConvertToNGFFPlate( + ngff_plate=NGFFPlate( + root_dir=".", + name="md-single-plane", + layout=PlateLayout.I384, + order_name="order", + barcode="barcode", + ), + yx_binning=2, + dask_chunk_size_factor=2, + warp_func=stitching_utils.translate_tiles_2d, + fuse_func=stitching_utils.fuse_mean, + ) + + # Run conversion. + converter.run( + plate_acquisition=plate, + well_sub_group="0", + chunks=(1, 512, 512), + max_layer=2, + ) + + +if __name__ == "__main__": + main() diff --git a/examples/ImageXpressStackeAcquisitionExample.py b/examples/ImageXpressStackeAcquisitionExample.py new file mode 100644 index 00000000..d6fddcd1 --- /dev/null +++ b/examples/ImageXpressStackeAcquisitionExample.py @@ -0,0 +1,46 @@ +import shutil +from pathlib import Path + +from faim_hcs.hcs.acquisition import TileAlignmentOptions +from faim_hcs.hcs.converter import ConvertToNGFFPlate, NGFFPlate +from faim_hcs.hcs.imagexpress import StackAcquisition +from faim_hcs.hcs.plate import PlateLayout +from faim_hcs.stitching import stitching_utils + + +def main(): + # Remove existing zarr. + shutil.rmtree("md-stack.zarr", ignore_errors=True) + + # Parse MD plate acquisition. + plate = StackAcquisition( + acquisition_dir=Path(__file__).parent.parent / "resources" / "Projection-Mix", + alignment=TileAlignmentOptions.GRID, + ) + + # Create converter. + converter = ConvertToNGFFPlate( + ngff_plate=NGFFPlate( + root_dir=".", + name="md-stack", + layout=PlateLayout.I384, + order_name="order", + barcode="barcode", + ), + yx_binning=2, + dask_chunk_size_factor=2, + warp_func=stitching_utils.translate_tiles_2d, + fuse_func=stitching_utils.fuse_mean, + ) + + # Run conversion. + converter.run( + plate_acquisition=plate, + well_sub_group="0", + chunks=(1, 512, 512), + max_layer=2, + ) + + +if __name__ == "__main__": + main() diff --git a/examples/TileStitchingExample.py b/examples/TileStitchingExample.py deleted file mode 100644 index fef61519..00000000 --- a/examples/TileStitchingExample.py +++ /dev/null @@ -1,67 +0,0 @@ -import zarr -from numcodecs import Blosc -from numpy._typing import ArrayLike -from ome_zarr.io import parse_url -from ome_zarr.scale import Scaler -from ome_zarr.writer import write_image - -from faim_hcs.stitching import DaskTileStitcher, Tile, stitching_utils -from faim_hcs.stitching.Tile import TilePosition - - -def no_fuse(warped_tiles: ArrayLike, warped_masks: ArrayLike) -> ArrayLike: - return warped_tiles[0] - - -def main(): - tiles = [ - Tile( - path="/home/tibuch/Data/ctc/Fluo-N2DH-GOWT1/01/t000.tif", - shape=(1024, 1024), - position=TilePosition(time=0, channel=0, z=0, y=0, x=0), - ), - Tile( - path="/home/tibuch/Data/ctc/Fluo-N2DH-GOWT1/01/t000.tif", - shape=(1024, 1024), - position=TilePosition(time=0, channel=1, z=0, y=0, x=0), - ), - Tile( - path="/home/tibuch/Data/ctc/Fluo-N2DH-GOWT1/01/t000.tif", - shape=(1024, 1024), - position=TilePosition(time=1, channel=0, z=0, y=0, x=0), - ), - Tile( - path="/home/tibuch/Data/ctc/Fluo-N2DH-GOWT1/01/t000.tif", - shape=(1024, 1024), - position=TilePosition(time=1, channel=1, z=0, y=0, x=0), - ), - ] - - stitcher = DaskTileStitcher( - tiles=tiles, - yx_chunk_shape=(1024, 1024), - ) - - stitched_img_da = stitcher.get_stitched_dask_array( - warp_func=stitching_utils.translate_tiles_2d, - fuse_func=no_fuse, - ) - - store = parse_url("stitched.zarr", mode="w").store - zarr_file = zarr.group(store=store, overwrite=True) - write_image( - image=stitched_img_da, - group=zarr_file, - axes=["t", "c", "z", "y", "x"], - storage_options=dict( - dimension_separator="/", - compressor=Blosc(cname="zstd", clevel=6, shuffle=Blosc.BITSHUFFLE), - ), - scaler=Scaler( - max_layer=0, - ), - ) - - -if __name__ == "__main__": - main() diff --git a/setup.cfg b/setup.cfg index 8ca30264..1f3913ff 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,6 +36,7 @@ install_requires = pandas pydantic==1.10.13 scikit-image>=0.20.0 + threadpoolctl tifffile tqdm zarr diff --git a/src/faim_hcs/CellVoyagerUtils.py b/src/faim_hcs/CellVoyagerUtils.py deleted file mode 100644 index b4642f07..00000000 --- a/src/faim_hcs/CellVoyagerUtils.py +++ /dev/null @@ -1,236 +0,0 @@ -from decimal import Decimal -from typing import Callable, Union - -import numpy as np -import pandas as pd -from numpy.typing import ArrayLike -from tifffile.tifffile import imread - -from faim_hcs.MontageUtils import montage_stage_pos_image_YX -from faim_hcs.UIntHistogram import UIntHistogram - - -def _build_ch_metadata(source: pd.DataFrame, channel: str): - ch_meta = source.set_index("Ch") - metadata = { - "wavelength": ch_meta.at[channel, "Target"], - # "power": power, - "exposure-time": float(ch_meta.at[channel, "ExposureTime"]), - "exposure-time-unit": "ms", # hard-coded - # "shading-correction": metaseries_ch_metadata["ShadingCorrection"] == "On", - "channel-name": ch_meta.at[channel, "Target"], - # "objective-numerical-aperture": metaseries_ch_metadata["_MagNA_"], - "objective": ch_meta.at[channel, "Objective"], - "display-color": ch_meta.at[channel, "Color"][-6:], - } - return metadata - - -def _build_px_metadata(source: pd.DataFrame, channel: str, dtype: np.dtype): - ch_meta = source.set_index("Ch") - return { - "pixel-type": dtype, - "spatial-calibration-units": "um", - "spatial-calibration-x": float(ch_meta.at[channel, "HorizontalPixelDimension"]), - "spatial-calibration-y": float(ch_meta.at[channel, "VerticalPixelDimension"]), - } - - -def build_stack(imgs: list): - # Build zero-stack - stack = None - for img in imgs: - if img is not None: - shape = img.shape - stack = np.zeros((len(imgs), *shape), dtype=img.dtype) - break - - # Fill in existing data - for i, img in enumerate(imgs): - if img is not None: - stack[i] = img - - return stack - - -def compute_z_sampling(ch_z_positions: list[list[Union[None, float]]]): - z_samplings = [] - for z_positions in ch_z_positions: - if z_positions is not None and None not in z_positions: - precision = -Decimal(str(z_positions[0])).as_tuple().exponent - z_step = np.round(np.mean(np.diff(z_positions)), decimals=precision) - z_samplings.append(z_step) - - return np.mean(z_samplings) - - -def get_well_image_CZYX( - well_files: pd.DataFrame, - channel_metadata_source: pd.DataFrame, - channels: list[str], - assemble_fn: Callable = montage_stage_pos_image_YX, -) -> tuple[ArrayLike, list[UIntHistogram], list[dict], dict, dict]: - """Assemble image data for the given well-files.""" - planes = well_files["ZIndex"].unique() - - stacks = [] - channel_histograms = [] - ch_metadata = [] - z_positions = [] - roi_tables = {} - - for ch in channels: - channel_files = well_files[well_files["Ch"] == ch] - if len(channel_files) > 0: - plane_imgs = [] - z_plane_positions = [] - for z in sorted(planes, key=int): - plane_files = channel_files[channel_files["ZIndex"] == z] - if len(plane_files) > 0: - img, z_pos, roi_tables = get_img_YX( - assemble_fn=assemble_fn, - files=plane_files, - channel_metadata=channel_metadata_source, - ) - plane_imgs.append(img) - z_plane_positions.append(z_pos) - else: - plane_imgs.append(None) - z_plane_positions.append(None) - zyx = build_stack(plane_imgs) - stacks.append(zyx) - channel_histograms.append(UIntHistogram(zyx)) - ch_metadata.append( - _build_ch_metadata(source=channel_metadata_source, channel=ch) - ) - z_positions.append(z_plane_positions) - else: - stacks.append(None) - channel_histograms.append(UIntHistogram()) - ch_metadata.append( - { - "channel-name": "empty", - "display-color": "000000", - } - ) - z_positions.append(None) - - czyx = build_stack(stacks) - - px_metadata = _build_px_metadata( - source=channel_metadata_source, - channel=ch, - dtype=czyx.dtype, - ) - - z_sampling = compute_z_sampling(z_positions) - px_metadata["z-scaling"] = z_sampling - - return czyx, channel_histograms, ch_metadata, px_metadata, roi_tables - - -def get_well_image_CYX( - well_files: pd.DataFrame, - channel_metadata_source: pd.DataFrame, - channels: list[str], - assemble_fn: Callable = montage_stage_pos_image_YX, -) -> tuple[ArrayLike, list[UIntHistogram], list[dict], dict, dict]: - """Assemble image data for the given well files. - - For each channel, a single 2D image is computed. If the well has multiple - fields per channel, the `assemble_fn` has to montage or stitch the fields - accordingly. - - :param well_files: all files corresponding to the well - :param channels: list of required channels - :param assemble_fn: creates a single image for each channel - :return: CYX image, channel-histograms, channel-metadata, general-metadata, - roi-tables dictionary - """ - channel_imgs = {} - channel_histograms = {} - channel_metadata = {} - roi_tables = {} - - for ch in channels: - channel_files = well_files[well_files["Ch"] == ch] - if len(channel_files) > 0: - img, _, roi_tables = get_img_YX( - assemble_fn=assemble_fn, - files=channel_files, - channel_metadata=channel_metadata_source, - ) - channel_imgs[ch] = img - channel_histograms[ch] = UIntHistogram(img) - channel_metadata[ch] = _build_ch_metadata(channel_metadata_source, ch) - - cyx = np.zeros((len(channels), *img.shape), dtype=img.dtype) - - channel_hists = [] - channel_meta = [] - for i, ch in enumerate(channels): - if ch in channel_imgs.keys(): - cyx[i] = channel_imgs[ch] - channel_hists.append(channel_histograms[ch]) - channel_meta.append(channel_metadata[ch]) - else: - channel_hists.append(UIntHistogram()) - channel_meta.append( - { - "channel-name": "empty", - "display-color": "000000", - } - ) - - px_metadata = _build_px_metadata( - source=channel_metadata_source, - channel=ch, - dtype=cyx.dtype, - ) - - return cyx, channel_hists, channel_meta, px_metadata, roi_tables - - -def get_img_YX(assemble_fn, files: pd.DataFrame, channel_metadata: pd.DataFrame): - """Assemble single 2D image for all fields. - - Assumes that all files are from a single channel. - - img: 2D pixel array (yx) - z_position - roi_tables: dict[DataFrame] - """ - channel = np.unique(files["Ch"]) - if len(channel) != 1: - raise ValueError( - f"get_img_YX requires files for a single channel only, got: {channel}" - ) - channel = channel[0] - - z_position = np.mean(files["Z"].astype(float)) # assumed to be all same - ch_meta = channel_metadata.set_index("Ch") - general_metadata = { - "spatial-calibration-x": float(ch_meta.at[channel, "HorizontalPixelDimension"]), - "spatial-calibration-y": float(ch_meta.at[channel, "VerticalPixelDimension"]), - "spatial-calibration-units": "um", # hard-coded unit - "pixel-type": None, - } - - imgs = [] - for file_row in files.sort_values(by="path").itertuples(): - img = imread(file_row.path) - # img = da.from_zarr(imread(file_row.path, aszarr=True), chunks=(-1,-1)) - if general_metadata["pixel-type"] is None: - general_metadata["pixel-type"] = img.dtype - img_metadata = { - "stage-position-x": float(file_row.X), - "stage-position-y": float(file_row.Y), - "spatial-calibration-x": general_metadata["spatial-calibration-x"], - "spatial-calibration-y": general_metadata["spatial-calibration-y"], - "stage-label": "F" - + file_row.FieldIndex, # TODO do we get a meaningful name from CV7000/CV8000? - } - imgs.append((img, img_metadata)) - img, roi_tables = assemble_fn(imgs) - # return general_metadata, img, metadata, z_position, roi_tables - return img, z_position, roi_tables diff --git a/src/faim_hcs/MetaSeriesUtils.py b/src/faim_hcs/MetaSeriesUtils.py deleted file mode 100644 index 3aca301e..00000000 --- a/src/faim_hcs/MetaSeriesUtils.py +++ /dev/null @@ -1,293 +0,0 @@ -from decimal import Decimal -from typing import Callable, Union - -import numpy as np -import pandas as pd -from numpy.typing import ArrayLike - -from faim_hcs.io.MetaSeriesTiff import load_metaseries_tiff -from faim_hcs.MontageUtils import montage_grid_image_YX -from faim_hcs.UIntHistogram import UIntHistogram -from faim_hcs.utils import rgb_to_hex, wavelength_to_rgb - - -def _build_ch_metadata(metaseries_ch_metadata: dict): - """Build channel metadata from metaseries metadata.""" - - def get_wavelength_power(channel): - # Custom channel names - custom_channel_dict = { - "Lumencor Cyan Intensity": "cyan", - "Lumencor Green Intensity": "green", - "Lumencor Red Intensity": "red", - "Lumencor Violet Intensity": "violet", - "Lumencor Yellow Intensity": "yellow", - } - - # Find the intensity channnels: - wavelengths = [] - for key in channel.keys(): - if key.endswith("Intensity"): - wavelengths.append(key) - - for wavelength in wavelengths: - if channel[wavelength] > 0.0: - if wavelength in custom_channel_dict.keys(): - wl = custom_channel_dict[wavelength] - else: - wl = wavelength - power = channel[wavelength] - - # Assert all other power values are zero - for other_wavelength in wavelengths: - if other_wavelength != wavelength: - assert channel[other_wavelength] == 0.0 - - return wl, power - - return None, None - - def get_exposure_time_unit(ch): - time, unit = ch["Exposure Time"].split(" ") - time = float(time) - return time, unit - - wavelength, power = get_wavelength_power(metaseries_ch_metadata) - time, unit = get_exposure_time_unit(metaseries_ch_metadata) - display_color = rgb_to_hex(*wavelength_to_rgb(metaseries_ch_metadata["wavelength"])) - metadata = { - "wavelength": wavelength, - "power": power, - "exposure-time": time, - "exposure-time-unit": unit, - "shading-correction": metaseries_ch_metadata["ShadingCorrection"] == "On", - "channel-name": metaseries_ch_metadata["_IllumSetting_"], - "objective-numerical-aperture": metaseries_ch_metadata["_MagNA_"], - "objective": metaseries_ch_metadata["_MagSetting_"], - "display-color": display_color, - } - if "Z Projection Method" in metaseries_ch_metadata: - metadata["z-projection-method"] = metaseries_ch_metadata["Z Projection Method"] - return metadata - - -def _z_metadata(metaseries_ch_metadata: dict): - return {"z-position": metaseries_ch_metadata["z-position"]} - - -def verify_integrity(field_metadata: list[dict]): - metadata = field_metadata[0] - for fm in field_metadata: - assert fm == metadata, "Metadata is not consistent across fields." - - return metadata - - -def build_stack(imgs: list): - # Build zero-stack - stack = None - for img in imgs: - if img is not None: - shape = img.shape - stack = np.zeros((len(imgs), *shape), dtype=img.dtype) - break - - # Fill in existing data - for i, img in enumerate(imgs): - if img is not None: - stack[i] = img - - return stack - - -def compute_z_sampling(ch_z_positions: list[list[Union[None, float]]]): - z_samplings = [] - for z_positions in ch_z_positions: - if z_positions is not None and None not in z_positions: - precision = -Decimal(str(z_positions[0])).as_tuple().exponent - z_step = np.round(np.mean(np.diff(z_positions)), decimals=precision) - z_samplings.append(z_step) - - return np.mean(z_samplings) - - -def roll_single_plane(stacks, ch_z_positions): - min_z, max_z = [], [] - for z_positions in ch_z_positions: - if z_positions is not None and None not in z_positions: - min_z.append(np.min(z_positions)) - max_z.append(np.max(z_positions)) - - min_z, max_z = np.mean(min_z), np.mean(max_z) - - for i, z_positions in enumerate(ch_z_positions): - if z_positions is not None and None in z_positions: - # Single planes are always acquired in first z-step - step_size = (max_z - min_z) / len(z_positions) - shift_z = int((z_positions[0] - min_z) // step_size) - stacks[i] = np.roll(stacks[i], shift_z, axis=0) - - -def get_well_image_CZYX( - well_files: pd.DataFrame, - channels: list[str], - assemble_fn: Callable = montage_grid_image_YX, -) -> tuple[ArrayLike, list[UIntHistogram], list[dict], dict, dict]: - """Assemble image data for the given well-files.""" - planes = well_files["z"].unique() - - stacks = [] - channel_histograms = [] - channel_metadata = [] - px_metadata = None - z_positions = [] - roi_tables = {} - - for ch in channels: - channel_files = well_files[well_files["channel"] == ch] - if len(channel_files) > 0: - plane_imgs = [] - z_plane_positions = [] - ch_metadata = None - for z in sorted(planes, key=int): - plane_files = channel_files[channel_files["z"] == z] - - if len(plane_files) > 0: - px_metadata, img, ch_meta, z_position, roi_tables = get_img_YX( - assemble_fn=assemble_fn, files=plane_files - ) - - if ch_metadata is None: - ch_metadata = ch_meta - - plane_imgs.append(img) - z_plane_positions.append(z_position) - else: - plane_imgs.append(None) - z_plane_positions.append(None) - - zyx = build_stack(plane_imgs) - stacks.append(zyx) - channel_histograms.append(UIntHistogram(stacks[-1])) - channel_metadata.append(ch_metadata) - z_positions.append(z_plane_positions) - else: - stacks.append(None) - channel_histograms.append(UIntHistogram()) - channel_metadata.append( - { - "channel-name": "empty", - "display-color": "000000", - } - ) - z_positions.append(None) - - z_sampling = compute_z_sampling(z_positions) - px_metadata["z-scaling"] = z_sampling - max_stack_size = max([x.shape[0] for x in stacks if x is not None]) - for roi_table in roi_tables.values(): - roi_table["len_z_micrometer"] = z_sampling * (max_stack_size - 1) - - roll_single_plane(stacks, z_positions) - - czyx = build_stack(stacks) - - for i in range(len(channel_histograms)): - if channel_histograms[i] is None: - channel_histograms[i] = UIntHistogram() - assert channel_metadata[i] is None - channel_metadata[i] = { - "channel-name": "empty", - "display-color": "000000", - } - - return czyx, channel_histograms, channel_metadata, px_metadata, roi_tables - - -def get_well_image_CYX( - well_files: pd.DataFrame, - channels: list[str], - assemble_fn: Callable = montage_grid_image_YX, -) -> tuple[ArrayLike, list[UIntHistogram], list[dict], dict, dict]: - """Assemble image data for the given well files. - - For each channel, a single 2D image is computed. If the well has multiple - fields per channel, the `assemble_fn` has to montage or stitch the fields - accordingly. - - :param well_files: all files corresponding to the well - :param channels: list of required channels - :param assemble_fn: creates a single image for each channel - :return: CYX image, channel-histograms, channel-metadata, general-metadata, - roi-tables dictionary - """ - channel_imgs = {} - channel_histograms = {} - channel_metadata = {} - px_metadata = None - roi_tables = {} - for ch in channels: - channel_files = well_files[well_files["channel"] == ch] - - if len(channel_files) > 0: - px_metadata, img, ch_metadata, _, roi_tables = get_img_YX( - assemble_fn, channel_files - ) - - channel_imgs[ch] = img - channel_histograms[ch] = UIntHistogram(img) - - channel_metadata[ch] = ch_metadata - - cyx = np.zeros((len(channels), *img.shape), dtype=img.dtype) - - channel_hists = [] - channel_meta = [] - for i, ch in enumerate(channels): - if ch in channel_imgs.keys(): - cyx[i] = channel_imgs[ch] - channel_hists.append(channel_histograms[ch]) - channel_meta.append(channel_metadata[ch]) - else: - channel_hists.append(UIntHistogram()) - channel_meta.append( - { - "channel-name": "empty", - "display-color": "000000", - } - ) - - return cyx, channel_hists, channel_meta, px_metadata, roi_tables - - -def get_img_YX(assemble_fn, files): - """Assemble single 2D image for all fields. - - general_metadata: spatial-calibration-x, spatial-calibration-y, spatial-calibration-units, pixel-type - img: 2D pixel array (yx) - metadata: list[dict] (wavelength, power, exposure-time, exposure-time-unit, shading-correction, - channel-name, objective-numerical-aperture, objective, display-color) - z_position - roi_tables: dict[DataFrame] - """ - imgs = [] - field_metadata = [] - z_positions = [] - general_metadata = None - for f in sorted(files["path"]): - img, ms_metadata = load_metaseries_tiff(f) - ch_metadata = _build_ch_metadata(ms_metadata) - z_positions.append(_z_metadata(ms_metadata)) - imgs.append((img, ms_metadata)) - field_metadata.append(ch_metadata) - if general_metadata is None: - general_metadata = { - "spatial-calibration-x": ms_metadata["spatial-calibration-x"], - "spatial-calibration-y": ms_metadata["spatial-calibration-y"], - "spatial-calibration-units": ms_metadata["spatial-calibration-units"], - "pixel-type": ms_metadata["PixelType"], - } - img, roi_tables = assemble_fn(imgs) - metadata = verify_integrity(field_metadata) - zs = [z["z-position"] for z in z_positions] - return general_metadata, img, metadata, np.mean(zs), roi_tables diff --git a/src/faim_hcs/MetaSeriesUtils_dask.py b/src/faim_hcs/MetaSeriesUtils_dask.py index aecb4310..6dacd803 100644 --- a/src/faim_hcs/MetaSeriesUtils_dask.py +++ b/src/faim_hcs/MetaSeriesUtils_dask.py @@ -1,63 +1,8 @@ -from typing import Callable - -import dask.array as da import numpy as np -import pandas as pd -import tifffile from numpy.typing import ArrayLike from scipy.ndimage import distance_transform_edt -def fuse_mean(tiles: ArrayLike, positions: ArrayLike) -> ArrayLike: - """ - Fuses tiles according to positions. - Where tiles overlap, it writes the mean of all tiles. - tiles: ArrayLike, should have shape (tiles, ny, nx) - positions: ArrayLike, should have shape (tiles, 2) - """ - ny_tot, nx_tot = positions.max(axis=0) + tiles.shape[-2:] - im_fused = np.zeros((ny_tot, nx_tot), dtype=tiles.dtype) - im_count = np.zeros_like(im_fused, dtype="uint8") - tile_count = np.ones_like(tiles[0], dtype="uint8") - ny_tile, nx_tile = tiles.shape[-2:] - - for tile, pos in zip(tiles, positions): - im_fused[pos[0] : pos[0] + ny_tile, pos[1] : pos[1] + nx_tile] += tile - im_count[pos[0] : pos[0] + ny_tile, pos[1] : pos[1] + nx_tile] += tile_count - - with np.errstate(divide="ignore"): - im_fused = im_fused // im_count - return im_fused - - -def fuse_mean_gradient(tiles: ArrayLike, positions: ArrayLike) -> ArrayLike: - """ - Fuses tiles according to positions. - Where tiles overlap, it writes the mean with a linear gradient. - tiles: ArrayLike, should have shape (tiles, ny, nx) - positions: ArrayLike, should have shape (tiles, 2) - """ - ny_tot, nx_tot = positions.max(axis=0) + tiles.shape[-2:] - im_fused = np.zeros((ny_tot, nx_tot), dtype="uint32") - im_weight = np.zeros_like(im_fused, dtype="uint32") - ny_tile, nx_tile = tiles.shape[-2:] - - # distance map to border of image - mask = np.ones((ny_tile, nx_tile)) - mask[[0, -1], :] = 0 - mask[:, [0, -1]] = 0 - dist_map = distance_transform_edt(mask).astype("uint32") + 1 - - for tile, pos in zip(tiles, positions): - im_fused[pos[0] : pos[0] + ny_tile, pos[1] : pos[1] + nx_tile] += ( - tile * dist_map - ) - im_weight[pos[0] : pos[0] + ny_tile, pos[1] : pos[1] + nx_tile] += dist_map - with np.errstate(divide="ignore"): - im_fused = im_fused // im_weight - return im_fused.astype(tiles.dtype) - - def fuse_random_gradient( tiles: ArrayLike, positions: ArrayLike, random_seed=0 ) -> ArrayLike: @@ -129,197 +74,3 @@ def fuse_rev(tiles: ArrayLike, positions: ArrayLike) -> ArrayLike: im_fused[pos[0] : pos[0] + ny_tile, pos[1] : pos[1] + nx_tile] = tile return im_fused - - -def _fuse_xy( - x: ArrayLike, assemble_fun: Callable, positions: ArrayLike, ny_tot: int, nx_tot: int -) -> ArrayLike: - """ - Load images and fuse them (used with dask.array.map_blocks()) - x: block of a dask array. axis[0]=fields & axis[-2:]=(y,x). The other axes - can be anything. - assemble_fun: function used to assemble tiles - positions: Array of tile-positions in pixels - ny_tot, nx_tot: yx-dimensions of output array - """ - ims_fused = np.empty(x.shape[1:-2] + (ny_tot, nx_tot), dtype=x.dtype) - for i in np.ndindex(x.shape[1:-2]): - slice_tuple = (slice(None),) + i # workaround for slicing like [:,*i] - ims_fused[i] = assemble_fun(x[slice_tuple], positions) - - return ims_fused - - -def fuse_dask(data: da.Array, positions: ArrayLike, assemble_fun: Callable) -> da.Array: - """ - Function to lazily fuse tiles of a dask-array - data: - dask array of shape (fields,channels,planes,ny,nx) - should have chunks (fields,1,1,ny,nx) or (1,1,1,ny,nx) - positions: - numpy-array of tile (y,x) positions in pixels - """ - # calculate tile-shape (ny,nx) and fused-shape (ny_tot,nx_tot) - ny, nx = data.shape[-2:] - ny_tot, nx_tot = positions.max(axis=0) + (ny, nx) - - # determine chunks of output array - out_chunks = da.core.normalize_chunks( - chunks=(1,) * (len(data.shape) - 3) + (ny_tot, nx_tot), - shape=data.shape[1:-2] + (ny_tot, nx_tot), - ) - - imgs_fused_da = da.map_blocks( - _fuse_xy, - data, - chunks=out_chunks, - drop_axis=0, - meta=np.array((), dtype=data.dtype), - # parameters for _fuse_da: - assemble_fun=assemble_fun, - positions=positions, - ny_tot=ny_tot, - nx_tot=nx_tot, - ) - - return imgs_fused_da - - -def create_filename_structure_FCZ( - well_files: pd.DataFrame, - channels: list[str], -) -> ArrayLike: - """ - Assemble filenames in a numpy-array with ordering (field,channel,plane). - This allows us to later easily map over the filenames to create a - dask-array of the images. - """ - planes = sorted(well_files["z"].unique(), key=int) - fields = sorted(well_files["field"].unique(), key=int) - # legacy: key=lambda s: int(re.findall(r"(\d+)", s)[0]) - - # Create an empty np array to store the filenames in the correct structure - fn_dtype = f" 1: - raise RuntimeError("Multiple files found for one FCZ") - - return fns_np - - -def create_filename_structure_FC( - well_files: pd.DataFrame, - channels: list[str], -) -> ArrayLike: - """ - Assemble filenames in a numpy-array with ordering (field,channel). - This allows us to later easily map over the filenames to create a - dask-array of the images. - """ - fields = sorted(well_files["field"].unique(), key=int) - # legacy: key=lambda s: int(re.findall(r"(\d+)", s)[0]) - - # Create an empty np array to store the filenames in the correct structure - fn_dtype = f" 1: - raise RuntimeError("Multiple files found for one FC") - - return fns_np - - -def _read_images(x: ArrayLike, ny: int, nx: int, im_dtype: type) -> ArrayLike: - """ - read images from filenames in an array - x: Array with one or more filenames, in any shape - ny, nx: shape of one tile in pixesl - dtype: dtype of image - returns: numpy-array of images, arranged in same shape as input array - """ - images = np.zeros(x.shape + (ny, nx), dtype=im_dtype) - for i in np.ndindex(x.shape): - filename = x[i] - if filename != "": - images[i] = tifffile.imread(filename) - return images - - -def read_FCZYX( - well_files: pd.DataFrame, channels: list[str], ny: int, nx: int, dtype: type -) -> da.Array: - """ - reads images from tiff-files into a dask array of shape (F,C,Z,Y,X) - """ - - # load filenames into array, so we can easily map over it - fns_np = create_filename_structure_FCZ(well_files, channels) - fns_da = da.from_array(fns_np, chunks=(1,) * len(fns_np.shape)) - - # create dask array of images - images_da = da.map_blocks( - _read_images, - fns_da, - chunks=da.core.normalize_chunks( - (1,) * len(fns_da.shape) + (ny, nx), fns_da.shape + (ny, nx) - ), - new_axis=list(range(len(fns_da.shape), len(fns_da.shape) + 2)), - meta=np.array((), dtype=dtype), - # parameters for _read_images: - ny=ny, - nx=nx, - im_dtype=dtype, - ) - return images_da - - -def read_FCYX( - well_files: pd.DataFrame, channels: list[str], ny: int, nx: int, dtype: type -) -> da.Array: - """ - reads images from tiff-files into a dask array of shape (F,C,Z,Y,X) - """ - # TODO: maye look into merging read_FCZYX and read FCYX - - # load filenames into array, so we can easily map over it - fns_np = create_filename_structure_FC(well_files, channels) - fns_da = da.from_array(fns_np, chunks=(1,) * len(fns_np.shape)) - - # create dask array of images - images_da = da.map_blocks( - _read_images, - fns_da, - chunks=da.core.normalize_chunks( - (1,) * len(fns_da.shape) + (ny, nx), fns_da.shape + (ny, nx) - ), - new_axis=list(range(len(fns_da.shape), len(fns_da.shape) + 2)), - meta=np.array((), dtype=dtype), - # parameters for _read_images: - ny=ny, - nx=nx, - im_dtype=dtype, - ) - return images_da diff --git a/src/faim_hcs/MontageUtils.py b/src/faim_hcs/MontageUtils.py deleted file mode 100644 index 02e4ec43..00000000 --- a/src/faim_hcs/MontageUtils.py +++ /dev/null @@ -1,225 +0,0 @@ -from typing import Any, Optional - -import numpy as np -import pandas as pd -from numpy.typing import ArrayLike - - -def _pixel_pos(dim: str, data: dict): - return np.round(data[f"stage-position-{dim}"] / data[f"spatial-calibration-{dim}"]) - - -def montage_grid_image_YX(data): - """Montage 2D fields into fixed grid, based on stage position metadata. - - Uses the stage position coordinates to decide which grid cell to put the - image in. Always writes images into a grid, thus avoiding overwriting - partially overwriting parts of images. Not well suited for arbitarily - positioned fields. In that case, use `montage_stage_pos_image_YX`. - - Also calculates ROI tables for the whole well and the field of views. - Given that Fractal ROI tables are always 3D, but we only stitch the xy - planes here, the z starting position is always 0 and the - z extent is set to 1. This is overwritten downsteam if the 2D planes are - assembled into a 3D stack. - - :param data: list of tuples of (image, metadata) - :return: img (stitched 2D np array), fov_df (dataframe with region of - interest information for the fields of view) - """ - min_y = min(_pixel_pos("y", d[1]) for d in data) - min_x = min(_pixel_pos("x", d[1]) for d in data) - max_y = max(_pixel_pos("y", d[1]) for d in data) - max_x = max(_pixel_pos("x", d[1]) for d in data) - - assert all([d[0].shape == data[0][0].shape for d in data]) - step_y = data[0][0].shape[0] - step_x = data[0][0].shape[1] - - shape = ( - int(np.round((max_y - min_y) / step_y + 1) * step_y), - int(np.round((max_x - min_x) / step_x + 1) * step_x), - ) - img = np.zeros(shape, dtype=data[0][0].dtype) - fov_rois = [] - - for d in data: - pos_x = int(np.round((_pixel_pos("x", d[1]) - min_x) / step_x)) - pos_y = int(np.round((_pixel_pos("y", d[1]) - min_y) / step_y)) - img[ - pos_y * step_y : (pos_y + 1) * step_y, pos_x * step_x : (pos_x + 1) * step_x - ] = d[0] - # Create the FOV ROI table for the site in physical units - fov_rois.append( - ( - _stage_label(d[1]), - pos_x * step_x * d[1]["spatial-calibration-x"], - pos_y * step_y * d[1]["spatial-calibration-y"], - 0.0, - step_x * d[1]["spatial-calibration-x"], - step_y * d[1]["spatial-calibration-y"], - 1.0, - ) - ) - - roi_tables = create_ROI_tables(fov_rois, shape, calibration_dict=d[1]) - - return img, roi_tables - - -def _get_molecular_devices_well_bbox_2D( - data: list[tuple[ArrayLike, dict]] -) -> tuple[Optional[Any], Optional[Any], Optional[Any], Optional[Any]]: - """Compute well-shape based on stage position metadata.""" - assert "stage-position-x" in data[0][1].keys(), "Missing metaseries metadata." - assert "stage-position-y" in data[0][1].keys(), "Missing metaseries metadata." - assert "spatial-calibration-x" in data[0][1].keys(), "Missing metaseries metadata." - assert "spatial-calibration-y" in data[0][1].keys(), "Missing metaseries metadata." - - min_x, max_x, min_y, max_y = None, None, None, None - for d in data: - pos_x = d[1]["stage-position-x"] - pos_y = d[1]["stage-position-y"] - res_x = d[1]["spatial-calibration-x"] - res_y = d[1]["spatial-calibration-y"] - - if min_x is None: - min_x = pos_x / res_x - max_x = min_x + d[0].shape[1] - elif min_x > (pos_x / res_x): - min_x = pos_x / res_x - - if max_x < (pos_x / res_x) + d[0].shape[1]: - max_x = (pos_x / res_x) + d[0].shape[1] - - if min_y is None: - min_y = pos_y / res_y - max_y = min_y + d[0].shape[0] - elif min_y > pos_y / res_y: - min_y = pos_y / res_y - - if max_y < (pos_y / res_y) + d[0].shape[0]: - max_y = (pos_y / res_y) + d[0].shape[0] - - return min_y, min_x, max_y, max_x - - -def montage_stage_pos_image_YX(data): - """Montage 2D fields based on stage position metadata. - - Montages 2D fields based on stage position metadata. If the stage position - specifies overlapping images, the overlapping part is overwritten - (=> just uses the data of one image). Not well suited for regular grids, - as the stage position can show overlap, but overwriting of data at the - edge is not the intended behavior. In that case, use - `montage_grid_image_YX`. - - Also calculates ROI tables for the whole well and the field of views in - the Fractal ROI table format. We only stitch the xy planes here. - Therefore, the z starting position is always 0 and the z extent is set to - 1. This is overwritten downsteam if the 2D planes are assembled into a - 3D stack. - - :param data: list of tuples (image, metadata) - :return: img (stitched 2D np array), fov_df (dataframe with region of - interest information for the fields of view) - """ - - def sort_key(d): - label = d[1]["stage-label"] - - label = label.split(":") - - if len(label) == 1: - return label - else: - return int(label[1].replace("Site", "")) - - data.sort(key=sort_key, reverse=True) - - min_y, min_x, max_y, max_x = _get_molecular_devices_well_bbox_2D(data) - - shape = (int(np.round(max_y - min_y)), int(np.round(max_x - min_x))) - - img = np.zeros(shape, dtype=data[0][0].dtype) - - fov_rois = [] - - for d in data: - pos_y = int( - np.round(d[1]["stage-position-y"] / d[1]["spatial-calibration-y"] - min_y) - ) - pos_x = int( - np.round(d[1]["stage-position-x"] / d[1]["spatial-calibration-x"] - min_x) - ) - - img[pos_y : pos_y + d[0].shape[0], pos_x : pos_x + d[0].shape[1]] = d[0] - - # Create the FOV ROI table for the site in physical units - fov_rois.append( - ( - _stage_label(d[1]), - pos_x * d[1]["spatial-calibration-x"], - pos_y * d[1]["spatial-calibration-y"], - 0.0, - d[0].shape[1] * d[1]["spatial-calibration-x"], - d[0].shape[0] * d[1]["spatial-calibration-y"], - 1.0, - ) - ) - - roi_tables = create_ROI_tables(fov_rois, shape, calibration_dict=d[1]) - - return img, roi_tables - - -def _stage_label(data: dict): - """Get the field of view (FOV) string for a given FOV dict""" - try: - return data["stage-label"].split(":")[-1][1:] - # Return an empty string if the metadata does not contain stage-label - except KeyError: - return "" - - -def create_ROI_tables(fov_rois, shape, calibration_dict): - columns = [ - "FieldIndex", - "x_micrometer", - "y_micrometer", - "z_micrometer", - "len_x_micrometer", - "len_y_micrometer", - "len_z_micrometer", - ] - roi_tables = {} - roi_tables["FOV_ROI_table"] = create_fov_ROI_table(fov_rois, columns) - roi_tables["well_ROI_table"] = create_well_ROI_table( - shape[1], - shape[0], - calibration_dict["spatial-calibration-x"], - calibration_dict["spatial-calibration-y"], - columns, - ) - return roi_tables - - -def create_well_ROI_table(shape_x, shape_y, pixel_size_x, pixel_size_y, columns): - well_roi = [ - "well_1", - 0.0, - 0.0, - 0.0, - shape_x * pixel_size_x, - shape_y * pixel_size_y, - 1.0, - ] - well_roi_table = pd.DataFrame(well_roi).T - well_roi_table.columns = columns - well_roi_table.set_index("FieldIndex", inplace=True) - return well_roi_table - - -def create_fov_ROI_table(fov_rois, columns): - roi_table = pd.DataFrame(fov_rois, columns=columns).set_index("FieldIndex") - return roi_table diff --git a/src/faim_hcs/Zarr.py b/src/faim_hcs/Zarr.py index 89b04db1..4c799d4f 100644 --- a/src/faim_hcs/Zarr.py +++ b/src/faim_hcs/Zarr.py @@ -1,449 +1,9 @@ -import os -from enum import IntEnum -from os.path import join -from pathlib import Path -from typing import Union +from typing import Any -import anndata as ad -import numpy as np -import pandas as pd -import zarr -from numpy._typing import ArrayLike -from ome_zarr.io import parse_url from ome_zarr.scale import Scaler -from ome_zarr.writer import ( - write_image, - write_multiscales_metadata, - write_plate_metadata, - write_well_metadata, -) +from ome_zarr.writer import write_image, write_multiscales_metadata from zarr import Group -from faim_hcs.UIntHistogram import UIntHistogram - - -class PlateLayout(IntEnum): - """Plate layout, 96-well or 384-well.""" - - I96 = 96 - I384 = 384 - - -def _get_row_cols(layout: Union[PlateLayout, int]) -> tuple[list[str], list[str]]: - """Return rows and columns for requested layout.""" - if layout == PlateLayout.I96: - rows = ["A", "B", "C", "D", "E", "F", "G", "H"] - cols = [str(i) for i in range(1, 13)] - assert len(rows) * len(cols) == 96 - elif layout == PlateLayout.I384: - rows = [ - "A", - "B", - "C", - "D", - "E", - "F", - "G", - "H", - "I", - "J", - "K", - "L", - "M", - "N", - "O", - "P", - ] - cols = [str(i) for i in range(1, 25)] - assert len(rows) * len(cols) == 384 - else: - raise NotImplementedError(f"{layout} layout not supported.") - - return rows, cols - - -def _create_zarr_plate( - root_dir: Path, - name: str, - layout: PlateLayout, - files: pd.DataFrame, - order_name: str, - barcode: str, -) -> Group: - """Create plate layout according to ome-zarr NGFF. - - Additionally the `order_name` and `barcode` is added to the plate.attrs. - - :param root_dir: where the zarr is stored - :param layout: plate layout - :param files: table of all image files - :param order_name: plate order name - :param barcode: plate barcode - :return: zarr group - """ - rows, cols = _get_row_cols(layout=layout) - - plate_path = join(root_dir, name + ".zarr") - os.makedirs(plate_path, exist_ok=False) - - store = parse_url(plate_path, mode="w").store - plate = zarr.group(store=store) - - write_plate_metadata( - plate, - columns=cols, - rows=rows, - wells=[f"{w[0]}/{str(int(w[1:]))}" for w in files["well"].unique()], - name=name, - field_count=1, - ) - - attrs = plate.attrs.asdict() - attrs["order_name"] = order_name - attrs["barcode"] = barcode - plate.attrs.put(attrs) - - return plate - - -def _add_wells_to_plate(plate: Group, files: pd.DataFrame) -> None: - """Add wells to zarr-plate according to ome-zarr NGFF.""" - for well in files["well"].unique(): - row, col = well[0], str(int(well[1:])) - - well_group = plate.require_group(row).require_group(col) - - well_group.create_group("0") - write_well_metadata(well_group, [{"path": "0"}]) - - -def build_zarr_scaffold( - root_dir: Union[str, Path], - files: pd.DataFrame, - name: str = None, - layout: Union[PlateLayout, int] = PlateLayout.I96, - order_name: str = "order-name", - barcode: str = "barcode", -) -> Group: - """Build empty zarr scaffold of a ome-zarr NGFF conform HCS experiment. - - Additionally `order_name` and `barcode` are added to the plate.attrs. - - :param root_dir: where the zarr is stored - :param files: table of image files - :param name: Name of the plate-zarr. By default taken from metadata. - :param layout: plate layout - :param order_name: plate order name - :param barcode: plate barcode - :return: zarr plate group - """ - names = files["name"].unique() - assert len(names) == 1, "Files do belong to more than one plate." - if name is None: - name = files["name"].unique()[0] - - plate = _create_zarr_plate( - root_dir=root_dir, - name=name, - layout=layout, - files=files, - order_name=order_name, - barcode=barcode, - ) - - _add_wells_to_plate(plate=plate, files=files) - - return plate - - -def _add_image_metadata( - img_group: Group, - ch_metadata: dict, - dtype: type, - histograms: list[UIntHistogram], -): - attrs = img_group.attrs.asdict() - - # Add omero metadata - attrs["omero"] = build_omero_channel_metadata(ch_metadata, dtype, histograms) - - # Add metaseries metadta - attrs["acquisition_metadata"] = {"channels": ch_metadata} - - # Save histograms and add paths to attributes - histogram_paths = [] - for i, (ch, hist) in enumerate(zip(ch_metadata, histograms)): - ch_name = ch["channel-name"].replace(" ", "_") - hist_name = f"C{str(i).zfill(2)}_{ch_name}_histogram.npz" - hist.save(join(img_group.store.path, img_group.path, hist_name)) - histogram_paths.append(hist_name) - - attrs["histograms"] = histogram_paths - - img_group.attrs.put(attrs) - - -def _compute_chunk_size_cyx( - img: ArrayLike, - max_levels: int = 4, - max_size: int = 2048, - lowest_res_target: int = 1024, - write_empty_chunks: bool = True, - dimension_separator: str = "/", -) -> tuple[list[dict[str, list[int]]], int]: - """Compute chunk-size for zarr storage. - - :param img: to be saved - :param max_levels: max resolution pyramid levels - :param max_size: chunk size maximum - :param lowest_res_target: lowest resolution target value. If the image is - smaller than this value, no more pyramid levels - will be created. - :return: storage options, number of pyramid levels - """ - storage_options = [] - chunks = [ - 1, - ] * img.ndim - for i in range(max_levels + 1): - h = min(max_size, img.shape[-2] // 2**i) - w = min(max_size, img.shape[-1] // 2**i) - chunks[-2] = h - chunks[-1] = w - storage_options.append( - { - "chunks": chunks.copy(), - "write_empty_chunks": write_empty_chunks, - "dimension_separator": dimension_separator, - } - ) - if h <= lowest_res_target and w <= lowest_res_target: - return storage_options, i - return storage_options, max_levels - - -def _get_axis_scale(axis, meta): - if axis["name"] == "x": - return meta["spatial-calibration-x"] - if axis["name"] == "y": - return meta["spatial-calibration-y"] - if axis["name"] == "z": - return meta["z-scaling"] - return 1 - - -def _set_multiscale_metadata(group: Group, general_metadata: dict, axes: list[dict]): - datasets = group.attrs.asdict()["multiscales"][0]["datasets"] - scaling = np.array([_get_axis_scale(axis, general_metadata) for axis in axes]) - - for ct in datasets: - rescaled = ct["coordinateTransformations"][0]["scale"] * scaling - ct["coordinateTransformations"][0]["scale"] = list(rescaled) - - write_multiscales_metadata(group, datasets=datasets, axes=axes) - - -def write_image_to_group( - img: ArrayLike, - axes: list[dict], - group: Group, - write_empty_chunks: bool = True, - **kwargs, -): - """ - Potential kwargs are `lowest_res_target`, `max_levels`, `max_size` and - `dimension_separator` that are used in `_compute_chunk_size_cyx`. - """ - storage_options, max_layer = _compute_chunk_size_cyx( - img, - write_empty_chunks=write_empty_chunks, - **kwargs, - ) - - scaler = Scaler(max_layer=max_layer) - - write_image( - img, group=group, axes=axes, storage_options=storage_options, scaler=scaler - ) - - -def write_image_and_metadata( - img: ArrayLike, - axes: list[dict], - histograms: list[UIntHistogram], - ch_metadata: list[dict], - general_metadata: dict, - group: Group, - write_empty_chunks: bool = True, - **kwargs, -): - """ - Potential kwargs are `lowest_res_target`, `max_levels`, `max_size` and - `dimension_separator` that are used in `_compute_chunk_size_cyx`. - """ - write_image_to_group( - img=img, - axes=axes, - group=group, - write_empty_chunks=write_empty_chunks, - **kwargs, - ) - - _set_multiscale_metadata(group=group, general_metadata=general_metadata, axes=axes) - - _add_image_metadata( - img_group=group, - ch_metadata=ch_metadata, - dtype=img.dtype, - histograms=histograms, - ) - - -def write_cyx_image_to_well( - img: ArrayLike, - histograms: list[UIntHistogram], - ch_metadata: list[dict], - general_metadata: dict, - group: Group, - write_empty_chunks: bool = True, - **kwargs, -): - """ - Potential kwargs are `lowest_res_target`, `max_levels`, `max_size` and - `dimension_separator` that are used in `_compute_chunk_size_cyx`. - """ - if general_metadata["spatial-calibration-units"] == "um": - axes = [ - {"name": "c", "type": "channel"}, - {"name": "y", "type": "space", "unit": "micrometer"}, - {"name": "x", "type": "space", "unit": "micrometer"}, - ] - else: - raise NotImplementedError("Spatial unit unknown.") - - write_image_and_metadata( - img=img, - axes=axes, - histograms=histograms, - ch_metadata=ch_metadata, - general_metadata=general_metadata, - group=group, - write_empty_chunks=write_empty_chunks, - **kwargs, - ) - - -def write_roi_table( - roi_table: pd.DataFrame, - table_name: str, - group: Group, -): - """Writes a roi table to an OME-Zarr image. If no table folder exists, it is created.""" - group_tables = group.require_group("tables") - - # Assign dtype explicitly, to avoid - # >> UserWarning: X converted to numpy array with dtype float64 - # when creating AnnData object - df_roi = roi_table.astype(np.float32) - - adata = ad.AnnData(X=df_roi) - adata.obs_names = roi_table.index - adata.var_names = list(map(str, roi_table.columns)) - ad._io.specs.write_elem(group_tables, table_name, adata) - update_table_metadata(group_tables, table_name) - - -def update_table_metadata(group_tables, table_name): - if "tables" not in group_tables.attrs: - group_tables.attrs["tables"] = [table_name] - elif table_name not in group_tables.attrs["tables"]: - group_tables.attrs["tables"] = group_tables.attrs["tables"] + [table_name] - - -def write_czyx_image_to_well( - img: ArrayLike, - histograms: list[UIntHistogram], - ch_metadata: list[dict], - general_metadata: dict, - group: Group, - write_empty_chunks: bool = True, - **kwargs, -): - """ - Potential kwargs are `lowest_res_target`, `max_levels`, `max_size` and - `dimension_separator` that are used in `_compute_chunk_size_cyx`. - """ - if general_metadata["spatial-calibration-units"] == "um": - axes = [ - {"name": "c", "type": "channel"}, - {"name": "z", "type": "space", "unit": "micrometer"}, - {"name": "y", "type": "space", "unit": "micrometer"}, - {"name": "x", "type": "space", "unit": "micrometer"}, - ] - else: - raise NotImplementedError("Spatial unit unknown.") - - write_image_and_metadata( - img=img, - axes=axes, - histograms=histograms, - ch_metadata=ch_metadata, - general_metadata=general_metadata, - group=group, - write_empty_chunks=write_empty_chunks, - **kwargs, - ) - - -def build_omero_channel_metadata( - ch_metadata: dict, dtype: type, histograms: list[UIntHistogram] -): - """Build omero conform channel metadata to be stored in zarr attributes. - - * Color is computed from the metaseries wavelength metadata. - * Label is the set to the metaseries _IllumSetting_ metadata. - * Intensity scaling is obtained from the data histogram [0.01, - 0.999] quantiles. - - :param ch_metadata: channel metadata from tiff-tags - :param dtype: data type - :param histograms: histograms of channels - :return: omero metadata dictionary - """ - channels = [] - for i, (ch, hist) in enumerate(zip(ch_metadata, histograms)): - label = ch["channel-name"] - if "z-projection-method" in ch.keys(): - proj_method = ch["z-projection-method"] - proj_method = proj_method.replace(" ", "-") - label = f"{proj_method}-Projection_{label}" - - start = hist.quantile(0.01) - end = hist.quantile(0.999) - # Avoid rescaling from 0 to 0 (leads to napari display errors) - if start == end: - end = end + 1 - - channels.append( - { - "active": True, - "coefficient": 1, - "color": ch["display-color"], - "family": "linear", - "inverted": False, - "label": label, - "wavelength_id": f"C{str(i + 1).zfill(2)}", - "window": { - "min": np.iinfo(dtype).min, - "max": np.iinfo(dtype).max, - "start": start, - "end": end, - }, - } - ) - - return {"channels": channels} - def _copy_multiscales_metadata(parent_group, subgroup): datasets = parent_group.attrs.asdict()["multiscales"][0]["datasets"] @@ -455,13 +15,12 @@ def write_labels_to_group( labels, labels_name, parent_group: Group, - write_empty_chunks: bool = True, + storage_options: dict[str, Any], + max_layer: int = 0, overwrite: bool = False, - **kwargs, ): """ - Potential kwargs are `lowest_res_target`, `max_levels`, `max_size` and - `dimension_separator` that are used in `_compute_chunk_size_cyx`. + Write labels to zarr group and copy multiscales metadata from parent group. """ subgroup = parent_group.require_group( f"labels/{labels_name}", @@ -473,12 +32,12 @@ def write_labels_to_group( labels.shape ), f"Group axes don't match label image dimensions: {len(axes)} <> {len(labels.shape)}." - write_image_to_group( - img=labels, - axes=axes, + write_image( + image=labels, group=subgroup, - write_empty_chunks=write_empty_chunks, - **kwargs, + axes=axes, + storage_options=storage_options, + scaler=Scaler(max_layer=max_layer), ) _copy_multiscales_metadata(parent_group, subgroup) diff --git a/src/faim_hcs/alignment/__init__.py b/src/faim_hcs/alignment/__init__.py new file mode 100644 index 00000000..453dfe1c --- /dev/null +++ b/src/faim_hcs/alignment/__init__.py @@ -0,0 +1 @@ +from .alignment import GridAlignment, StageAlignment # noqa: F401 diff --git a/src/faim_hcs/alignment/alignment.py b/src/faim_hcs/alignment/alignment.py new file mode 100644 index 00000000..3e7dac3c --- /dev/null +++ b/src/faim_hcs/alignment/alignment.py @@ -0,0 +1,67 @@ +from abc import ABC +from copy import copy + +from faim_hcs.stitching import Tile, stitching_utils + + +class AbstractAlignment(ABC): + _unaligned_tiles: list[Tile] = None + _aligned_tiles: list[Tile] = None + + def __init__(self, tiles: list[Tile]) -> None: + super().__init__() + self._unaligned_tiles = stitching_utils.shift_to_origin(tiles) + self._aligned_tiles = self._align(tiles) + + def _align(self, tiles: list[Tile]) -> list[Tile]: + raise NotImplementedError() + + def get_tiles(self) -> list[Tile]: + return self._aligned_tiles + + +class StageAlignment(AbstractAlignment): + """ + Align tiles using stage positions. + """ + + def _align(self, tiles: list[Tile]) -> list[Tile]: + return tiles + + +class GridAlignment(AbstractAlignment): + """ + Align tiles on a regular grid. + """ + + def _align(self, tiles: list[Tile]) -> list[Tile]: + aligned_tiles = [] + + tile_shape = tiles[0].shape + + grid_positions_y = set() + grid_positions_x = set() + tile_map = {} + for tile in tiles: + assert tile.shape == tile_shape, "All tiles must have the same shape." + y_pos = tile.position.y // tile_shape[0] + x_pos = tile.position.x // tile_shape[1] + if (y_pos, x_pos) in tile_map.keys(): + tile_map[(y_pos, x_pos)].append(tile) + else: + tile_map[(y_pos, x_pos)] = [tile] + grid_positions_y.add(y_pos) + grid_positions_x.add(x_pos) + + grid_positions_y = list(sorted(grid_positions_y)) + grid_positions_x = list(sorted(grid_positions_x)) + for y_pos in grid_positions_y: + for x_pos in grid_positions_x: + if (y_pos, x_pos) in tile_map.keys(): + for unaligned_tile in tile_map[(y_pos, x_pos)]: + new_tile = copy(unaligned_tile) + new_tile.position.y = y_pos * tile_shape[0] + new_tile.position.x = x_pos * tile_shape[1] + aligned_tiles.append(new_tile) + + return aligned_tiles diff --git a/src/faim_hcs/hcs/__init__.py b/src/faim_hcs/hcs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/faim_hcs/hcs/acquisition.py b/src/faim_hcs/hcs/acquisition.py new file mode 100644 index 00000000..61579bca --- /dev/null +++ b/src/faim_hcs/hcs/acquisition.py @@ -0,0 +1,285 @@ +from abc import ABC, abstractmethod +from collections.abc import Iterable +from enum import Enum +from pathlib import Path +from typing import Any, Optional, Union + +import numpy as np +import pandas as pd + +from faim_hcs.io.ChannelMetadata import ChannelMetadata +from faim_hcs.stitching import Tile + + +class TileAlignmentOptions(Enum): + """Tile alignment options.""" + + STAGE_POSITION = "StageAlignment" + GRID = "GridAlignment" + + +class PlateAcquisition(ABC): + _acquisition_dir = None + _files = None + _alignment: TileAlignmentOptions = None + _background_correction_matrices: Optional[dict[str, Union[Path, str]]] + _illumination_correction_matrices: Optional[dict[str, Union[Path, str]]] + + def __init__( + self, + acquisition_dir: Union[Path, str], + alignment: TileAlignmentOptions, + background_correction_matrices: Optional[dict[str, Union[Path, str]]], + illumination_correction_matrices: Optional[dict[str, Union[Path, str]]], + ) -> None: + self._acquisition_dir = acquisition_dir + self._files = self._parse_files() + self._alignment = alignment + self._background_correction_matrices = background_correction_matrices + self._illumination_correction_matrices = illumination_correction_matrices + super().__init__() + + @abstractmethod + def _parse_files(self) -> pd.DataFrame: + """Parse all files in the acquisition directory. + + Returns + ------- + DataFrame + Table of all files in the acquisition. + """ + raise NotImplementedError() + + @abstractmethod + def get_well_acquisitions(self) -> list["WellAcquisition"]: + """List of wells.""" + raise NotImplementedError() + + @abstractmethod + def get_channel_metadata(self) -> dict[int, ChannelMetadata]: + """Channel metadata.""" + raise NotImplementedError() + + def get_well_names(self) -> Iterable[str]: + """ + Get the names of all wells in the acquisition. + """ + for well in self.get_well_acquisitions(): + yield well.name + + def get_omero_channel_metadata(self) -> list[dict]: + """ + Get the channel metadata in OMERO format. + + Returns + ------- + List of channel metadata. + """ + ome_channels = [] + ch_metadata = self.get_channel_metadata() + max_channel = max(list(ch_metadata.keys())) + for index in range(max_channel + 1): + if index in ch_metadata.keys(): + metadata = ch_metadata[index] + ome_channels.append( + { + "active": True, + "coefficient": 1, + "color": metadata.display_color, + "family": "linear", + "inverted": False, + "label": metadata.channel_name, + "wavelength_id": f"C{str(metadata.channel_index + 1).zfill(2)}", + "window": { + "min": np.iinfo(np.uint16).min, + "max": np.iinfo(np.uint16).max, + "start": np.iinfo(np.uint16).min, + "end": np.iinfo(np.uint16).max, + }, + } + ) + elif index < max_channel: + ome_channels.append( + { + "active": False, + "coefficient": 1, + "color": "#000000", + "family": "linear", + "inverted": False, + "label": "empty", + "wavelength_id": f"C{str(index + 1).zfill(2)}", + "window": { + "min": np.iinfo(np.uint16).min, + "max": np.iinfo(np.uint16).max, + "start": np.iinfo(np.uint16).min, + "end": np.iinfo(np.uint16).max, + }, + } + ) + + return ome_channels + + def get_common_well_shape(self) -> tuple[int, int, int, int, int]: + """ + Compute the maximum well extent such that each well is covered. + + Returns + ------- + (time, channel, z, y, x) + """ + well_shapes = [] + for well in self.get_well_acquisitions(): + well_shapes.append(well.get_shape()) + + return tuple(np.max(well_shapes, axis=0)) + + +class WellAcquisition(ABC): + """ + A single well of a plate acquisition. + """ + + name: str = None + _files = None + _alignment: TileAlignmentOptions = None + _background_correction_matrices: Optional[dict[str, Union[Path, str]]] + _illumincation_correction_matrices: Optional[dict[str, Union[Path, str]]] + _tiles = None + + def __init__( + self, + files: pd.DataFrame, + alignment: TileAlignmentOptions, + background_correction_matrices: Optional[dict[str, Union[Path, str]]], + illumination_correction_matrices: Optional[dict[str, Union[Path, str]]], + ) -> None: + assert ( + files["well"].nunique() == 1 + ), "WellAcquisition must contain files from a single well." + self.name = files["well"].iloc[0] + self._files = files + self._alignment = alignment + self._background_correction_matrices = background_correction_matrices + self._illumincation_correction_matrices = illumination_correction_matrices + self._tiles = self._align_tiles(tiles=self._assemble_tiles()) + super().__init__() + + @abstractmethod + def _assemble_tiles(self) -> list[Tile]: + """Parse all tiles in the well.""" + raise NotImplementedError() + + def get_dtype(self) -> np.dtype: + """ + Get the data type of the well acquisition. + + Returns + ------- + type + """ + return self._tiles[0].load_data().dtype + + def _align_tiles(self, tiles: list[Tile]) -> list[Tile]: + if self._alignment == TileAlignmentOptions.STAGE_POSITION: + from faim_hcs.alignment import StageAlignment + + return StageAlignment(tiles=tiles).get_tiles() + + if self._alignment == TileAlignmentOptions.GRID: + from faim_hcs.alignment import GridAlignment + + return GridAlignment(tiles=tiles).get_tiles() + + raise ValueError(f"Unknown alignment option: {self._alignment}") + + def get_tiles(self) -> list[Tile]: + """List of tiles.""" + return self._tiles + + def get_row_col(self) -> tuple[str, str]: + """ + Get the row and column of the well acquisition. + + Returns + ------- + row, column + """ + return self.name[0], self.name[1:] + + @abstractmethod + def get_axes(self) -> list[str]: + """ + Get the axes of the well acquisition. + """ + raise NotImplementedError() + + @abstractmethod + def get_yx_spacing(self) -> tuple[float, float]: + """ + Get the yx spacing of the well acquisition. + """ + raise NotImplementedError() + + @abstractmethod + def get_z_spacing(self) -> Optional[float]: + """ + Get the z spacing of the well acquisition. + """ + raise NotImplementedError() + + def get_coordinate_transformations( + self, max_layer: int, yx_binning: int + ) -> list[dict[str, Any]]: + """ + Get the NGFF conform coordinate transformations for the well + acquisition. + + Parameters + ---------- + max_layer : Maximum layer of the resolution pyramid. + yx_binning : Bin factor of the yx resolution. + + Returns + ------- + List of coordinate transformations. + """ + transformations = [] + for s in range(max_layer + 1): + if self.get_z_spacing() is not None: + transformations.append( + [ + { + "scale": [ + 1.0, + self.get_z_spacing(), + self.get_yx_spacing()[0] * yx_binning * 2**s, + self.get_yx_spacing()[1] * yx_binning * 2**s, + ], + "type": "scale", + } + ] + ) + else: + transformations.append( + [ + { + "scale": [ + 1.0, + self.get_yx_spacing()[0] * yx_binning * 2**s, + self.get_yx_spacing()[1] * yx_binning * 2**s, + ], + "type": "scale", + } + ] + ) + + return transformations + + def get_shape(self): + """ + Compute the theoretical shape of the stitched well image. + """ + tile_extents = [] + for tile in self._tiles: + tile_extents.append(tile.get_position() + np.array((1, 1, 1) + tile.shape)) + return tuple(np.max(tile_extents, axis=0)) diff --git a/src/faim_hcs/hcs/cellvoyager/CellVoyagerWellAcquisition.py b/src/faim_hcs/hcs/cellvoyager/CellVoyagerWellAcquisition.py new file mode 100644 index 00000000..591e838c --- /dev/null +++ b/src/faim_hcs/hcs/cellvoyager/CellVoyagerWellAcquisition.py @@ -0,0 +1,89 @@ +from pathlib import Path +from typing import Optional, Union + +import pandas as pd + +from faim_hcs.hcs.acquisition import TileAlignmentOptions, WellAcquisition +from faim_hcs.stitching import Tile +from faim_hcs.stitching.Tile import TilePosition + + +class CellVoyagerWellAcquisition(WellAcquisition): + """ + Data structure for a CellVoyager well acquisition. + """ + + def __init__( + self, + files: pd.DataFrame, + alignment: TileAlignmentOptions, + metadata: pd.DataFrame, + z_spacing: Optional[float], + background_correction_matrices: dict[str, Union[Path, str]] = None, + illumination_correction_matrices: dict[str, Union[Path, str]] = None, + ): + self._metadata = metadata + self._z_spacing = z_spacing + super().__init__( + files=files, + alignment=alignment, + background_correction_matrices=background_correction_matrices, + illumination_correction_matrices=illumination_correction_matrices, + ) + + def _assemble_tiles(self) -> list[Tile]: + tiles = [] + for i, row in self._files.iterrows(): + file = row["path"] + time_point = row["TimePoint"] + channel = row["Ch"] + z = row["ZIndex"] + + ch_metadata = self._metadata[self._metadata["Ch"] == channel].iloc[0] + shape = ( + int(ch_metadata["VerticalPixels"]), + int(ch_metadata["HorizontalPixels"]), + ) + + yx_spacing = self.get_yx_spacing() + + bgcm = None + if self._background_correction_matrices is not None: + bgcm = self._background_correction_matrices[str(channel)] + + icm = None + if self._illumincation_correction_matrices is not None: + icm = self._illumincation_correction_matrices[str(channel)] + + tiles.append( + Tile( + path=file, + shape=shape, + position=TilePosition( + time=time_point, + channel=int(channel), + z=z, + y=int(float(row["Y"]) / yx_spacing[0]), + x=int(float(row["X"]) / yx_spacing[1]), + ), + background_correction_matrix_path=bgcm, + illumination_correction_matrix_path=icm, + ) + ) + return tiles + + def get_axes(self) -> list[str]: + if self._z_spacing is not None: + return ["c", "z", "y", "x"] + else: + return ["c", "y", "x"] + + def get_yx_spacing(self) -> tuple[float, float]: + ch_metadata = self._metadata.iloc[0] + return ( + float(ch_metadata["VerticalPixelDimension"]), + float(ch_metadata["HorizontalPixelDimension"]), + ) + + def get_z_spacing(self) -> Optional[float]: + return self._z_spacing diff --git a/src/faim_hcs/hcs/cellvoyager/StackAcquisition.py b/src/faim_hcs/hcs/cellvoyager/StackAcquisition.py new file mode 100644 index 00000000..d0aa0f7f --- /dev/null +++ b/src/faim_hcs/hcs/cellvoyager/StackAcquisition.py @@ -0,0 +1,151 @@ +from decimal import Decimal +from os.path import exists, join +from pathlib import Path +from typing import Optional, Union +from xml.etree import ElementTree as ET + +import numpy as np +import pandas as pd + +from faim_hcs.hcs.acquisition import ( + PlateAcquisition, + TileAlignmentOptions, + WellAcquisition, +) +from faim_hcs.hcs.cellvoyager.CellVoyagerWellAcquisition import ( + CellVoyagerWellAcquisition, +) +from faim_hcs.io.ChannelMetadata import ChannelMetadata + +BTS_NS = "{http://www.yokogawa.co.jp/BTS/BTSSchema/1.0}" + + +class StackAcquisition(PlateAcquisition): + def __init__( + self, + acquisition_dir: Union[Path, str], + alignment: TileAlignmentOptions, + background_correction_matrices: Optional[dict[str, Union[Path, str]]] = None, + illumination_correction_matrices: Optional[dict[str, Union[Path, str]]] = None, + ): + super().__init__( + acquisition_dir=acquisition_dir, + alignment=alignment, + background_correction_matrices=background_correction_matrices, + illumination_correction_matrices=illumination_correction_matrices, + ) + self._z_spacing = self._compute_z_spacing() + + def get_channel_metadata(self) -> dict[int, ChannelMetadata]: + metadata = self._parse_metadata() + ch_metadata = {} + + for i, row in metadata.iterrows(): + index = int(row["Ch"]) - 1 + ch_metadata[index] = ChannelMetadata( + channel_index=index, + channel_name=row["Ch"], + display_color=row["Color"], + spatial_calibration_x=row["HorizontalPixelDimension"], + spatial_calibration_y=row["VerticalPixelDimension"], + spatial_calibration_units="um", + z_spacing=self._z_spacing, + wavelength=row["Target"], + exposure_time=row["ExposureTime"], + exposure_time_unit="ms", + objective=row["Objective"], + ) + + assert min(ch_metadata.keys()) == 0, "Channel indices must start at 0." + + return ch_metadata + + def _compute_z_spacing(self) -> Optional[float]: + z_steps = np.array( + [float(i) for i in self._files.groupby("Z").mean("ZIndex").index] + ) + + precision = -Decimal(str(z_steps[0])).as_tuple().exponent + z_step = np.round(np.mean(np.diff(z_steps)), decimals=precision) + return z_step + + def get_well_acquisitions(self) -> list[WellAcquisition]: + wells = [] + for well in self._files["well"].unique(): + wells.append( + CellVoyagerWellAcquisition( + files=self._files[self._files["well"] == well], + alignment=self._alignment, + metadata=self._parse_metadata(), + z_spacing=self._z_spacing, + background_correction_matrices=self._background_correction_matrices, + illumination_correction_matrices=self._illumination_correction_matrices, + ) + ) + + return wells + + def _parse_metadata(self) -> pd.DataFrame: + mrf_file = join(self._acquisition_dir, "MeasurementDetail.mrf") + if not exists(mrf_file): + raise ValueError( + f"MeasurementDetail.mrf not found in: {self._acquisition_dir}" + ) + mrf_tree = ET.parse(mrf_file) + mrf_root = mrf_tree.getroot() + + channels = [] + for channel in mrf_root.findall(BTS_NS + "MeasurementChannel"): + row = { + key.replace(BTS_NS, ""): value for key, value in channel.attrib.items() + } + channels.append(row) + + mes_file = join( + self._acquisition_dir, + mrf_root.attrib[BTS_NS + "MeasurementSettingFileName"], + ) + if not exists(mes_file): + raise ValueError(f"Settings file not found: {mes_file}") + mes_tree = ET.parse(mes_file) + mes_root = mes_tree.getroot() + + channel_settings = [] + for channel in mes_root.find(BTS_NS + "ChannelList").findall( + BTS_NS + "Channel" + ): + row = { + key.replace(BTS_NS, ""): value for key, value in channel.attrib.items() + } + channel_settings.append(row) + + return pd.merge( + pd.DataFrame(channels), + pd.DataFrame(channel_settings), + left_on="Ch", + right_on="Ch", + ) + + def _parse_files(self) -> pd.DataFrame: + mlf_file = join(self._acquisition_dir, "MeasurementData.mlf") + if not exists(mlf_file): + raise ValueError( + f"MeasurementData.mlf not found in: {self._acquisition_dir}" + ) + mlf_tree = ET.parse(mlf_file) + mlf_root = mlf_tree.getroot() + + files = [] + for record in mlf_root.findall(BTS_NS + "MeasurementRecord"): + row = { + key.replace(BTS_NS, ""): value for key, value in record.attrib.items() + } + if row.pop("Type") == "IMG": + row |= { + "path": join(self._acquisition_dir, record.text), + "well": chr(ord("@") + int(row.pop("Row"))) + + row.pop("Column").zfill(2), + } + files.append(row) + + return pd.DataFrame(files) diff --git a/src/faim_hcs/hcs/cellvoyager/__init__.py b/src/faim_hcs/hcs/cellvoyager/__init__.py new file mode 100644 index 00000000..82ba14c4 --- /dev/null +++ b/src/faim_hcs/hcs/cellvoyager/__init__.py @@ -0,0 +1 @@ +from .StackAcquisition import StackAcquisition # noqa: F401 diff --git a/src/faim_hcs/hcs/converter.py b/src/faim_hcs/hcs/converter.py new file mode 100644 index 00000000..255eb850 --- /dev/null +++ b/src/faim_hcs/hcs/converter.py @@ -0,0 +1,256 @@ +import os +from os.path import join +from pathlib import Path +from typing import Callable, Union + +import dask.array as da +import numpy as np +import zarr +from numcodecs import Blosc +from ome_zarr.io import parse_url +from ome_zarr.scale import Scaler +from ome_zarr.writer import write_image, write_plate_metadata, write_well_metadata +from pydantic import BaseModel +from tqdm import tqdm + +from faim_hcs.hcs.acquisition import PlateAcquisition +from faim_hcs.hcs.plate import PlateLayout, get_rows_and_columns +from faim_hcs.stitching import stitching_utils + + +class NGFFPlate(BaseModel): + root_dir: Union[Path, str] + name: str + layout: PlateLayout + order_name: str + barcode: str + + +class ConvertToNGFFPlate: + """ + Convert a plate acquisition to an NGFF plate. + """ + + _ngff_plate: NGFFPlate + + def __init__( + self, + ngff_plate: NGFFPlate, + yx_binning: int = 1, + dask_chunk_size_factor: int = 2, + warp_func: Callable = stitching_utils.translate_tiles_2d, + fuse_func: Callable = stitching_utils.fuse_mean, + ): + """ + Parameters + ---------- + ngff_plate : + NGFF plate information. + yx_binning : + YX binning factor. + dask_chunk_size_factor : + Dask chunk size factor. Increasing this will increase the memory + usage. + warp_func : + Function used to warp tile images. + fuse_func : + Function used to fuse tile images. + """ + assert ( + isinstance(yx_binning, int) and yx_binning >= 1 + ), "yx_binning must be an integer >= 1." + assert ( + isinstance(dask_chunk_size_factor, int) and dask_chunk_size_factor >= 1 + ), "dask_chunk_size_factor must be an integer >= 1." + self._ngff_plate = ngff_plate + self._yx_binning = yx_binning + self._dask_chunk_size_factor = dask_chunk_size_factor + self._warp_func = warp_func + self._fuse_func = fuse_func + + def _create_zarr_plate(self, plate_acquisition: PlateAcquisition) -> zarr.Group: + plate_path = join(self._ngff_plate.root_dir, self._ngff_plate.name + ".zarr") + if not os.path.exists(plate_path): + os.makedirs(plate_path, exist_ok=False) + store = parse_url(plate_path, mode="w").store + plate = zarr.group(store=store) + + rows, cols = get_rows_and_columns(layout=self._ngff_plate.layout) + + write_plate_metadata( + plate, + columns=cols, + rows=rows, + wells=[f"{w[0]}/{w[1:]}" for w in plate_acquisition.get_well_names()], + name=self._ngff_plate.name, + field_count=1, + ) + + attrs = plate.attrs.asdict() + attrs["order_name"] = self._ngff_plate.order_name + attrs["barcode"] = self._ngff_plate.barcode + plate.attrs.put(attrs) + return plate + else: + store = parse_url(plate_path, mode="w").store + return zarr.group(store=store) + + def run( + self, + plate_acquisition: PlateAcquisition, + well_sub_group: str = "0", + chunks: Union[tuple[int, int], tuple[int, int, int]] = (2048, 2048), + max_layer: int = 3, + storage_options: dict = None, + ) -> zarr.Group: + """ + Convert a plate acquisition to an NGFF plate. + + Parameters + ---------- + plate_acquisition : + A single plate acquisition. + well_sub_group : + Name of the well sub-group. + chunks : + Chunk size in (Z)YX. + max_layer : + Maximum layer of the resolution pyramid layers. + storage_options : + Zarr storage options. + + Returns + ------- + zarr.Group of the plate. + """ + assert 2 <= len(chunks) <= 3, "Chunks must be 2D or 3D." + plate = self._create_zarr_plate(plate_acquisition) + for well_acquisition in tqdm(plate_acquisition.get_well_acquisitions()): + well_group = self._create_well_group( + plate, + well_acquisition, + well_sub_group, + ) + + stitched_well_da = self._stitch_well_image( + chunks, + well_acquisition, + output_shape=plate_acquisition.get_common_well_shape(), + ) + + output_da = self._bin_yx(stitched_well_da) + + output_da = output_da.squeeze() + + write_image( + image=output_da, + group=well_group[well_sub_group], + axes=well_acquisition.get_axes(), + storage_options=self._get_storage_options( + storage_options, output_da.shape, chunks + ), + scaler=Scaler(max_layer=max_layer), + coordinate_transformations=well_acquisition.get_coordinate_transformations( + max_layer=max_layer, + yx_binning=self._yx_binning, + ), + ) + + well_group[well_sub_group].attrs["omero"] = { + "channels": plate_acquisition.get_omero_channel_metadata() + } + + well_group[well_sub_group].attrs["acquisition_metadata"] = { + "channels": [ + ch_metadata.dict() + for ch_metadata in plate_acquisition.get_channel_metadata().values() + ] + } + return plate + + def _bin_yx(self, image_da): + if self._yx_binning > 1: + return da.coarsen( + reduction=self._mean_cast_to(image_da.dtype), + x=image_da, + axes={ + 0: 1, + 1: 1, + 2: 1, + 3: self._yx_binning, + 4: self._yx_binning, + }, + trim_excess=True, + ) + else: + return image_da + + def _stitch_well_image( + self, + chunks, + well_acquisition, + output_shape: tuple[int, int, int, int, int], + ): + from faim_hcs.stitching import DaskTileStitcher + + stitcher = DaskTileStitcher( + tiles=well_acquisition.get_tiles(), + yx_chunk_shape=( + chunks[-2] * self._dask_chunk_size_factor, + chunks[-1] * self._dask_chunk_size_factor, + ), + output_shape=output_shape, + dtype=well_acquisition.get_dtype(), + ) + image_da = stitcher.get_stitched_dask_array( + warp_func=self._warp_func, + fuse_func=self._fuse_func, + ) + return image_da + + def _create_well_group(self, plate, well_acquisition, well_sub_group): + row, col = well_acquisition.get_row_col() + well_group = plate.require_group(row).require_group(col) + well_group.require_group(well_sub_group) + write_well_metadata(well_group, [{"path": well_sub_group}]) + return well_group + + @staticmethod + def _mean_cast_to(target_dtype): + def _mean( + a, + axis=None, + dtype=None, + out=None, + keepdims=np._NoValue, + *, + where=np._NoValue, + ): + return np.mean( + a=a, axis=axis, dtype=dtype, out=out, keepdims=keepdims, where=where + ).astype(target_dtype) + + return _mean + + @staticmethod + def _get_storage_options( + storage_options: dict, + output_shape: tuple[int, ...], + chunks: tuple[int, ...], + ): + if storage_options is None: + return dict( + dimension_separator="/", + compressor=Blosc(cname="zstd", clevel=6, shuffle=Blosc.BITSHUFFLE), + chunks=ConvertToNGFFPlate._out_chunks(output_shape, chunks), + write_empty_chunks=False, + ) + else: + return storage_options + + @staticmethod + def _out_chunks(shape, chunks): + if len(shape) == len(chunks): + return chunks + else: + return (1,) * (len(shape) - len(chunks)) + chunks diff --git a/src/faim_hcs/hcs/imagexpress/ImageXpressPlateAcquisition.py b/src/faim_hcs/hcs/imagexpress/ImageXpressPlateAcquisition.py new file mode 100644 index 00000000..0b958e91 --- /dev/null +++ b/src/faim_hcs/hcs/imagexpress/ImageXpressPlateAcquisition.py @@ -0,0 +1,127 @@ +import os +import re +from abc import abstractmethod +from pathlib import Path +from typing import Optional, Union + +import pandas as pd + +from faim_hcs.hcs.acquisition import ( + PlateAcquisition, + TileAlignmentOptions, + WellAcquisition, +) +from faim_hcs.hcs.imagexpress.ImageXpressWellAcquisition import ( + ImageXpressWellAcquisition, +) +from faim_hcs.io.ChannelMetadata import ChannelMetadata +from faim_hcs.io.MetaSeriesTiff import load_metaseries_tiff_metadata +from faim_hcs.utils import rgb_to_hex, wavelength_to_rgb + + +class ImageXpressPlateAcquisition(PlateAcquisition): + def __init__( + self, + acquisition_dir: Union[Path, str], + alignment: TileAlignmentOptions, + background_correction_matrices: Optional[dict[str, Union[Path, str]]] = None, + illumination_correction_matrices: Optional[dict[str, Union[Path, str]]] = None, + ): + super().__init__( + acquisition_dir=acquisition_dir, + alignment=alignment, + background_correction_matrices=background_correction_matrices, + illumination_correction_matrices=illumination_correction_matrices, + ) + + def _parse_files(self) -> pd.DataFrame: + """Parse all files in the acquisition directory. + + Returns + ------- + DataFrame + Table of all files in the acquisition. + """ + return pd.DataFrame( + ImageXpressPlateAcquisition._list_and_match_files( + root_dir=self._acquisition_dir, + root_re=self._get_root_re(), + filename_re=self._get_filename_re(), + ) + ) + + @staticmethod + def _list_and_match_files( + root_dir: Union[Path, str], + root_re: re.Pattern, + filename_re: re.Pattern, + ) -> list[str]: + files = [] + for root, _, filenames in os.walk(root_dir): + m_root = root_re.fullmatch(root) + if m_root: + for f in filenames: + m_filename = filename_re.fullmatch(f) + if m_filename: + row = m_root.groupdict() + row |= m_filename.groupdict() + row["path"] = str(Path(root).joinpath(f)) + files.append(row) + return files + + @abstractmethod + def _get_root_re(self) -> re.Pattern: + """Regular expression for matching the root directory of the acquisition.""" + raise NotImplementedError() + + @abstractmethod + def _get_filename_re(self) -> re.Pattern: + """Regular expression for matching the filename of the acquisition.""" + raise NotImplementedError() + + def get_well_acquisitions(self) -> list[WellAcquisition]: + return [ + ImageXpressWellAcquisition( + files=self._files[self._files["well"] == well], + alignment=self._alignment, + z_spacing=self._get_z_spacing(), + background_correction_matrices=self._background_correction_matrices, + illumination_correction_matrices=self._illumination_correction_matrices, + ) + for well in self._files["well"].unique() + ] + + @abstractmethod + def _get_z_spacing(self) -> Optional[float]: + raise NotImplementedError() + + def get_channel_metadata(self) -> dict[int, ChannelMetadata]: + ch_metadata = {} + for ch in self._files["channel"].unique(): + channel_files = self._files[self._files["channel"] == ch] + path = channel_files["path"].iloc[0] + metadata = load_metaseries_tiff_metadata(path=path) + index = int(ch[1:]) - 1 + if "Z Projection Method" in metadata.keys(): + name = ( + f"{metadata['Z Projection Method'].replace(' ', '-')}-Projection_" + f"{metadata['_IllumSetting_']}" + ) + else: + name = metadata["_IllumSetting_"] + ch_metadata[index] = ChannelMetadata( + channel_index=index, + channel_name=name, + display_color=rgb_to_hex(*wavelength_to_rgb(metadata["wavelength"])), + spatial_calibration_x=metadata["spatial-calibration-x"], + spatial_calibration_y=metadata["spatial-calibration-y"], + spatial_calibration_units=metadata["spatial-calibration-units"], + z_spacing=self._get_z_spacing(), + wavelength=metadata["wavelength"], + exposure_time=float(metadata["Exposure Time"].split(" ")[0]), + exposure_time_unit=metadata["Exposure Time"].split(" ")[1], + objective=metadata["_MagSetting_"], + ) + + assert min(ch_metadata.keys()) == 0, "Channel indices must start at 0." + return ch_metadata diff --git a/src/faim_hcs/hcs/imagexpress/ImageXpressWellAcquisition.py b/src/faim_hcs/hcs/imagexpress/ImageXpressWellAcquisition.py new file mode 100644 index 00000000..f841c23f --- /dev/null +++ b/src/faim_hcs/hcs/imagexpress/ImageXpressWellAcquisition.py @@ -0,0 +1,83 @@ +from pathlib import Path +from typing import Optional, Union + +import pandas as pd + +from faim_hcs.hcs.acquisition import TileAlignmentOptions, WellAcquisition +from faim_hcs.io.MetaSeriesTiff import load_metaseries_tiff_metadata +from faim_hcs.stitching import Tile +from faim_hcs.stitching.Tile import TilePosition + + +class ImageXpressWellAcquisition(WellAcquisition): + def __init__( + self, + files: pd.DataFrame, + alignment: TileAlignmentOptions, + z_spacing: Optional[float], + background_correction_matrices: dict[str, Union[Path, str]] = None, + illumination_correction_matrices: dict[str, Union[Path, str]] = None, + ) -> None: + self._z_spacing = z_spacing + super().__init__( + files=files, + alignment=alignment, + background_correction_matrices=background_correction_matrices, + illumination_correction_matrices=illumination_correction_matrices, + ) + + def _assemble_tiles(self) -> list[Tile]: + tiles = [] + for i, row in self._files.iterrows(): + file = row["path"] + time_point = 0 + channel = row["channel"] + metadata = load_metaseries_tiff_metadata(file) + if self._z_spacing is None: + z = 0 + else: + z = int(metadata["stage-position-z"] / self._z_spacing) + + bgcm = None + if self._background_correction_matrices is not None: + bgcm = self._background_correction_matrices[channel] + + icm = None + if self._illumincation_correction_matrices is not None: + icm = self._illumincation_correction_matrices[channel] + + tiles.append( + Tile( + path=file, + shape=(metadata["pixel-size-y"], metadata["pixel-size-x"]), + position=TilePosition( + time=time_point, + channel=int(channel[1:]), + z=z, + y=int( + metadata["stage-position-y"] + / metadata["spatial-calibration-y"] + ), + x=int( + metadata["stage-position-x"] + / metadata["spatial-calibration-x"] + ), + ), + background_correction_matrix_path=bgcm, + illumination_correction_matrix_path=icm, + ) + ) + return tiles + + def get_yx_spacing(self) -> tuple[float, float]: + metadata = load_metaseries_tiff_metadata(self._files.iloc[0]["path"]) + return (metadata["spatial-calibration-y"], metadata["spatial-calibration-x"]) + + def get_z_spacing(self) -> Optional[float]: + return self._z_spacing + + def get_axes(self) -> list[str]: + if "z" in self._files.columns: + return ["c", "z", "y", "x"] + else: + return ["c", "y", "x"] diff --git a/src/faim_hcs/hcs/imagexpress/MixedAcquisition.py b/src/faim_hcs/hcs/imagexpress/MixedAcquisition.py new file mode 100644 index 00000000..58e42529 --- /dev/null +++ b/src/faim_hcs/hcs/imagexpress/MixedAcquisition.py @@ -0,0 +1,80 @@ +import re +from pathlib import Path +from typing import Optional, Union + +from numpy._typing import NDArray + +from faim_hcs.hcs.acquisition import TileAlignmentOptions +from faim_hcs.hcs.imagexpress import StackAcquisition + + +class MixedAcquisition(StackAcquisition): + """Image stack acquisition with Projectsion acquired with a Molecular + Devices ImageXpress Micro Confocal system. + + MIP-2P-2sub-Stack --> {name} [Optional] + └── 2023-02-21 --> {date} + └── 1334 --> {acquisition id} + ├── Projection-Mix_E07_s1_w1E94C24BD-45E4-450A-9919-257C714278F7.tif + ├── Projection-Mix_E07_s1_w1_thumb4BFD4018-E675-475E-B5AB-2E959E6B6DA1.tif + ├── ... + ├── Projection-Mix_E08_s2_w3CCE83D85-0912-429E-9F18-716A085BB5BC.tif + ├── Projection-Mix_E08_s2_w3_thumb4D88636E-181E-4AF6-BC53-E7A435959C8F.tif + ├── ZStep_1 + │   ├── Projection-Mix_E07_s1_w1E78EB128-BD0D-4D94-A6AD-3FF28BB1B105.tif + │   ├── Projection-Mix_E07_s1_w1_thumb187DE64B-038A-4671-BF6B-683721723769.tif + │   ├── Projection-Mix_E07_s1_w2C0A49256-E289-4C0F-ADC9-F7728ABDB141.tif + │   ├── Projection-Mix_E07_s1_w2_thumb57D4B151-71BF-480E-8CC4-C23A2690B763.tif + │   ├── Projection-Mix_E07_s1_w427CCB2E4-1BF4-45E7-8BC7-264B48EF9C4A.tif + │   ├── Projection-Mix_E07_s1_w4_thumb555647D0-77F1-4A43-9472-AE509F95E236.tif + │   ├── ... + │   └── Projection-Mix_E08_s2_w4_thumbD2785594-4F49-464F-9F80-1B82E30A560A.tif + ├── ... + └── ZStep_9 + ├── Projection-Mix_E07_s1_w1091EB8A5-272A-466D-B8A0-7547C6BA392B.tif + ├── ... + └── Projection-Mix_E08_s2_w2_thumb210C0D5D-C20E-484D-AFB2-EFE669A56B84.tif + + Image data is stored in {name}_{well}_{field}_w{channel}{md_id}.tif. + The *_thumb*.tif files, used by Molecular Devices as preview, are ignored. + """ + + def __init__( + self, + acquisition_dir: Union[Path, str], + alignment: TileAlignmentOptions, + background_correction_matrix: Optional[dict[str, NDArray]] = None, + illumination_correction_matrix: Optional[NDArray] = None, + ): + super().__init__( + acquisition_dir=acquisition_dir, + alignment=alignment, + background_correction_matrices=background_correction_matrix, + illumination_correction_matrices=illumination_correction_matrix, + ) + self._filter_mips() + + def _get_root_re(self) -> re.Pattern: + return re.compile( + r".*[\/\\](?P\d{4}-\d{2}-\d{2})[\/\\](?P\d+)(?:[\/\\]ZStep_(?P\d+))?.*" + ) + + def _get_filename_re(self) -> re.Pattern: + return re.compile( + r"(?P.*)_(?P[A-Z]+\d{2})_(?Ps\d+)_(?Pw[1-9]{1})(?!_thumb)(?P.*)(?P.tif)" + ) + + def _filter_mips(self): + """Remove MIP files if the whole stack was acquired.""" + for ch in self._files["channel"].unique(): + channel_files = self._files[self._files["channel"] == ch] + z_positions = channel_files["z"].unique() + has_mip = None in z_positions + has_stack = len(z_positions) > 1 + if has_mip and has_stack: + self._files.drop( + self._files[ + (self._files["channel"] == ch) & (self._files["z"].isna()) + ].index, + inplace=True, + ) diff --git a/src/faim_hcs/hcs/imagexpress/SinglePlaneAcquisition.py b/src/faim_hcs/hcs/imagexpress/SinglePlaneAcquisition.py new file mode 100644 index 00000000..6b97b672 --- /dev/null +++ b/src/faim_hcs/hcs/imagexpress/SinglePlaneAcquisition.py @@ -0,0 +1,61 @@ +import re +from pathlib import Path +from typing import Optional, Union + +from faim_hcs.hcs.acquisition import TileAlignmentOptions +from faim_hcs.hcs.imagexpress import ImageXpressPlateAcquisition + + +class SinglePlaneAcquisition(ImageXpressPlateAcquisition): + """Parse top folder (single planes) of an acquisition of a MolecularDevices ImageXpress Micro Confocal system. + + Storage layout on disk for 2 wells with 2 fields and 2 channels:: + + MIP-2P-2sub --> {name} [Optional] + └── 2022-07-05 --> {date} + └── 1075 --> {acquisition id} + ├── MIP-2P-2sub_C05_s1_w146C9B2CD-0BB3-4B8A-9187-2805F4C90506.tif + ├── MIP-2P-2sub_C05_s1_w1_thumb6EFE77C6-B96D-412A-9FD1-710DBDA32821.tif + ├── MIP-2P-2sub_C05_s1_w2B90625C8-6EA7-4E54-8289-C539EB75263E.tif + ├── MIP-2P-2sub_C05_s1_w2_thumbEDDF803A-AE5E-4190-8C06-F54341AEC4A6.tif + ├── MIP-2P-2sub_C05_s2_w1E2913F7F-E229-4B6A-BFED-02BCF54561FA.tif + ├── MIP-2P-2sub_C05_s2_w1_thumb72E3641A-C91B-4501-900A-245BAC58FF46.tif + ├── MIP-2P-2sub_C05_s2_w241C38630-BCFD-4393-8706-58755CECE059.tif + ├── MIP-2P-2sub_C05_s2_w2_thumb5377A5AC-9BBF-4BAF-99A2-24896E3373A2.tif + ├── MIP-2P-2sub_C06_s1_w152C23B9A-EB4C-4DF6-8A7F-F4147A9E7DDE.tif + ├── MIP-2P-2sub_C06_s1_w1_thumb541AA634-387C-4B84-B0D8-EE4CB1C88E81.tif + ├── MIP-2P-2sub_C06_s1_w2FB0D7D9B-3EA0-445E-9A05-7D01154A9A5C.tif + ├── MIP-2P-2sub_C06_s1_w2_thumb8FA1E466-57CD-4237-B09B-CAB48154647D.tif + ├── MIP-2P-2sub_C06_s2_w1F365E60C-BCC2-4B74-9856-BCE07C8B0FD3.tif + ├── MIP-2P-2sub_C06_s2_w1_thumb9652366E-36A0-4B7F-8B18-DA89D7DB41BD.tif + ├── MIP-2P-2sub_C06_s2_w20EEC6AEA-1727-41E6-806C-40FF6AF68B6C.tif + └── MIP-2P-2sub_C06_s2_w2_thumb710CD846-0185-4362-BBAF-C700AE0013B3.tif + + Image data is stored in {name}_{well}_{field}_w{channel}{md_id}.tif. + The *_thumb*.tif files, used by Molecular Devices as preview, are ignored. + """ + + def __init__( + self, + acquisition_dir: Union[Path, str], + alignment: TileAlignmentOptions, + background_correction_matrices: Optional[dict[str, Union[Path, str]]] = None, + illumination_correction_matrices: Optional[dict[str, Union[Path, str]]] = None, + ): + super().__init__( + acquisition_dir=acquisition_dir, + alignment=alignment, + background_correction_matrices=background_correction_matrices, + illumination_correction_matrices=illumination_correction_matrices, + ) + + def _get_root_re(self) -> re.Pattern: + return re.compile(r".*[\/\\](?P\d{4}-\d{2}-\d{2})[\/\\](?P\d+)") + + def _get_filename_re(self) -> re.Pattern: + return re.compile( + r"(?P.*)_(?P[A-Z]+\d{2})_(?Ps\d+)_(?Pw[1-9]{1})(?!_thumb)(?P.*)(?P.tif)" + ) + + def _get_z_spacing(self) -> Optional[float]: + return None diff --git a/src/faim_hcs/hcs/imagexpress/StackAcquisition.py b/src/faim_hcs/hcs/imagexpress/StackAcquisition.py new file mode 100644 index 00000000..a3fe128c --- /dev/null +++ b/src/faim_hcs/hcs/imagexpress/StackAcquisition.py @@ -0,0 +1,102 @@ +import re +from decimal import Decimal +from pathlib import Path +from typing import Optional, Union + +import numpy as np + +from faim_hcs.hcs.acquisition import TileAlignmentOptions +from faim_hcs.hcs.imagexpress import ImageXpressPlateAcquisition +from faim_hcs.io.MetaSeriesTiff import load_metaseries_tiff_metadata + + +class StackAcquisition(ImageXpressPlateAcquisition): + """Image stack acquisition with a Molecular Devices ImageXpress Micro + Confocal system. + + MIP-2P-2sub-Stack --> {name} [Optional] + └── 2023-02-21 --> {date} + └── 1334 --> {acquisition id} + ├── ZStep_1 + │   ├── Projection-Mix_E07_s1_w1E78EB128-BD0D-4D94-A6AD-3FF28BB1B105.tif + │   ├── Projection-Mix_E07_s1_w1_thumb187DE64B-038A-4671-BF6B-683721723769.tif + │   ├── Projection-Mix_E07_s1_w2C0A49256-E289-4C0F-ADC9-F7728ABDB141.tif + │   ├── Projection-Mix_E07_s1_w2_thumb57D4B151-71BF-480E-8CC4-C23A2690B763.tif + │   ├── Projection-Mix_E07_s1_w427CCB2E4-1BF4-45E7-8BC7-264B48EF9C4A.tif + │   ├── Projection-Mix_E07_s1_w4_thumb555647D0-77F1-4A43-9472-AE509F95E236.tif + │   ├── ... + │   └── Projection-Mix_E08_s2_w4_thumbD2785594-4F49-464F-9F80-1B82E30A560A.tif + ├── ... + └── ZStep_9 + ├── Projection-Mix_E07_s1_w1091EB8A5-272A-466D-B8A0-7547C6BA392B.tif + ├── ... + └── Projection-Mix_E08_s2_w2_thumb210C0D5D-C20E-484D-AFB2-EFE669A56B84.tif + + Image data is stored in {name}_{well}_{field}_w{channel}{md_id}.tif. + The *_thumb*.tif files, used by Molecular Devices as preview, are ignored. + """ + + def __init__( + self, + acquisition_dir: Union[Path, str], + alignment: TileAlignmentOptions, + background_correction_matrices: Optional[dict[str, Union[Path, str]]] = None, + illumination_correction_matrices: Optional[dict[str, Union[Path, str]]] = None, + ): + super().__init__( + acquisition_dir=acquisition_dir, + alignment=alignment, + background_correction_matrices=background_correction_matrices, + illumination_correction_matrices=illumination_correction_matrices, + ) + self._z_spacing = self._compute_z_spacing() + + def _get_root_re(self) -> re.Pattern: + return re.compile( + r".*[\/\\](?P\d{4}-\d{2}-\d{2})[\/\\](?P\d+)(?:[\/\\]ZStep_(?P\d+))" + ) + + def _get_filename_re(self) -> re.Pattern: + return re.compile( + r"(?P.*)_(?P[A-Z]+\d{2})_(?Ps\d+)_(?Pw[1-9]{1})(?!_thumb)(?P.*)(?P.tif)" + ) + + def _get_z_spacing(self) -> Optional[float]: + return self._z_spacing + + def _compute_z_spacing( + self, + ) -> Optional[float]: + if "z" in self._files.columns: + channels_with_stack = self._files[self._files["z"] == "2"][ + "channel" + ].unique() + else: + return None + + plane_positions = {} + + for i, row in self._files[ + self._files["channel"].isin(channels_with_stack) + ].iterrows(): + file = row["path"] + if "z" in row.keys() and row["z"] is not None: + z = int(row["z"]) + metadata = load_metaseries_tiff_metadata(file) + z_position = metadata["stage-position-z"] + if z in plane_positions.keys(): + plane_positions[z].append(z_position) + else: + plane_positions[z] = [z_position] + + if len(plane_positions) > 1: + plane_positions = dict(sorted(plane_positions.items())) + average_z_positions = [] + for z, positions in plane_positions.items(): + average_z_positions.append(np.mean(positions)) + + precision = -Decimal(str(plane_positions[1][0])).as_tuple().exponent + z_step = np.round(np.mean(np.diff(average_z_positions)), decimals=precision) + return z_step + else: + return None diff --git a/src/faim_hcs/hcs/imagexpress/__init__.py b/src/faim_hcs/hcs/imagexpress/__init__.py new file mode 100644 index 00000000..e5f00530 --- /dev/null +++ b/src/faim_hcs/hcs/imagexpress/__init__.py @@ -0,0 +1,5 @@ +from .ImageXpressPlateAcquisition import ImageXpressPlateAcquisition # noqa: F401 +from .ImageXpressWellAcquisition import ImageXpressWellAcquisition # noqa: F401 +from .SinglePlaneAcquisition import SinglePlaneAcquisition # noqa: F401 +from .StackAcquisition import StackAcquisition # noqa: F401 +from .MixedAcquisition import MixedAcquisition # noqa: F401 diff --git a/src/faim_hcs/hcs/plate.py b/src/faim_hcs/hcs/plate.py new file mode 100644 index 00000000..c579f822 --- /dev/null +++ b/src/faim_hcs/hcs/plate.py @@ -0,0 +1,54 @@ +from enum import IntEnum +from typing import Union + + +class PlateLayout(IntEnum): + """Plate layout, 18, 24, 96 or 384-well.""" + + I18 = 18 + I24 = 24 + I96 = 96 + I384 = 384 + + +def get_rows_and_columns( + layout: Union[PlateLayout, int] +) -> tuple[list[str], list[str]]: + """Return rows and columns for requested layout.""" + if layout == PlateLayout.I18: + rows = ["A", "B", "C"] + cols = [str(i).zfill(2) for i in range(1, 7)] + assert len(rows) * len(cols) == 18 + elif layout == PlateLayout.I24: + rows = ["A", "B", "C", "D"] + cols = [str(i).zfill(2) for i in range(1, 7)] + assert len(rows) * len(cols) == 24 + elif layout == PlateLayout.I96: + rows = ["A", "B", "C", "D", "E", "F", "G", "H"] + cols = [str(i).zfill(2) for i in range(1, 13)] + assert len(rows) * len(cols) == 96 + elif layout == PlateLayout.I384: + rows = [ + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + ] + cols = [str(i).zfill(2) for i in range(1, 25)] + assert len(rows) * len(cols) == 384 + else: + raise NotImplementedError(f"{layout} layout not supported.") + + return rows, cols diff --git a/src/faim_hcs/io/ChannelMetadata.py b/src/faim_hcs/io/ChannelMetadata.py new file mode 100644 index 00000000..5d80dce3 --- /dev/null +++ b/src/faim_hcs/io/ChannelMetadata.py @@ -0,0 +1,17 @@ +from typing import Optional, Union + +from pydantic import BaseModel, NonNegativeInt, PositiveFloat, PositiveInt + + +class ChannelMetadata(BaseModel): + channel_index: NonNegativeInt + channel_name: str + display_color: str + spatial_calibration_x: float + spatial_calibration_y: float + spatial_calibration_units: str + z_spacing: Optional[PositiveFloat] + wavelength: Union[PositiveInt, str] + exposure_time: PositiveFloat + exposure_time_unit: str + objective: str diff --git a/src/faim_hcs/io/MetaSeriesTiff.py b/src/faim_hcs/io/MetaSeriesTiff.py index 7d452a05..5dedcf66 100644 --- a/src/faim_hcs/io/MetaSeriesTiff.py +++ b/src/faim_hcs/io/MetaSeriesTiff.py @@ -4,18 +4,20 @@ from tifffile import tifffile -def load_metaseries_tiff(path: Path) -> tuple[ArrayLike, dict]: - """Load metaseries tiff file and parts of its metadata. +def load_metaseries_tiff_metadata(path: Path) -> tuple[ArrayLike, dict]: + """Load parts of the metadata of a metaseries tiff file. The following metadata is collected: + * pixel-size-x + * pixel-size-y * _IllumSetting_ * spatial-calibration-x * spatial-calibration-y * spatial-calibration-units - * stage-position-x - * stage-position-y + * ImageXpress Micro X + * ImageXpress Micro Y + * ImageXpress Micro Z * z-position - * PixelType * _MagNA_ * _MagSetting_ * Exposure Time @@ -38,15 +40,16 @@ def load_metaseries_tiff(path: Path) -> tuple[ArrayLike, dict]: """ with tifffile.TiffFile(path) as tiff: assert tiff.is_metaseries, f"{path} is not a metamorph file." - data = tiff.asarray() selected_keys = [ + "pixel-size-x", + "pixel-size-y", "_IllumSetting_", "spatial-calibration-x", "spatial-calibration-y", "spatial-calibration-units", - "stage-position-x", - "stage-position-y", - "z-position", + "ImageXpress Micro X", + "ImageXpress Micro Y", + "ImageXpress Micro Z", "_MagNA_", "_MagSetting_", "Exposure Time", @@ -65,6 +68,20 @@ def load_metaseries_tiff(path: Path) -> tuple[ArrayLike, dict]: for k in plane_info if k in selected_keys or k.endswith("Intensity") } - metadata["PixelType"] = str(data.dtype) + metadata["stage-position-x"] = metadata["ImageXpress Micro X"] + metadata["stage-position-y"] = metadata["ImageXpress Micro Y"] + metadata["stage-position-z"] = metadata["ImageXpress Micro Z"] + + metadata.pop("ImageXpress Micro X") + metadata.pop("ImageXpress Micro Y") + metadata.pop("ImageXpress Micro Z") + + return metadata + +def load_metaseries_tiff(path: Path) -> tuple[ArrayLike, dict]: + with tifffile.TiffFile(path) as tiff: + data = tiff.asarray() + metadata = load_metaseries_tiff_metadata(path=path) + metadata["PixelType"] = str(data.dtype) return data, metadata diff --git a/src/faim_hcs/io/MolecularDevicesImageXpress.py b/src/faim_hcs/io/MolecularDevicesImageXpress.py deleted file mode 100644 index f816d975..00000000 --- a/src/faim_hcs/io/MolecularDevicesImageXpress.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Methods to parse image files acquired with a MolecularDevices ImageXpress system.""" - -import os -import re -from pathlib import Path -from typing import Union - -import pandas as pd - -_METASERIES_FILENAME_PATTERN = r"(?P.*)_(?P[A-Z]+\d{2})_(?Ps\d+)_(?Pw[1-9]{1})(?!_thumb)(?P.*)(?P.tif)" -_METASERIES_FOLDER_PATTERN = r".*[\/\\](?P\d{4}-\d{2}-\d{2})[\/\\](?P\d+)(?:[\/\\]ZStep_(?P\d+))?.*" -_METASERIES_MAIN_FOLDER_PATTERN = ( - r".*[\/\\](?P\d{4}-\d{2}-\d{2})[\/\\](?P\d+)(?![\/\\]ZStep_.*)" -) -_METASERIES_ZSTEP_FOLDER_PATTERN = ( - r".*[\/\\](?P\d{4}-\d{2}-\d{2})[\/\\](?P\d+)[\/\\]ZStep_(?P\d+).*" -) - - -def parse_single_plane_multi_fields(acquisition_dir: Union[Path, str]) -> pd.DataFrame: - """Parse top folder (single planes) of an acquisition of a MolecularDevices ImageXpress Micro Confocal system. - - Storage layout on disk for 2 wells with 2 fields and 2 channels:: - - MIP-2P-2sub --> {name} [Optional] - └── 2022-07-05 --> {date} - └── 1075 --> {acquisition id} - ├── MIP-2P-2sub_C05_s1_w146C9B2CD-0BB3-4B8A-9187-2805F4C90506.tif - ├── MIP-2P-2sub_C05_s1_w1_thumb6EFE77C6-B96D-412A-9FD1-710DBDA32821.tif - ├── MIP-2P-2sub_C05_s1_w2B90625C8-6EA7-4E54-8289-C539EB75263E.tif - ├── MIP-2P-2sub_C05_s1_w2_thumbEDDF803A-AE5E-4190-8C06-F54341AEC4A6.tif - ├── MIP-2P-2sub_C05_s2_w1E2913F7F-E229-4B6A-BFED-02BCF54561FA.tif - ├── MIP-2P-2sub_C05_s2_w1_thumb72E3641A-C91B-4501-900A-245BAC58FF46.tif - ├── MIP-2P-2sub_C05_s2_w241C38630-BCFD-4393-8706-58755CECE059.tif - ├── MIP-2P-2sub_C05_s2_w2_thumb5377A5AC-9BBF-4BAF-99A2-24896E3373A2.tif - ├── MIP-2P-2sub_C06_s1_w152C23B9A-EB4C-4DF6-8A7F-F4147A9E7DDE.tif - ├── MIP-2P-2sub_C06_s1_w1_thumb541AA634-387C-4B84-B0D8-EE4CB1C88E81.tif - ├── MIP-2P-2sub_C06_s1_w2FB0D7D9B-3EA0-445E-9A05-7D01154A9A5C.tif - ├── MIP-2P-2sub_C06_s1_w2_thumb8FA1E466-57CD-4237-B09B-CAB48154647D.tif - ├── MIP-2P-2sub_C06_s2_w1F365E60C-BCC2-4B74-9856-BCE07C8B0FD3.tif - ├── MIP-2P-2sub_C06_s2_w1_thumb9652366E-36A0-4B7F-8B18-DA89D7DB41BD.tif - ├── MIP-2P-2sub_C06_s2_w20EEC6AEA-1727-41E6-806C-40FF6AF68B6C.tif - └── MIP-2P-2sub_C06_s2_w2_thumb710CD846-0185-4362-BBAF-C700AE0013B3.tif - - Image data is stored in {name}_{well}_{field}_w{channel}{md_id}.tif. - The *_thumb*.tif files, used by Molecular Devices as preview, are ignored. - - :param acquisition_dir: Path to acquisition directory. - :return: table of all acquired image data. - """ - return parse_files(acquisition_dir=acquisition_dir, mode="top-level") - - -def parse_multi_field_stacks(acquisition_dir: Union[Path, str]) -> pd.DataFrame: - """Parse ZStep folders of an acquisition of a Molecular Devices ImageXpress Micro Confocal system. - - Storage hierarchy on disk for 2 wells with 2 fields and 2 channels:: - - MIP-2P-2sub --> {name} [Optional] - └── 2022-07-05 --> {date} - └── 1075 --> {acquisition id} - ├── MIP-2P-2sub_C05_s1_w146C9B2CD-0BB3-4B8A-9187-2805F4C90506.tif - ├── MIP-2P-2sub_C05_s1_w1_thumb6EFE77C6-B96D-412A-9FD1-710DBDA32821.tif - ├── MIP-2P-2sub_C05_s1_w2B90625C8-6EA7-4E54-8289-C539EB75263E.tif - ├── MIP-2P-2sub_C05_s1_w2_thumbEDDF803A-AE5E-4190-8C06-F54341AEC4A6.tif - ├── MIP-2P-2sub_C05_s2_w1E2913F7F-E229-4B6A-BFED-02BCF54561FA.tif - ├── MIP-2P-2sub_C05_s2_w1_thumb72E3641A-C91B-4501-900A-245BAC58FF46.tif - ├── MIP-2P-2sub_C05_s2_w241C38630-BCFD-4393-8706-58755CECE059.tif - ├── MIP-2P-2sub_C05_s2_w2_thumb5377A5AC-9BBF-4BAF-99A2-24896E3373A2.tif - ├── MIP-2P-2sub_C06_s1_w152C23B9A-EB4C-4DF6-8A7F-F4147A9E7DDE.tif - ├── MIP-2P-2sub_C06_s1_w1_thumb541AA634-387C-4B84-B0D8-EE4CB1C88E81.tif - ├── MIP-2P-2sub_C06_s1_w2FB0D7D9B-3EA0-445E-9A05-7D01154A9A5C.tif - ├── MIP-2P-2sub_C06_s1_w2_thumb8FA1E466-57CD-4237-B09B-CAB48154647D.tif - ├── MIP-2P-2sub_C06_s2_w1F365E60C-BCC2-4B74-9856-BCE07C8B0FD3.tif - ├── MIP-2P-2sub_C06_s2_w1_thumb9652366E-36A0-4B7F-8B18-DA89D7DB41BD.tif - ├── MIP-2P-2sub_C06_s2_w20EEC6AEA-1727-41E6-806C-40FF6AF68B6C.tif - └── MIP-2P-2sub_C06_s2_w2_thumb710CD846-0185-4362-BBAF-C700AE0013B3.tif - - Image data is stored in {name}_{well}_{field}_w{channel}{md_id}.tif. - The *_thumb*.tif files, used by Molecular Devices as preview, are ignored. - - :param acquisition_dir: Path to acquisition directory. - :return: table of all acquired image data. - """ - return parse_files(acquisition_dir=acquisition_dir, mode="z-steps") - - -def parse_files(acquisition_dir: Union[Path, str], mode: str = "all") -> pd.DataFrame: - """Parse any multi-field acquisition of a Molecular Devices ImageXpress Micro Confocal system. - - Storage layout on disk:: - - Experiment --> {name} [Optional] - └── 2023-02-22 --> {date} - └── 1099 --> {acquisition id} - ├── ZStep_1 - TODO fix file tree - - Image data is stored in {name}_{well}_{field}_w{channel}{md_id}.tif. - The *_thumb*.tif files, used by Molecular Devices as preview, are ignored. - - :param acquisition_dir: Path to acquisition directory. - :param mode: whether to parse 'top-level' file only, 'z-steps' files only, or 'all' (default). - :return: table of all acquired image data. - """ - if mode == "top-level": - root_pattern = _METASERIES_MAIN_FOLDER_PATTERN - elif mode == "z-steps": - root_pattern = _METASERIES_ZSTEP_FOLDER_PATTERN - else: - root_pattern = _METASERIES_FOLDER_PATTERN - return pd.DataFrame( - _list_dataset_files( - root_dir=acquisition_dir, - root_re=re.compile(root_pattern), - filename_re=re.compile(_METASERIES_FILENAME_PATTERN), - ) - ) - - -def _list_dataset_files( - root_dir: Union[Path, str], root_re: re.Pattern, filename_re: re.Pattern -) -> list[str]: - files = [] - for root, _, filenames in os.walk(root_dir): - m_root = root_re.fullmatch(root) - if m_root: - for f in filenames: - m_filename = filename_re.fullmatch(f) - if m_filename: - row = m_root.groupdict() - row |= m_filename.groupdict() - row["path"] = str(Path(root).joinpath(f)) - files.append(row) - return files diff --git a/src/faim_hcs/io/YokogawaCellVoyager.py b/src/faim_hcs/io/YokogawaCellVoyager.py deleted file mode 100644 index 67f06f3b..00000000 --- a/src/faim_hcs/io/YokogawaCellVoyager.py +++ /dev/null @@ -1,77 +0,0 @@ -from os.path import exists, join -from pathlib import Path -from typing import Union -from xml.etree import ElementTree as ET - -import pandas as pd - -BTS_NS = "{http://www.yokogawa.co.jp/BTS/BTSSchema/1.0}" - - -def parse_files( - acquisition_dir: Union[Path, str], -): - mlf_file = join(acquisition_dir, "MeasurementData.mlf") - if not exists(mlf_file): - raise ValueError(f"MeasurementData.mlf not found in: {acquisition_dir}") - mlf_tree = ET.parse(mlf_file) - mlf_root = mlf_tree.getroot() - - files = [] - for record in mlf_root.findall(BTS_NS + "MeasurementRecord"): - row = {key.replace(BTS_NS, ""): value for key, value in record.attrib.items()} - if row.pop("Type") == "IMG": - row |= { - "path": join(acquisition_dir, record.text), - "well": chr(ord("@") + int(row.pop("Row"))) - + row.pop("Column").zfill(2), - } - files.append(row) - - return pd.DataFrame(files) - - -def parse_metadata( - acquistion_dir: Union[Path, str], -): - mrf_file = join(acquistion_dir, "MeasurementDetail.mrf") - if not exists(mrf_file): - raise ValueError(f"MeasurementDetail.mrf not found in: {acquistion_dir}") - mrf_tree = ET.parse(mrf_file) - mrf_root = mrf_tree.getroot() - - channels = [] - for channel in mrf_root.findall(BTS_NS + "MeasurementChannel"): - row = {key.replace(BTS_NS, ""): value for key, value in channel.attrib.items()} - channels.append(row) - - mes_file = join( - acquistion_dir, mrf_root.attrib[BTS_NS + "MeasurementSettingFileName"] - ) - if not exists(mes_file): - raise ValueError(f"Settings file not found: {mes_file}") - mes_tree = ET.parse(mes_file) - mes_root = mes_tree.getroot() - - channel_settings = [] - for channel in mes_root.find(BTS_NS + "ChannelList").findall(BTS_NS + "Channel"): - row = {key.replace(BTS_NS, ""): value for key, value in channel.attrib.items()} - channel_settings.append(row) - - plate = mrf_root.find(BTS_NS + "MeasurementSamplePlate") - wpi_file = join(acquistion_dir, plate.attrib[BTS_NS + "WellPlateFileName"]) - if not exists(wpi_file): - raise ValueError(f"Plate information file not found: {wpi_file}") - wpi_tree = ET.parse(wpi_file) - wpi_root = wpi_tree.getroot() - name = wpi_root.attrib[BTS_NS + "Name"] - - # NB: we probably do not need to parse the well plate product file - # wpp_file = join(acquistion_dir, plate.attrib[BTS_NS + "WellPlateProductFileName"]) - - return name, pd.merge( - pd.DataFrame(channels), - pd.DataFrame(channel_settings), - left_on="Ch", - right_on="Ch", - ) diff --git a/src/faim_hcs/roitable/FractalROITable.py b/src/faim_hcs/roitable/FractalROITable.py new file mode 100644 index 00000000..cdbc28d4 --- /dev/null +++ b/src/faim_hcs/roitable/FractalROITable.py @@ -0,0 +1,117 @@ +import anndata as ad +import numpy as np +import pandas as pd +from zarr import Group + +from faim_hcs.hcs.acquisition import PlateAcquisition, WellAcquisition +from faim_hcs.stitching import Tile + + +def create_ROI_tables(plate_acquistion: PlateAcquisition, calibration_dict): + columns = [ + "FieldIndex", + "x_micrometer", + "y_micrometer", + "z_micrometer", + "len_x_micrometer", + "len_y_micrometer", + "len_z_micrometer", + ] + plate_roi_tables = {} + for well_acquisition in plate_acquistion.get_well_acquisitions(): + plate_roi_tables[well_acquisition.name] = dict( + FOV_ROI_table=create_fov_ROI_table( + well_acquisition.get_tiles(), + columns, + calibration_dict, + ), + well_ROI_table=create_well_ROI_table( + well_acquisition, + columns, + calibration_dict, + ), + ) + + return plate_roi_tables + + +def create_well_ROI_table( + well_acquisition: WellAcquisition, + columns, + calibration_dict, +): + well_roi = [ + "well_1", + 0.0, + 0.0, + 0.0, + well_acquisition.get_shape()[-1] * calibration_dict["spatial-calibration-x"], + well_acquisition.get_shape()[-2] * calibration_dict["spatial-calibration-y"], + well_acquisition.get_shape()[-3] * calibration_dict["spatial-calibration-z"], + ] + well_roi_table = pd.DataFrame(well_roi).T + well_roi_table.columns = columns + well_roi_table.set_index("FieldIndex", inplace=True) + return well_roi_table + + +def create_fov_ROI_table( + tiles: list[Tile], columns, calibration_dict: dict[str, float] +): + fov_rois = [] + tile = tiles[0] + min_z = tile.position.z * calibration_dict["spatial-calibration-z"] + max_z = (tile.position.z + 1) * calibration_dict["spatial-calibration-z"] + for tile in tiles: + z_start = tile.position.z * calibration_dict["spatial-calibration-z"] + z_end = (tile.position.z + 1) * calibration_dict["spatial-calibration-z"] + if z_start < min_z: + min_z = z_start + + if z_end > max_z: + max_z = z_end + + if tile.position.z == 0 and tile.position.channel == 0: + fov_rois.append( + ( + "", + tile.position.x * calibration_dict["spatial-calibration-x"], + tile.position.y * calibration_dict["spatial-calibration-y"], + tile.position.z * calibration_dict["spatial-calibration-z"], + tile.shape[-1] * calibration_dict["spatial-calibration-x"], + tile.shape[-2] * calibration_dict["spatial-calibration-y"], + (tile.position.z + 1) * calibration_dict["spatial-calibration-z"], + ) + ) + roi_table = pd.DataFrame(fov_rois, columns=columns).set_index("FieldIndex") + + roi_table["z_micrometer"] = min_z + roi_table["len_z_micrometer"] = max_z + return roi_table + + +def write_roi_table( + roi_table: pd.DataFrame, + table_name: str, + group: Group, +): + """Writes a roi table to an OME-Zarr image. If no table folder exists, it is created.""" + group_tables = group.require_group("tables") + + # Assign dtype explicitly, to avoid + # >> UserWarning: X converted to numpy array with dtype float64 + # when creating AnnData object + df_roi = roi_table.astype(np.float32) + + adata = ad.AnnData(X=df_roi) + adata.obs_names = roi_table.index + adata.var_names = list(map(str, roi_table.columns)) + ad._io.specs.write_elem(group_tables, table_name, adata) + update_table_metadata(group_tables, table_name) + + +def update_table_metadata(group_tables, table_name): + if "tables" not in group_tables.attrs: + group_tables.attrs["tables"] = [table_name] + elif table_name not in group_tables.attrs["tables"]: + group_tables.attrs["tables"] = group_tables.attrs["tables"] + [table_name] diff --git a/src/faim_hcs/roitable/__init__.py b/src/faim_hcs/roitable/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/faim_hcs/stitching/DaskTileStitcher.py b/src/faim_hcs/stitching/DaskTileStitcher.py index 642058d5..e387e0b5 100644 --- a/src/faim_hcs/stitching/DaskTileStitcher.py +++ b/src/faim_hcs/stitching/DaskTileStitcher.py @@ -1,5 +1,5 @@ from functools import partial -from typing import Callable +from typing import Callable, Optional import numpy as np from dask import array as da @@ -18,8 +18,21 @@ def __init__( self, tiles: list[Tile], yx_chunk_shape: tuple[int, int], + output_shape: Optional[tuple[int, int, int, int, int]] = None, dtype: np.dtype = np.uint16, ): + """ + Parameters + ---------- + tiles : + Tiles to stitch. + yx_chunk_shape : + Chunk shape in y and x. + output_shape : + Shape of the output image. If None, the shape is computed from the tiles. + dtype : + Data type of the output image. + """ self.tiles: list[Tile] = stitching_utils.shift_to_origin(tiles) self.chunk_shape = ( 1, @@ -28,7 +41,10 @@ def __init__( ) + yx_chunk_shape self.dtype = dtype - self._shape = self._compute_output_shape() + if output_shape is None: + self._shape = self._compute_output_shape() + else: + self._shape = output_shape self._n_chunks = self._compute_number_of_chunks() self._block_to_tile_map = self._compute_block_to_tile_map() @@ -92,7 +108,7 @@ def get_stitched_dask_array( Returns ------- - + Dask array of the stitched image. """ func = partial( stitching_utils.assemble_chunk, diff --git a/src/faim_hcs/stitching/Tile.py b/src/faim_hcs/stitching/Tile.py index b6345e51..9fe832b1 100644 --- a/src/faim_hcs/stitching/Tile.py +++ b/src/faim_hcs/stitching/Tile.py @@ -1,8 +1,9 @@ -from typing import Optional +from pathlib import Path +from typing import Optional, Union -from numpy._typing import ArrayLike -from pydantic import NonNegativeInt -from skimage.measure.fit import BaseModel +import numpy as np +from numpy._typing import NDArray +from pydantic import BaseModel, NonNegativeInt from tifffile import imread @@ -13,26 +14,14 @@ class TilePosition(BaseModel): y: int x: int - def __init__( - self, - time: Optional[NonNegativeInt], - channel: Optional[NonNegativeInt], - z: int, - y: int, - x: int, - ): - super().__init__() - self.time = time - self.channel = channel - self.z = z - self.y = y - self.x = x - def __repr__(self): return f"TilePosition(time={self.time}, channel={self.channel}, z={self.z}, y={self.y}, x={self.x})" + def __str__(self): + return self.__repr__() + -class Tile(BaseModel): +class Tile: """ A tile with a path to the image data, shape and position. """ @@ -40,16 +29,28 @@ class Tile(BaseModel): path: str shape: tuple[int, int] position: TilePosition + background_correction_matrix_path: Optional[Union[Path, str]] = None + illumination_correction_matrix_path: Optional[Union[Path, str]] = None - def __init__(self, path: str, shape: tuple[int, int], position: TilePosition): + def __init__( + self, + path: Union[Path, str], + shape: tuple[int, int], + position: TilePosition, + background_correction_matrix_path: Optional[Union[Path, str]] = None, + illumination_correction_matrix_path: Optional[Union[Path, str]] = None, + ): super().__init__() self.path = path self.shape = shape self.position = position + self.background_correction_matrix_path = background_correction_matrix_path + self.illumination_correction_matrix_path = illumination_correction_matrix_path def __repr__(self): return ( - f"Tile(path={self.path}, shape={self.shape}, " f"position={self.position})" + f"Tile(path='{self.path}', shape={self.shape}, " + f"position={self.position})" ) def __str__(self): @@ -70,7 +71,7 @@ def get_position(self) -> tuple[int, int, int, int, int]: self.position.x, ) - def load_data(self) -> ArrayLike: + def load_data(self) -> NDArray: """ Load the image data from the path. @@ -78,4 +79,24 @@ def load_data(self) -> ArrayLike: ------- Image data """ - return imread(self.path) + data = imread(self.path) + dtype = data.dtype + if self.background_correction_matrix_path is not None: + bgcm = imread(self.background_correction_matrix_path) + assert bgcm.shape == data.shape, ( + f"Background correction matrix shape {bgcm.shape} " + f"does not match image shape {data.shape}." + ) + mi, ma = np.iinfo(dtype).min, np.iinfo(dtype).max + data = np.clip(data - bgcm, a_min=mi, a_max=ma) + + if self.illumination_correction_matrix_path is not None: + icm = imread(self.illumination_correction_matrix_path) + assert icm.shape == data.shape, ( + f"Illumination correction matrix shape {icm.shape} " + f"does not match image shape {data.shape}." + ) + mi, ma = np.iinfo(dtype).min, np.iinfo(dtype).max + data = np.clip(data / icm, a_min=mi, a_max=ma) + + return data.astype(dtype=dtype) diff --git a/src/faim_hcs/stitching/stitching_utils.py b/src/faim_hcs/stitching/stitching_utils.py index f736dd07..de37fd0a 100644 --- a/src/faim_hcs/stitching/stitching_utils.py +++ b/src/faim_hcs/stitching/stitching_utils.py @@ -1,13 +1,53 @@ from copy import copy import numpy as np -from numpy._typing import ArrayLike +from numpy._typing import NDArray +from scipy.ndimage import distance_transform_edt from skimage.transform import EuclideanTransform, warp +from threadpoolctl import threadpool_limits from faim_hcs.stitching.Tile import Tile, TilePosition -def fuse_mean(warped_tiles: ArrayLike, warped_masks: ArrayLike) -> ArrayLike: +def fuse_linear(warped_tiles: NDArray, warped_masks: NDArray) -> NDArray: + """ + Fuse transformed tiles using a linear gradient to compute the weighted + average where tiles are overlapping. + + Parameters + ---------- + warped_tiles : + Tile images transformed to the final image space. + warped_masks : + Masks indicating foreground pixels for the transformed tiles. + + Returns + ------- + Fused image. + """ + dtype = warped_tiles.dtype + if warped_tiles.shape[0] > 1: + weights = np.zeros_like(warped_masks, dtype=np.float32) + for i, mask in enumerate(warped_masks): + weights[i] = distance_transform_edt( + warped_masks[i].astype(np.float32), + ) + + denominator = weights.sum(axis=0) + weights = np.true_divide(weights, denominator, where=denominator > 0) + weights = np.nan_to_num(weights, nan=0, posinf=1, neginf=0) + weights = np.clip( + weights, + 0, + 1, + ) + else: + weights = warped_masks + + return np.sum(warped_tiles * weights, axis=0).astype(dtype) + + +def fuse_mean(warped_tiles: NDArray, warped_masks: NDArray) -> NDArray: """ Fuse transformed tiles and compute the mean of the overlapping pixels. @@ -22,14 +62,19 @@ def fuse_mean(warped_tiles: ArrayLike, warped_masks: ArrayLike) -> ArrayLike: ------- Fused image. """ - weights = warped_masks.astype(np.float32) - weights = weights / weights.sum(axis=0) + denominator = warped_masks.sum(axis=0) + weights = np.true_divide(warped_masks, denominator, where=denominator > 0) + weights = np.clip( + np.nan_to_num(weights, nan=0, posinf=1, neginf=0), + 0, + 1, + ) fused_image = np.sum(warped_tiles * weights, axis=0) return fused_image.astype(warped_tiles.dtype) -def fuse_sum(warped_tiles: ArrayLike, warped_masks: ArrayLike) -> ArrayLike: +def fuse_sum(warped_tiles: NDArray, warped_masks: NDArray) -> NDArray: """ Fuse transformed tiles and compute the sum of the overlapping pixels. @@ -48,44 +93,77 @@ def fuse_sum(warped_tiles: ArrayLike, warped_masks: ArrayLike) -> ArrayLike: return fused_image.astype(warped_tiles.dtype) +@threadpool_limits.wrap(limits=1, user_api="blas") def translate_tiles_2d(block_info, yx_chunk_shape, dtype, tiles): + """ + Translate tiles to their relative position inside the given block. + + Parameters + ---------- + block_info : + da.map_blocks block_info. + yx_chunk_shape : + shape of the chunk in yx. + dtype : + dtype of the tiles. + tiles : + list of tiles. + + Returns + ------- + translated tiles, translated masks + """ array_location = block_info[None]["array-location"] chunk_yx_origin = np.array([array_location[3][0], array_location[4][0]]) - warped_tiles = np.zeros((len(tiles),) + yx_chunk_shape, dtype=dtype) - warped_masks = np.zeros_like(warped_tiles, dtype=bool) - for i, tile in enumerate(tiles): + warped_tiles = [] + warped_masks = [] + for tile in tiles: tile_origin = np.array(tile.get_yx_position()) transform = EuclideanTransform( translation=(chunk_yx_origin - tile_origin)[::-1] ) tile_data = tile.load_data() - mask = np.ones(tile_data.shape, dtype=bool) - warped_tiles[ - i, - ..., - ] = warp( - tile_data, + mask = np.ones_like(tile_data) + warped = warp( + np.stack([tile_data, mask], axis=-1), transform, cval=0, output_shape=yx_chunk_shape, order=0, preserve_range=True, - ).astype(dtype) + ) + warped_tiles.append(warped[..., 0].astype(dtype)) + warped_masks.append(warped[..., 1].astype(bool)) - warped_masks[i] = warp( - mask, - transform, - cval=False, - output_shape=yx_chunk_shape, - order=0, - preserve_range=True, - ).astype(bool) - return warped_tiles, warped_masks + warped_masks = np.nan_to_num( + np.array(warped_masks), nan=False, posinf=True, neginf=False + ) + return np.array(warped_tiles), warped_masks def assemble_chunk( block_info=None, tile_map=None, warp_func=None, fuse_func=None, dtype=None ): + """ + Assemble a chunk of the stitched image. + + Parameters + ---------- + block_info : + da.map_blocks block_info. + tile_map : + map of block positions to tiles. + warp_func : + function used to warp tiles. + fuse_func : + function used to fuse tiles. + dtype : + tile data type. + + Returns + ------- + fused tiles corresponding to this block/chunk + """ chunk_location = block_info[None]["chunk-location"] chunk_shape = block_info[None]["chunk-shape"] tiles = tile_map[chunk_location] diff --git a/tests/alignment/test_alignment.py b/tests/alignment/test_alignment.py new file mode 100644 index 00000000..43158f3e --- /dev/null +++ b/tests/alignment/test_alignment.py @@ -0,0 +1,83 @@ +import pytest + +from faim_hcs.stitching import Tile +from faim_hcs.stitching.Tile import TilePosition + + +@pytest.fixture +def tiles() -> list[Tile]: + return [ + Tile( + path="path", + shape=(512, 512), + position=TilePosition(time=0, channel=0, z=231, y=4829, x=20128), + ), + Tile( + path="path", + shape=(512, 512), + position=TilePosition(time=0, channel=0, z=231, y=4829 + 512, x=20128), + ), + Tile( + path="path", + shape=(512, 512), + position=TilePosition( + time=0, channel=0, z=231, y=4829 + 2 * 512 + 1, x=20128 + ), + ), + Tile( + path="path", + shape=(512, 512), + position=TilePosition(time=0, channel=0, z=231, y=4829, x=20128 + 512), + ), + Tile( + path="path", + shape=(512, 512), + position=TilePosition( + time=0, channel=0, z=231, y=4829 + 512, x=20128 + 512 - 1 + ), + ), + Tile( + path="path", + shape=(512, 512), + position=TilePosition( + time=0, channel=0, z=231, y=4829 + 2 * 512 + 1, x=20128 + 512 + 10 + ), + ), + ] + + +def test_StageAlignment(tiles): + from faim_hcs.alignment import StageAlignment + + alignment = StageAlignment(tiles) + aligned_tiles = alignment.get_tiles() + assert len(aligned_tiles) == len(tiles) + for tile in aligned_tiles: + assert tile.shape == (512, 512) + assert tile.position.time == 0 + assert tile.position.channel == 0 + assert tile.position.z == 0 + assert tile.position.y in [0, 512, 1025] + assert tile.position.x in [0, 511, 512, 522] + + +def test_GridAlignment(tiles): + from faim_hcs.alignment import GridAlignment + + alignment = GridAlignment(tiles) + aligned_tiles = alignment.get_tiles() + assert len(aligned_tiles) == len(tiles) + for tile in aligned_tiles: + assert tile.shape == (512, 512) + assert tile.position.time == 0 + assert tile.position.channel == 0 + assert tile.position.z == 0 + assert tile.position.y in [0, 512, 1024] + assert tile.position.x in [0, 512] + + +def test_AbstractAlignment(tiles): + from faim_hcs.alignment.alignment import AbstractAlignment + + with pytest.raises(NotImplementedError): + AbstractAlignment(tiles) diff --git a/tests/hcs/cellvoyager/files.csv b/tests/hcs/cellvoyager/files.csv new file mode 100644 index 00000000..4945d5b0 --- /dev/null +++ b/tests/hcs/cellvoyager/files.csv @@ -0,0 +1,33 @@ +,Time,TimePoint,FieldIndex,ZIndex,TimelineIndex,ActionIndex,Action,X,Y,Z,Ch,path,well +0,2023-09-18T13:58:44.438+02:00,1,1,1,1,1,3D,-567.1,402.9,0.0,1,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F001L01A01Z01C01.tif,D08 +1,2023-09-18T13:58:44.647+02:00,1,1,2,1,1,3D,-567.1,402.9,3.0,1,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F001L01A01Z02C01.tif,D08 +2,2023-09-18T13:58:44.863+02:00,1,1,3,1,1,3D,-567.1,402.9,6.0,1,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F001L01A01Z03C01.tif,D08 +3,2023-09-18T13:58:45.080+02:00,1,1,4,1,1,3D,-567.1,402.9,9.0,1,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F001L01A01Z04C01.tif,D08 +4,2023-09-18T13:58:45.568+02:00,1,1,1,1,2,3D,-567.1,402.9,0.0,2,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F001L01A02Z01C02.tif,D08 +5,2023-09-18T13:58:45.773+02:00,1,1,2,1,2,3D,-567.1,402.9,3.0,2,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F001L01A02Z02C02.tif,D08 +6,2023-09-18T13:58:45.976+02:00,1,1,3,1,2,3D,-567.1,402.9,6.0,2,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F001L01A02Z03C02.tif,D08 +7,2023-09-18T13:58:46.200+02:00,1,1,4,1,2,3D,-567.1,402.9,9.0,2,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F001L01A02Z04C02.tif,D08 +8,2023-09-18T13:58:47.042+02:00,1,2,1,1,1,3D,82.8,402.9,0.0,1,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F002L01A01Z01C01.tif,D08 +9,2023-09-18T13:58:47.250+02:00,1,2,2,1,1,3D,82.8,402.9,3.0,1,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F002L01A01Z02C01.tif,D08 +10,2023-09-18T13:58:47.459+02:00,1,2,3,1,1,3D,82.8,402.9,6.0,1,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F002L01A01Z03C01.tif,D08 +11,2023-09-18T13:58:47.659+02:00,1,2,4,1,1,3D,82.8,402.9,9.0,1,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F002L01A01Z04C01.tif,D08 +12,2023-09-18T13:58:48.140+02:00,1,2,1,1,2,3D,82.8,402.9,0.0,2,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F002L01A02Z01C02.tif,D08 +13,2023-09-18T13:58:48.337+02:00,1,2,2,1,2,3D,82.8,402.9,3.0,2,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F002L01A02Z02C02.tif,D08 +14,2023-09-18T13:58:48.556+02:00,1,2,3,1,2,3D,82.8,402.9,6.0,2,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F002L01A02Z03C02.tif,D08 +15,2023-09-18T13:58:48.757+02:00,1,2,4,1,2,3D,82.8,402.9,9.0,2,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F002L01A02Z04C02.tif,D08 +16,2023-09-18T13:58:49.624+02:00,1,3,1,1,1,3D,-567.1,-247.0,0.0,1,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F003L01A01Z01C01.tif,D08 +17,2023-09-18T13:58:49.837+02:00,1,3,2,1,1,3D,-567.1,-247.0,3.0,1,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F003L01A01Z02C01.tif,D08 +18,2023-09-18T13:58:50.040+02:00,1,3,3,1,1,3D,-567.1,-247.0,6.0,1,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F003L01A01Z03C01.tif,D08 +19,2023-09-18T13:58:50.243+02:00,1,3,4,1,1,3D,-567.1,-247.0,9.0,1,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F003L01A01Z04C01.tif,D08 +20,2023-09-18T13:58:50.714+02:00,1,3,1,1,2,3D,-567.1,-247.0,0.0,2,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F003L01A02Z01C02.tif,D08 +21,2023-09-18T13:58:50.920+02:00,1,3,2,1,2,3D,-567.1,-247.0,3.0,2,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F003L01A02Z02C02.tif,D08 +22,2023-09-18T13:58:51.125+02:00,1,3,3,1,2,3D,-567.1,-247.0,6.0,2,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F003L01A02Z03C02.tif,D08 +23,2023-09-18T13:58:51.326+02:00,1,3,4,1,2,3D,-567.1,-247.0,9.0,2,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F003L01A02Z04C02.tif,D08 +24,2023-09-18T13:58:52.175+02:00,1,4,1,1,1,3D,82.8,-247.0,0.0,1,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F004L01A01Z01C01.tif,D08 +25,2023-09-18T13:58:52.375+02:00,1,4,2,1,1,3D,82.8,-247.0,3.0,1,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F004L01A01Z02C01.tif,D08 +26,2023-09-18T13:58:52.573+02:00,1,4,3,1,1,3D,82.8,-247.0,6.0,1,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F004L01A01Z03C01.tif,D08 +27,2023-09-18T13:58:52.778+02:00,1,4,4,1,1,3D,82.8,-247.0,9.0,1,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F004L01A01Z04C01.tif,D08 +28,2023-09-18T13:58:53.253+02:00,1,4,1,1,2,3D,82.8,-247.0,0.0,2,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F004L01A02Z01C02.tif,D08 +29,2023-09-18T13:58:53.453+02:00,1,4,2,1,2,3D,82.8,-247.0,3.0,2,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F004L01A02Z02C02.tif,D08 +30,2023-09-18T13:58:53.650+02:00,1,4,3,1,2,3D,82.8,-247.0,6.0,2,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F004L01A02Z03C02.tif,D08 +31,2023-09-18T13:58:53.849+02:00,1,4,4,1,2,3D,82.8,-247.0,9.0,2,resources/CV8000/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_D08_T0001F004L01A02Z04C02.tif,D08 diff --git a/tests/hcs/cellvoyager/test_CellVoyagerWellAcquisition.py b/tests/hcs/cellvoyager/test_CellVoyagerWellAcquisition.py new file mode 100644 index 00000000..f8c2aa4f --- /dev/null +++ b/tests/hcs/cellvoyager/test_CellVoyagerWellAcquisition.py @@ -0,0 +1,135 @@ +import os +from os.path import join +from pathlib import Path + +import pandas as pd +import pytest + +from faim_hcs.hcs.acquisition import TileAlignmentOptions +from faim_hcs.hcs.cellvoyager.CellVoyagerWellAcquisition import ( + CellVoyagerWellAcquisition, +) + + +@pytest.fixture +def files() -> pd.DataFrame: + resource_dir = Path(__file__).parent.parent.parent.parent + + files = pd.read_csv(join(Path(__file__).parent, "files.csv"), index_col=0) + + files["path"] = files.apply(lambda row: join(resource_dir, row["path"]), axis=1) + + return files + + +@pytest.fixture +def metadata() -> pd.DataFrame: + return pd.DataFrame( + { + "Ch": [1, 2], + "VerticalPixels": [2000, 2000], + "HorizontalPixels": [2000, 2000], + "VerticalPixelDimension": [0.65, 0.65], + "HorizontalPixelDimension": [0.65, 0.65], + } + ) + + +def test__assemble_tiles(files, metadata): + cv_well_acquisition = CellVoyagerWellAcquisition( + files=files, + alignment=TileAlignmentOptions.GRID, + metadata=metadata, + z_spacing=5.0, + ) + + tiles = cv_well_acquisition._assemble_tiles() + assert len(tiles) == 32 + for tile in tiles: + assert os.path.exists(tile.path) + assert tile.shape == (2000, 2000) + assert tile.position.channel in [1, 2] + assert tile.position.time == 1 + assert tile.position.z in [1, 2, 3, 4] + assert tile.position.y in list((files["Y"].unique() / 0.65).astype(int)) + assert tile.position.x in list((files["X"].unique() / 0.65).astype(int)) + + +def test_get_axes(files, metadata): + cv_well_acquisition = CellVoyagerWellAcquisition( + files=files, + alignment=TileAlignmentOptions.GRID, + metadata=metadata, + z_spacing=5.0, + ) + axes = cv_well_acquisition.get_axes() + assert axes == ["c", "z", "y", "x"] + + cv_well_acquisition = CellVoyagerWellAcquisition( + files=files, + alignment=TileAlignmentOptions.GRID, + metadata=metadata, + z_spacing=None, + ) + axes = cv_well_acquisition.get_axes() + assert axes == ["c", "y", "x"] + + +def test_get_yx_spacing(files, metadata): + cv_well_acquisition = CellVoyagerWellAcquisition( + files=files, + alignment=TileAlignmentOptions.GRID, + metadata=metadata, + z_spacing=5.0, + ) + + yx_spacing = cv_well_acquisition.get_yx_spacing() + assert yx_spacing == (0.65, 0.65) + + +def test_get_z_spacing(files, metadata): + cv_well_acquisition = CellVoyagerWellAcquisition( + files=files, + alignment=TileAlignmentOptions.GRID, + metadata=metadata, + z_spacing=5.0, + ) + + z_spacing = cv_well_acquisition.get_z_spacing() + assert z_spacing == 5.0 + + +def test_bgcm(files, metadata): + cv_well_acquisition = CellVoyagerWellAcquisition( + files=files, + alignment=TileAlignmentOptions.GRID, + metadata=metadata, + z_spacing=5.0, + background_correction_matrices={"1": "bgcm1", "2": "bgcm2"}, + ) + + tiles = cv_well_acquisition._assemble_tiles() + for tile in tiles: + if tile.position.channel == 1: + assert tile.background_correction_matrix_path == "bgcm1" + + if tile.position.channel == 2: + assert tile.background_correction_matrix_path == "bgcm2" + + +def test_icm(files, metadata): + cv_well_acquisition = CellVoyagerWellAcquisition( + files=files, + alignment=TileAlignmentOptions.GRID, + metadata=metadata, + z_spacing=5.0, + illumination_correction_matrices={"1": "icm1", "2": "icm2"}, + ) + + tiles = cv_well_acquisition._assemble_tiles() + for tile in tiles: + if tile.position.channel == 1: + assert tile.illumination_correction_matrix_path == "icm1" + + if tile.position.channel == 2: + assert tile.illumination_correction_matrix_path == "icm2" diff --git a/tests/hcs/cellvoyager/test_StackAcquisition.py b/tests/hcs/cellvoyager/test_StackAcquisition.py new file mode 100644 index 00000000..bc17a8eb --- /dev/null +++ b/tests/hcs/cellvoyager/test_StackAcquisition.py @@ -0,0 +1,153 @@ +import re +from pathlib import Path + +import pytest +from tifffile import imread + +from faim_hcs.hcs.acquisition import TileAlignmentOptions +from faim_hcs.hcs.cellvoyager import StackAcquisition + + +@pytest.fixture +def cv_acquisition() -> Path: + dir = ( + Path(__file__).parent.parent.parent.parent + / "resources" + / "CV8000" + / "CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839" + / "CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack" + ) + return dir + + +def test_get_channel_metadata(cv_acquisition): + plate = StackAcquisition( + acquisition_dir=cv_acquisition, + alignment=TileAlignmentOptions.GRID, + ) + + ch_metadata = plate.get_channel_metadata() + assert len(ch_metadata) == 4 + assert ch_metadata[0].channel_name == "1" + assert ch_metadata[0].display_color == "#FF002FFF" + assert ch_metadata[0].spatial_calibration_x == 0.325 + assert ch_metadata[0].spatial_calibration_y == 0.325 + assert ch_metadata[0].spatial_calibration_units == "um" + assert ch_metadata[0].z_spacing == 3.0 + assert ch_metadata[0].wavelength == 405 + assert ch_metadata[0].exposure_time == 100 + assert ch_metadata[0].exposure_time_unit == "ms" + assert ch_metadata[0].objective == "20x v2" + + assert ch_metadata[1].channel_name == "2" + assert ch_metadata[1].display_color == "#FF00FFA1" + assert ch_metadata[1].spatial_calibration_x == 0.325 + assert ch_metadata[1].spatial_calibration_y == 0.325 + assert ch_metadata[1].spatial_calibration_units == "um" + assert ch_metadata[1].z_spacing == 3.0 + assert ch_metadata[1].wavelength == 488 + assert ch_metadata[1].exposure_time == 100 + assert ch_metadata[1].exposure_time_unit == "ms" + assert ch_metadata[1].objective == "20x v2" + + assert ch_metadata[2].channel_name == "3" + assert ch_metadata[2].display_color == "#FFFF8200" + assert ch_metadata[2].spatial_calibration_x == 0.325 + assert ch_metadata[2].spatial_calibration_y == 0.325 + assert ch_metadata[2].spatial_calibration_units == "um" + assert ch_metadata[2].z_spacing == 3.0 + assert ch_metadata[2].wavelength == 561 + assert ch_metadata[2].exposure_time == 250 + assert ch_metadata[2].exposure_time_unit == "ms" + assert ch_metadata[2].objective == "20x v2" + + assert ch_metadata[3].channel_name == "4" + assert ch_metadata[3].display_color == "#FFFF1B00" + assert ch_metadata[3].spatial_calibration_x == 0.325 + assert ch_metadata[3].spatial_calibration_y == 0.325 + assert ch_metadata[3].spatial_calibration_units == "um" + assert ch_metadata[3].z_spacing == 3.0 + assert ch_metadata[3].wavelength == 640 + assert ch_metadata[3].exposure_time == 250 + assert ch_metadata[3].exposure_time_unit == "ms" + assert ch_metadata[3].objective == "20x v2" + + +def test__compute_z_spacing(cv_acquisition): + plate = StackAcquisition( + acquisition_dir=cv_acquisition, + alignment=TileAlignmentOptions.GRID, + ) + + z_spacing = plate._compute_z_spacing() + assert z_spacing == 3.0 + + +def test__parse_files(cv_acquisition): + plate = StackAcquisition( + acquisition_dir=cv_acquisition, + alignment=TileAlignmentOptions.GRID, + ) + + files = plate._parse_files() + assert len(files) == 96 + assert files["well"].unique().tolist() == ["D08", "E03", "F08"] + assert files["Ch"].unique().tolist() == ["1", "2"] + assert files["Z"].unique().tolist() == ["0.0", "3.0", "6.0", "9.0"] + + assert files.columns.tolist() == [ + "Time", + "TimePoint", + "FieldIndex", + "ZIndex", + "TimelineIndex", + "ActionIndex", + "Action", + "X", + "Y", + "Z", + "Ch", + "path", + "well", + ] + + +def test_get_well_acquisitions(cv_acquisition): + plate = StackAcquisition( + acquisition_dir=cv_acquisition, + alignment=TileAlignmentOptions.GRID, + ) + + wells = plate.get_well_acquisitions() + assert len(wells) == 3 + for well in wells: + for tile in well.get_tiles(): + file_name = ( + f".*/CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_" + f"{well.name}_T" + f"{str(tile.position.time + 1).zfill(4)}F.*L.*A.*Z" + f"{str(tile.position.z + 1).zfill(2)}C" + f"{str(tile.position.channel + 1).zfill(2)}.tif" + ) + re_file_name = re.compile(file_name) + assert re_file_name.match(tile.path) + assert tile.shape == imread(tile.path).shape + assert tile.illumination_correction_matrix_path is None + assert tile.background_correction_matrix_path is None + + +def test_raise_value_errors(cv_acquisition): + with pytest.raises(ValueError): + plate = StackAcquisition( + acquisition_dir=".", + alignment=TileAlignmentOptions.GRID, + ) + + with pytest.raises(ValueError): + plate = StackAcquisition( + acquisition_dir=cv_acquisition, + alignment=TileAlignmentOptions.GRID, + ) + # Change acquisition_dir to mock missing mrf and mes files. + plate._acquisition_dir = "." + plate._parse_metadata() diff --git a/tests/hcs/imagexpress/files.csv b/tests/hcs/imagexpress/files.csv new file mode 100644 index 00000000..3ca6de01 --- /dev/null +++ b/tests/hcs/imagexpress/files.csv @@ -0,0 +1,43 @@ +,date,acq_id,z,name,well,field,channel,md_id,ext,path +0,2023-02-21,1334,5,Projection-Mix,E07,s1,w1,21C731DC-B545-430A-B705-5BEF20B33B9A,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_5/Projection-Mix_E07_s1_w121C731DC-B545-430A-B705-5BEF20B33B9A.tif +3,2023-02-21,1334,5,Projection-Mix,E07,s2,w2,E143AECE-A510-421A-B352-50DCFA610C8D,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_5/Projection-Mix_E07_s2_w2E143AECE-A510-421A-B352-50DCFA610C8D.tif +6,2023-02-21,1334,5,Projection-Mix,E07,s2,w1,1FE44C29-5EE8-432C-96A8-2D6E94C184E7,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_5/Projection-Mix_E07_s2_w11FE44C29-5EE8-432C-96A8-2D6E94C184E7.tif +7,2023-02-21,1334,5,Projection-Mix,E07,s1,w2,5051A384-0655-423F-A6B3-DD29E05363A6,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_5/Projection-Mix_E07_s1_w25051A384-0655-423F-A6B3-DD29E05363A6.tif +9,2023-02-21,1334,6,Projection-Mix,E07,s2,w1,12721B3E-424D-4D5F-B7C0-CD7F20259090,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_6/Projection-Mix_E07_s2_w112721B3E-424D-4D5F-B7C0-CD7F20259090.tif +12,2023-02-21,1334,6,Projection-Mix,E07,s1,w1,39F54C56-73A4-4902-991A-75EAEE836E5D,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_6/Projection-Mix_E07_s1_w139F54C56-73A4-4902-991A-75EAEE836E5D.tif +14,2023-02-21,1334,6,Projection-Mix,E07,s2,w2,E734CED2-EE31-4621-B779-1EC7965BF9C8,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_6/Projection-Mix_E07_s2_w2E734CED2-EE31-4621-B779-1EC7965BF9C8.tif +15,2023-02-21,1334,6,Projection-Mix,E07,s1,w2,4631083C-E384-457B-8FBC-BE43B0F58E5B,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_6/Projection-Mix_E07_s1_w24631083C-E384-457B-8FBC-BE43B0F58E5B.tif +17,2023-02-21,1334,2,Projection-Mix,E07,s1,w2,82ED0DF4-8188-48BB-944C-03B905E1C50A,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_2/Projection-Mix_E07_s1_w282ED0DF4-8188-48BB-944C-03B905E1C50A.tif +18,2023-02-21,1334,2,Projection-Mix,E07,s1,w1,89A35A04-20E3-47AD-A1F9-8065B2840EFD,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_2/Projection-Mix_E07_s1_w189A35A04-20E3-47AD-A1F9-8065B2840EFD.tif +19,2023-02-21,1334,2,Projection-Mix,E07,s2,w2,2EED77C5-EDAC-4C4E-A6CF-ACF66B64D4C4,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_2/Projection-Mix_E07_s2_w22EED77C5-EDAC-4C4E-A6CF-ACF66B64D4C4.tif +21,2023-02-21,1334,2,Projection-Mix,E07,s2,w1,19DACB95-CB7D-4899-9AB4-D4E10B44145D,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_2/Projection-Mix_E07_s2_w119DACB95-CB7D-4899-9AB4-D4E10B44145D.tif +24,2023-02-21,1334,10,Projection-Mix,E07,s1,w2,7FF63331-0307-4A31-89AC-B363F8BCED7A,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_10/Projection-Mix_E07_s1_w27FF63331-0307-4A31-89AC-B363F8BCED7A.tif +25,2023-02-21,1334,10,Projection-Mix,E07,s1,w1,1D01380B-E7DA-4C09-A343-EDA3D235D808,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_10/Projection-Mix_E07_s1_w11D01380B-E7DA-4C09-A343-EDA3D235D808.tif +29,2023-02-21,1334,10,Projection-Mix,E07,s2,w2,0DB41161-451B-4A8B-9DA6-8E9C7D56D30E,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_10/Projection-Mix_E07_s2_w20DB41161-451B-4A8B-9DA6-8E9C7D56D30E.tif +31,2023-02-21,1334,10,Projection-Mix,E07,s2,w1,2BFD0E01-D7F8-4596-8904-3209AD741437,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_10/Projection-Mix_E07_s2_w12BFD0E01-D7F8-4596-8904-3209AD741437.tif +34,2023-02-21,1334,7,Projection-Mix,E07,s2,w1,414CFA60-D960-4FDA-9FE2-4FFAA07AF558,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_7/Projection-Mix_E07_s2_w1414CFA60-D960-4FDA-9FE2-4FFAA07AF558.tif +36,2023-02-21,1334,7,Projection-Mix,E07,s1,w2,766A03F0-334F-4D57-B758-F41911E3320C,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_7/Projection-Mix_E07_s1_w2766A03F0-334F-4D57-B758-F41911E3320C.tif +37,2023-02-21,1334,7,Projection-Mix,E07,s2,w2,DCBF5538-5503-46B9-A11B-75497ACDE62A,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_7/Projection-Mix_E07_s2_w2DCBF5538-5503-46B9-A11B-75497ACDE62A.tif +38,2023-02-21,1334,7,Projection-Mix,E07,s1,w1,4887A96B-5EFF-4ABF-BBAC-38BDB13D81F5,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_7/Projection-Mix_E07_s1_w14887A96B-5EFF-4ABF-BBAC-38BDB13D81F5.tif +40,2023-02-21,1334,8,Projection-Mix,E07,s1,w1,D3071A65-DC30-4AB2-A0F6-98A7FEB25B7F,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_8/Projection-Mix_E07_s1_w1D3071A65-DC30-4AB2-A0F6-98A7FEB25B7F.tif +44,2023-02-21,1334,8,Projection-Mix,E07,s1,w2,1EF435F5-5581-40A2-A90E-7ECEFD780F1A,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_8/Projection-Mix_E07_s1_w21EF435F5-5581-40A2-A90E-7ECEFD780F1A.tif +46,2023-02-21,1334,8,Projection-Mix,E07,s2,w2,E064803D-A997-434E-8E0E-0326F022DD81,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_8/Projection-Mix_E07_s2_w2E064803D-A997-434E-8E0E-0326F022DD81.tif +47,2023-02-21,1334,8,Projection-Mix,E07,s2,w1,B7A78D26-E0A4-4B77-A57E-EC75BBC4BC0B,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_8/Projection-Mix_E07_s2_w1B7A78D26-E0A4-4B77-A57E-EC75BBC4BC0B.tif +50,2023-02-21,1334,1,Projection-Mix,E07,s1,w4,27CCB2E4-1BF4-45E7-8BC7-264B48EF9C4A,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_1/Projection-Mix_E07_s1_w427CCB2E4-1BF4-45E7-8BC7-264B48EF9C4A.tif +51,2023-02-21,1334,1,Projection-Mix,E07,s2,w4,F95A8A9F-0939-47C2-8D3E-F6E91AF0C4ED,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_1/Projection-Mix_E07_s2_w4F95A8A9F-0939-47C2-8D3E-F6E91AF0C4ED.tif +54,2023-02-21,1334,1,Projection-Mix,E07,s1,w2,C0A49256-E289-4C0F-ADC9-F7728ABDB141,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_1/Projection-Mix_E07_s1_w2C0A49256-E289-4C0F-ADC9-F7728ABDB141.tif +55,2023-02-21,1334,1,Projection-Mix,E07,s2,w2,045200D6-DAE1-4224-AFDE-9EFD107D85EB,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_1/Projection-Mix_E07_s2_w2045200D6-DAE1-4224-AFDE-9EFD107D85EB.tif +56,2023-02-21,1334,1,Projection-Mix,E07,s1,w1,E78EB128-BD0D-4D94-A6AD-3FF28BB1B105,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_1/Projection-Mix_E07_s1_w1E78EB128-BD0D-4D94-A6AD-3FF28BB1B105.tif +59,2023-02-21,1334,1,Projection-Mix,E07,s2,w1,AC08A410-4276-4921-9FDA-9CB1249B3156,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_1/Projection-Mix_E07_s2_w1AC08A410-4276-4921-9FDA-9CB1249B3156.tif +60,2023-02-21,1334,9,Projection-Mix,E07,s1,w1,091EB8A5-272A-466D-B8A0-7547C6BA392B,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_9/Projection-Mix_E07_s1_w1091EB8A5-272A-466D-B8A0-7547C6BA392B.tif +61,2023-02-21,1334,9,Projection-Mix,E07,s2,w1,0961945B-7AF1-4182-85E2-DCE08A54F6E6,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_9/Projection-Mix_E07_s2_w10961945B-7AF1-4182-85E2-DCE08A54F6E6.tif +63,2023-02-21,1334,9,Projection-Mix,E07,s1,w2,9B34CF69-877E-4617-A171-C7BD42ED5EE7,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_9/Projection-Mix_E07_s1_w29B34CF69-877E-4617-A171-C7BD42ED5EE7.tif +66,2023-02-21,1334,9,Projection-Mix,E07,s2,w2,AA84543B-6063-4630-A293-AE288A98DF29,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_9/Projection-Mix_E07_s2_w2AA84543B-6063-4630-A293-AE288A98DF29.tif +71,2023-02-21,1334,4,Projection-Mix,E07,s2,w1,99B8D628-8B89-40A4-A91C-3A5125698029,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_4/Projection-Mix_E07_s2_w199B8D628-8B89-40A4-A91C-3A5125698029.tif +72,2023-02-21,1334,4,Projection-Mix,E07,s2,w2,A0C56C66-6538-453D-8D3E-821A1794CCB4,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_4/Projection-Mix_E07_s2_w2A0C56C66-6538-453D-8D3E-821A1794CCB4.tif +73,2023-02-21,1334,4,Projection-Mix,E07,s1,w1,4B8A9CF3-EAC1-4C9C-8501-8A8546F24D95,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_4/Projection-Mix_E07_s1_w14B8A9CF3-EAC1-4C9C-8501-8A8546F24D95.tif +75,2023-02-21,1334,4,Projection-Mix,E07,s1,w2,97F1C712-6E25-444C-93DF-579182759C52,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_4/Projection-Mix_E07_s1_w297F1C712-6E25-444C-93DF-579182759C52.tif +76,2023-02-21,1334,3,Projection-Mix,E07,s1,w1,B9AD8C2D-0576-48DC-86A5-C6AE4AD33802,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_3/Projection-Mix_E07_s1_w1B9AD8C2D-0576-48DC-86A5-C6AE4AD33802.tif +79,2023-02-21,1334,3,Projection-Mix,E07,s2,w2,D7E0C2D6-CE27-4EC4-8E7B-90D3F351B9C3,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_3/Projection-Mix_E07_s2_w2D7E0C2D6-CE27-4EC4-8E7B-90D3F351B9C3.tif +81,2023-02-21,1334,3,Projection-Mix,E07,s2,w1,3F8FCF31-E226-4345-BE10-362D859537BE,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_3/Projection-Mix_E07_s2_w13F8FCF31-E226-4345-BE10-362D859537BE.tif +82,2023-02-21,1334,3,Projection-Mix,E07,s1,w2,5B80CE92-FF54-4E8F-B208-22AB72686763,.tif,resources/Projection-Mix/2023-02-21/1334/ZStep_3/Projection-Mix_E07_s1_w25B80CE92-FF54-4E8F-B208-22AB72686763.tif diff --git a/tests/hcs/imagexpress/test_ImageXpress.py b/tests/hcs/imagexpress/test_ImageXpress.py new file mode 100644 index 00000000..e5d20f8c --- /dev/null +++ b/tests/hcs/imagexpress/test_ImageXpress.py @@ -0,0 +1,267 @@ +from dataclasses import dataclass +from pathlib import Path + +import pytest +from numpy.testing import assert_almost_equal + +from faim_hcs.hcs import imagexpress +from faim_hcs.hcs.acquisition import ( + PlateAcquisition, + TileAlignmentOptions, + WellAcquisition, +) +from faim_hcs.hcs.imagexpress import ( + ImageXpressPlateAcquisition, + SinglePlaneAcquisition, + StackAcquisition, +) + + +@pytest.fixture +def acquisition_dir(): + return Path(__file__).parent.parent.parent.parent / "resources" / "Projection-Mix" + + +@pytest.fixture +def single_plane_acquisition(acquisition_dir): + return SinglePlaneAcquisition(acquisition_dir, alignment=TileAlignmentOptions.GRID) + + +def test_single_plane_acquistion(single_plane_acquisition: PlateAcquisition): + wells = single_plane_acquisition.get_well_acquisitions() + + assert wells is not None + assert len(wells) == 2 + # MIPs: 2 wells * 2 fields * 3 channels = 12 files + assert len(single_plane_acquisition._files) == 12 + + channels = single_plane_acquisition.get_channel_metadata() + assert len(channels) == 3 + ch = channels[0] + assert ch.channel_index == 0 + assert ch.channel_name == "Maximum-Projection_FITC_05" + assert ch.display_color == "73ff00" + assert ch.exposure_time == 15.0 + assert ch.exposure_time_unit == "ms" + assert ch.objective == "20X Plan Apo Lambda" + assert ch.spatial_calibration_units == "um" + assert ch.spatial_calibration_x == 1.3668 + assert ch.spatial_calibration_y == 1.3668 + assert ch.wavelength == 536 + assert ch.z_spacing is None + + ch = channels[1] + assert ch.channel_index == 1 + assert ch.channel_name == "Best-Focus-Projection_FITC_05" + assert ch.display_color == "73ff00" + assert ch.exposure_time == 15.0 + assert ch.exposure_time_unit == "ms" + assert ch.objective == "20X Plan Apo Lambda" + assert ch.spatial_calibration_units == "um" + assert ch.spatial_calibration_x == 1.3668 + assert ch.spatial_calibration_y == 1.3668 + assert ch.wavelength == 536 + assert ch.z_spacing is None + + ch = channels[2] + assert ch.channel_index == 2 + assert ch.channel_name == "Maximum-Projection_FITC_05" + assert ch.display_color == "73ff00" + assert ch.exposure_time == 15.0 + assert ch.exposure_time_unit == "ms" + assert ch.objective == "20X Plan Apo Lambda" + assert ch.spatial_calibration_units == "um" + assert ch.spatial_calibration_x == 1.3668 + assert ch.spatial_calibration_y == 1.3668 + assert ch.wavelength == 536 + assert ch.z_spacing is None + + for well in single_plane_acquisition.get_well_acquisitions(): + assert isinstance(well, WellAcquisition) + assert len(well.get_tiles()) == 6 + for tile in well.get_tiles(): + assert tile.position.time == 0 + assert tile.position.channel in [0, 1, 2] + assert tile.position.z == 0 + assert tile.position.y in [0] + assert tile.position.x in [0, 512] + assert tile.shape == (512, 512) + + +@pytest.fixture +def stack_acquisition(acquisition_dir): + return StackAcquisition(acquisition_dir, alignment=TileAlignmentOptions.GRID) + + +def test_stack_acquistion(stack_acquisition: PlateAcquisition): + wells = stack_acquisition.get_well_acquisitions() + + assert wells is not None + assert len(wells) == 2 + # Full Stacks: 2 wells * 2 fields * 2 channels * 10 planes = 80 files + # Single plane in stack: 2 wells * 2 fields * 1 channel * 1 plane = 4 files + # Total of 84 files. + # There are additionally 12 MIP files in the directory, but these are + # ignored in this setup. + assert len(stack_acquisition._files) == 84 + + channels = stack_acquisition.get_channel_metadata() + assert len(channels) == 3 + ch = channels[0] + assert ch.channel_index == 0 + assert ch.channel_name == "FITC_05" + assert ch.display_color == "73ff00" + assert ch.exposure_time == 15.0 + assert ch.exposure_time_unit == "ms" + assert ch.objective == "20X Plan Apo Lambda" + assert ch.spatial_calibration_units == "um" + assert ch.spatial_calibration_x == 1.3668 + assert ch.spatial_calibration_y == 1.3668 + assert ch.wavelength == 536 + assert_almost_equal(ch.z_spacing, 5.0, decimal=4) + + ch = channels[1] + assert ch.channel_index == 1 + assert ch.channel_name == "FITC_05" + assert ch.display_color == "73ff00" + assert ch.exposure_time == 15.0 + assert ch.exposure_time_unit == "ms" + assert ch.objective == "20X Plan Apo Lambda" + assert ch.spatial_calibration_units == "um" + assert ch.spatial_calibration_x == 1.3668 + assert ch.spatial_calibration_y == 1.3668 + assert ch.wavelength == 536 + assert_almost_equal(ch.z_spacing, 5.0, decimal=4) + + ch = channels[3] + assert ch.channel_index == 3 + assert ch.channel_name == "FITC_05" + assert ch.display_color == "73ff00" + assert ch.exposure_time == 15.0 + assert ch.exposure_time_unit == "ms" + assert ch.objective == "20X Plan Apo Lambda" + assert ch.spatial_calibration_units == "um" + assert ch.spatial_calibration_x == 1.3668 + assert ch.spatial_calibration_y == 1.3668 + assert ch.wavelength == 536 + assert_almost_equal(ch.z_spacing, 5.0, decimal=4) + + for well in stack_acquisition.get_well_acquisitions(): + assert isinstance(well, WellAcquisition) + assert len(well.get_tiles()) == 42 + for tile in well.get_tiles(): + assert tile.position.time == 0 + assert tile.position.channel in [0, 1, 3] + assert tile.position.channel not in [4] + assert tile.position.z in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + assert tile.position.y in [0] + assert tile.position.x in [0, 512] + assert tile.shape == (512, 512) + + +@pytest.fixture +def mixed_acquisition(acquisition_dir): + return imagexpress.MixedAcquisition( + acquisition_dir, + alignment=TileAlignmentOptions.GRID, + ) + + +def test_mixed_acquisition(mixed_acquisition: PlateAcquisition): + wells = mixed_acquisition.get_well_acquisitions() + + assert wells is not None + assert len(wells) == 2 + # Stacks: 2 wells * 2 fields * 2 channels * 10 z-steps = 80 files + # Single Plane: 2 wells * 2 fields * 1 channel = 4 files + # MIP: 2 wells * 2 fields * 1 channel = 4 files + # There are additionally 8 files for the MIPs of the stacks + # (2 wells * 2 fields * 2 channels). But these are ignored. + assert len(mixed_acquisition._files) == 80 + 4 + 4 + + channels = mixed_acquisition.get_channel_metadata() + assert len(channels) == 4 + ch = channels[0] + assert ch.channel_index == 0 + assert ch.channel_name == "FITC_05" + assert ch.display_color == "73ff00" + assert ch.exposure_time == 15.0 + assert ch.exposure_time_unit == "ms" + assert ch.objective == "20X Plan Apo Lambda" + assert ch.spatial_calibration_units == "um" + assert ch.spatial_calibration_x == 1.3668 + assert ch.spatial_calibration_y == 1.3668 + assert ch.wavelength == 536 + assert_almost_equal(ch.z_spacing, 5.0, decimal=4) + + ch = channels[1] + assert ch.channel_index == 1 + assert ch.channel_name == "FITC_05" + assert ch.display_color == "73ff00" + assert ch.exposure_time == 15.0 + assert ch.exposure_time_unit == "ms" + assert ch.objective == "20X Plan Apo Lambda" + assert ch.spatial_calibration_units == "um" + assert ch.spatial_calibration_x == 1.3668 + assert ch.spatial_calibration_y == 1.3668 + assert ch.wavelength == 536 + assert_almost_equal(ch.z_spacing, 5.0, decimal=4) + + ch = channels[2] + assert ch.channel_index == 2 + assert ch.channel_name == "Maximum-Projection_FITC_05" + assert ch.display_color == "73ff00" + assert ch.exposure_time == 15.0 + assert ch.exposure_time_unit == "ms" + assert ch.objective == "20X Plan Apo Lambda" + assert ch.spatial_calibration_units == "um" + assert ch.spatial_calibration_x == 1.3668 + assert ch.spatial_calibration_y == 1.3668 + assert ch.wavelength == 536 + assert_almost_equal(ch.z_spacing, 5.0, decimal=4) + + ch = channels[3] + assert ch.channel_index == 3 + assert ch.channel_name == "FITC_05" + assert ch.display_color == "73ff00" + assert ch.exposure_time == 15.0 + assert ch.exposure_time_unit == "ms" + assert ch.objective == "20X Plan Apo Lambda" + assert ch.spatial_calibration_units == "um" + assert ch.spatial_calibration_x == 1.3668 + assert ch.spatial_calibration_y == 1.3668 + assert ch.wavelength == 536 + assert_almost_equal(ch.z_spacing, 5.0, decimal=4) + + for well in mixed_acquisition.get_well_acquisitions(): + assert isinstance(well, WellAcquisition) + assert len(well.get_tiles()) == 44 + for tile in well.get_tiles(): + assert tile.position.time == 0 + assert tile.position.channel in [0, 1, 2, 3] + assert tile.position.z in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + assert tile.position.y in [0] + assert tile.position.x in [0, 512] + assert tile.shape == (512, 512) + + +@pytest.fixture +def dummy_plate(): + ImageXpressPlateAcquisition.__abstractmethods__ = set() + + @dataclass + class Plate(ImageXpressPlateAcquisition): + pass + + return Plate() + + +def test_raise_not_implemented_error(dummy_plate): + with pytest.raises(NotImplementedError): + dummy_plate._get_root_re() + + with pytest.raises(NotImplementedError): + dummy_plate._get_filename_re() + + with pytest.raises(NotImplementedError): + dummy_plate._get_z_spacing() diff --git a/tests/hcs/imagexpress/test_ImageXpressWellAcquisition.py b/tests/hcs/imagexpress/test_ImageXpressWellAcquisition.py new file mode 100644 index 00000000..2afa6f9d --- /dev/null +++ b/tests/hcs/imagexpress/test_ImageXpressWellAcquisition.py @@ -0,0 +1,125 @@ +import os +from os.path import join +from pathlib import Path + +import pandas as pd +import pytest + +from faim_hcs.hcs.acquisition import TileAlignmentOptions +from faim_hcs.hcs.imagexpress import ImageXpressWellAcquisition + + +@pytest.fixture +def files() -> pd.DataFrame: + resource_dir = Path(__file__).parent.parent.parent.parent + files = pd.read_csv(join(Path(__file__).parent, "files.csv"), index_col=0) + + files["path"] = files.apply(lambda row: join(resource_dir, row["path"]), axis=1) + + return files + + +def test__assemble_tiles(files): + ix_well_acquisition = ImageXpressWellAcquisition( + files=files, + alignment=TileAlignmentOptions.GRID, + z_spacing=3.0, + ) + + tiles = ix_well_acquisition._assemble_tiles() + assert len(tiles) == 42 + for tile in tiles: + assert os.path.exists(tile.path) + assert tile.shape == (512, 512) + assert tile.position.channel in [1, 2, 4] + assert tile.position.time == 0 + assert tile.position.z in [ + 3106, + 3107, + 3109, + 3111, + 3112, + 3114, + 3116, + 3117, + 3119, + 3121, + ] + + +def test_get_axes(files): + ix_well_acquisition = ImageXpressWellAcquisition( + files=files, + alignment=TileAlignmentOptions.GRID, + z_spacing=3.0, + ) + + axes = ix_well_acquisition.get_axes() + assert axes == ["c", "z", "y", "x"] + + ix_well_acquisition = ImageXpressWellAcquisition( + files=files.drop("z", axis=1), + alignment=TileAlignmentOptions.GRID, + z_spacing=None, + ) + + axes = ix_well_acquisition.get_axes() + assert axes == ["c", "y", "x"] + + +def test_get_yx_spacing(files): + ix_well_acquisition = ImageXpressWellAcquisition( + files=files, + alignment=TileAlignmentOptions.GRID, + z_spacing=3.0, + ) + + yx_spacing = ix_well_acquisition.get_yx_spacing() + assert yx_spacing == (1.3668, 1.3668) + + +def test_get_z_spacing(files): + ix_well_acquisition = ImageXpressWellAcquisition( + files=files, + alignment=TileAlignmentOptions.GRID, + z_spacing=3.0, + ) + assert ix_well_acquisition.get_z_spacing() == 3.0 + + +def test_bgcm(files): + ix_well_acquisition = ImageXpressWellAcquisition( + files=files, + alignment=TileAlignmentOptions.GRID, + z_spacing=3.0, + background_correction_matrices={"w1": "bgcm1", "w2": "bgcm2", "w4": "bgcm4"}, + ) + tiles = ix_well_acquisition._assemble_tiles() + for tile in tiles: + if tile.position.channel == 1: + assert tile.background_correction_matrix_path == "bgcm1" + elif tile.position.channel == 2: + assert tile.background_correction_matrix_path == "bgcm2" + elif tile.position.channel == 4: + assert tile.background_correction_matrix_path == "bgcm4" + else: + assert tile.background_correction_matrix_path is None + + +def test_icm(files): + ix_well_acquisition = ImageXpressWellAcquisition( + files=files, + alignment=TileAlignmentOptions.GRID, + z_spacing=3.0, + illumination_correction_matrices={"w1": "icm1", "w2": "icm2", "w4": "icm4"}, + ) + tiles = ix_well_acquisition._assemble_tiles() + for tile in tiles: + if tile.position.channel == 1: + assert tile.illumination_correction_matrix_path == "icm1" + elif tile.position.channel == 2: + assert tile.illumination_correction_matrix_path == "icm2" + elif tile.position.channel == 4: + assert tile.illumination_correction_matrix_path == "icm4" + else: + assert tile.illumination_correction_matrix_path is None diff --git a/tests/hcs/test_acquisition.py b/tests/hcs/test_acquisition.py new file mode 100644 index 00000000..798adcc4 --- /dev/null +++ b/tests/hcs/test_acquisition.py @@ -0,0 +1,413 @@ +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from faim_hcs.hcs.acquisition import ( + PlateAcquisition, + TileAlignmentOptions, + WellAcquisition, +) +from faim_hcs.io.ChannelMetadata import ChannelMetadata +from faim_hcs.stitching import Tile +from faim_hcs.stitching.Tile import TilePosition + + +@pytest.fixture +def dummy(): + PlateAcquisition.__abstractmethods__ = set() + + @dataclass + class Plate(PlateAcquisition): + _acquisition_dir = "acquisition_dir" + _files = pd.read_csv( + Path(__file__).parent / "imagexpress" / "files.csv", index_col=0 + ) + _alignment = TileAlignmentOptions.STAGE_POSITION + + return Plate() + + +def test_raise_not_implemented_error(dummy): + with pytest.raises(NotImplementedError): + dummy._parse_files() + + with pytest.raises(NotImplementedError): + dummy.get_well_acquisitions() + + with pytest.raises(NotImplementedError): + dummy.get_channel_metadata() + + +def test_get_well_names(dummy): + WellAcquisition.__abstractmethods__ = set() + + class DummyWell(WellAcquisition): + name = "A01" + + def _align_tiles(self, tiles: list[Tile]) -> list[Tile]: + pass + + def _assemble_tiles(self) -> list[Tile]: + pass + + def func(): + return [ + DummyWell( + files=pd.read_csv( + Path(__file__).parent / "imagexpress" / "files.csv", index_col=0 + ), + alignment=TileAlignmentOptions.GRID, + background_correction_matrices=None, + illumination_correction_matrices=None, + ) + ] + + dummy.get_well_acquisitions = func + + assert list(dummy.get_well_names()) == ["E07"] + + +def test_get_omero_channel_metadata(dummy): + dummy.get_channel_metadata = lambda: { + 1: ChannelMetadata( + channel_index=1, + channel_name="name", + display_color="FFFFFF", + spatial_calibration_x=1, + spatial_calibration_y=2, + spatial_calibration_units="um", + z_spacing=3, + wavelength=432, + exposure_time=0.1, + exposure_time_unit="ms", + objective="20x", + ), + 2: ChannelMetadata( + channel_index=2, + channel_name="no-name", + display_color="FFFFAA", + spatial_calibration_x=1, + spatial_calibration_y=2, + spatial_calibration_units="um", + z_spacing=3, + wavelength=432, + exposure_time=0.1, + exposure_time_unit="ms", + objective="20x", + ), + } + + ome_metadata = dummy.get_omero_channel_metadata() + assert len(ome_metadata) == 3 + assert ome_metadata[0] == { + "active": False, + "coefficient": 1, + "color": "#000000", + "family": "linear", + "inverted": False, + "label": "empty", + "wavelength_id": "C01", + "window": { + "min": np.iinfo(np.uint16).min, + "max": np.iinfo(np.uint16).max, + "start": np.iinfo(np.uint16).min, + "end": np.iinfo(np.uint16).max, + }, + } + assert ome_metadata[1] == { + "active": True, + "coefficient": 1, + "color": "FFFFFF", + "family": "linear", + "inverted": False, + "label": "name", + "wavelength_id": "C02", + "window": { + "min": np.iinfo(np.uint16).min, + "max": np.iinfo(np.uint16).max, + "start": np.iinfo(np.uint16).min, + "end": np.iinfo(np.uint16).max, + }, + } + assert ome_metadata[2] == { + "active": True, + "coefficient": 1, + "color": "FFFFAA", + "family": "linear", + "inverted": False, + "label": "no-name", + "wavelength_id": "C03", + "window": { + "min": np.iinfo(np.uint16).min, + "max": np.iinfo(np.uint16).max, + "start": np.iinfo(np.uint16).min, + "end": np.iinfo(np.uint16).max, + }, + } + + +def test_get_common_well_shape(dummy): + WellAcquisition.__abstractmethods__ = set() + + class DummyWellA(WellAcquisition): + name = "A01" + + def _align_tiles(self, tiles: list[Tile]) -> list[Tile]: + pass + + def _assemble_tiles(self) -> list[Tile]: + pass + + def get_shape(self): + return (1, 1, 3, 11, 13) + + class DummyWellB(WellAcquisition): + name = "A01" + + def _align_tiles(self, tiles: list[Tile]) -> list[Tile]: + pass + + def _assemble_tiles(self) -> list[Tile]: + pass + + def get_shape(self): + return (1, 1, 3, 10, 23) + + def func(): + return [ + DummyWellA( + files=pd.read_csv( + Path(__file__).parent / "imagexpress" / "files.csv", index_col=0 + ), + alignment=TileAlignmentOptions.GRID, + background_correction_matrices=None, + illumination_correction_matrices=None, + ), + DummyWellB( + files=pd.read_csv( + Path(__file__).parent / "imagexpress" / "files.csv", index_col=0 + ), + alignment=TileAlignmentOptions.GRID, + background_correction_matrices=None, + illumination_correction_matrices=None, + ), + ] + + dummy.get_well_acquisitions = func + + assert dummy.get_common_well_shape() == (1, 1, 3, 11, 23) + + +@pytest.fixture +def dummy_well(): + WellAcquisition.__abstractmethods__ = set() + + @dataclass + class Well(WellAcquisition): + name = "A01" + _files = pd.read_csv( + Path(__file__).parent / "imagexpress" / "files.csv", index_col=0 + ) + _alignment = TileAlignmentOptions.STAGE_POSITION + + return Well() + + +def test_raise_not_implemented_errors(dummy_well): + with pytest.raises(NotImplementedError): + dummy_well._assemble_tiles() + + with pytest.raises(NotImplementedError): + dummy_well.get_axes() + + with pytest.raises(NotImplementedError): + dummy_well.get_yx_spacing() + + with pytest.raises(NotImplementedError): + dummy_well.get_z_spacing() + + +def test_get_coordiante_transformations_3d(dummy_well): + dummy_well.get_z_spacing = lambda: 1.0 + dummy_well.get_yx_spacing = lambda: (2.0, 3.0) + ct = dummy_well.get_coordinate_transformations( + max_layer=2, + yx_binning=1, + ) + assert len(ct) == 3 + assert ct[0] == [ + { + "scale": [ + 1.0, + 1.0, + 2.0, + 3.0, + ], + "type": "scale", + } + ] + assert ct[1] == [ + { + "scale": [ + 1.0, + 1.0, + 4.0, + 6.0, + ], + "type": "scale", + } + ] + assert ct[2] == [ + { + "scale": [ + 1.0, + 1.0, + 8.0, + 12.0, + ], + "type": "scale", + } + ] + + +def test_get_coordiante_transformations_2d(dummy_well): + dummy_well.get_z_spacing = lambda: None + dummy_well.get_yx_spacing = lambda: (2.0, 3.0) + ct = dummy_well.get_coordinate_transformations( + max_layer=2, + yx_binning=1, + ) + assert len(ct) == 3 + assert ct[0] == [ + { + "scale": [ + 1.0, + 2.0, + 3.0, + ], + "type": "scale", + } + ] + assert ct[1] == [ + { + "scale": [ + 1.0, + 4.0, + 6.0, + ], + "type": "scale", + } + ] + assert ct[2] == [ + { + "scale": [ + 1.0, + 8.0, + 12.0, + ], + "type": "scale", + } + ] + + +def test_get_dtype(dummy_well): + dummy_well._tiles = [ + Tile( + path=Path(__file__).parent.parent.parent + / "resources" + / "CV8000" + / "CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839" + / "CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack" + / "CV8000-Minimal-DataSet-2C-3W-4S-FP2" + "-stack_D08_T0001F001L01A01Z01C01.tif", + shape=(2000, 2000), + position=TilePosition(time=0, channel=0, z=0, y=0, x=0), + ) + ] + + assert dummy_well.get_dtype() == np.uint16 + + +def test_get_row_col(dummy_well): + assert dummy_well.get_row_col() == ("A", "01") + + +def test_align_tiles(dummy_well): + dummy_well._alignment = TileAlignmentOptions.STAGE_POSITION + dummy_well._tiles = [ + Tile( + path=Path(__file__).parent.parent.parent + / "resources" + / "CV8000" + / "CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839" + / "CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack" + / "CV8000-Minimal-DataSet-2C-3W-4S-FP2" + "-stack_D08_T0001F001L01A01Z01C01.tif", + shape=(2000, 2000), + position=TilePosition(time=1, channel=2, z=0, y=3, x=-70), + ) + ] + + aligned = dummy_well._align_tiles(dummy_well._tiles) + + assert len(aligned) == len(dummy_well._tiles) + assert aligned[0].position.time == 0 + assert aligned[0].position.channel == 0 + assert aligned[0].position.z == 0 + assert aligned[0].position.x == 0 + assert aligned[0].position.y == 0 + + assert dummy_well.get_dtype() == np.uint16 + + dummy_well._alignment = TileAlignmentOptions.GRID + dummy_well._tiles = [ + Tile( + path=Path(__file__).parent.parent.parent + / "resources" + / "CV8000" + / "CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839" + / "CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack" + / "CV8000-Minimal-DataSet-2C-3W-4S-FP2" + "-stack_D08_T0001F001L01A01Z01C01.tif", + shape=(2000, 2000), + position=TilePosition(time=1, channel=2, z=0, y=3, x=-70), + ) + ] + + aligned = dummy_well._align_tiles(dummy_well._tiles) + + assert len(aligned) == len(dummy_well._tiles) + assert aligned[0].position.time == 0 + assert aligned[0].position.channel == 0 + assert aligned[0].position.z == 0 + assert aligned[0].position.x == 0 + assert aligned[0].position.y == 0 + + assert dummy_well.get_dtype() == np.uint16 + + +def test_alignment_not_implemented(dummy_well): + dummy_well._alignment = "Unknown" + with pytest.raises(ValueError): + dummy_well._align_tiles(dummy_well._align_tiles(dummy_well._tiles)) + + +def test_get_shape(dummy_well): + dummy_well._tiles = [ + Tile( + path=Path(__file__).parent.parent.parent + / "resources" + / "CV8000" + / "CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839" + / "CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack" + / "CV8000-Minimal-DataSet-2C-3W-4S-FP2" + "-stack_D08_T0001F001L01A01Z01C01.tif", + shape=(2000, 2000), + position=TilePosition(time=0, channel=0, z=0, y=0, x=0), + ) + ] + + assert dummy_well.get_shape() == (1, 1, 1, 2000, 2000) diff --git a/tests/hcs/test_converter.py b/tests/hcs/test_converter.py new file mode 100644 index 00000000..d7056f56 --- /dev/null +++ b/tests/hcs/test_converter.py @@ -0,0 +1,207 @@ +from os.path import exists, join +from pathlib import Path + +import dask +import numpy as np +import pytest +import zarr +from numcodecs import Blosc + +from faim_hcs.hcs.acquisition import TileAlignmentOptions +from faim_hcs.hcs.cellvoyager import StackAcquisition +from faim_hcs.hcs.converter import ConvertToNGFFPlate, NGFFPlate +from faim_hcs.hcs.plate import PlateLayout + + +def test_NGFFPlate(): + root_dir = "/path/to/root_dir" + name = "plate_name" + layout = PlateLayout.I18 + order_name = "order_name" + barcode = "barcode" + plate = NGFFPlate( + root_dir=root_dir, + name=name, + layout=layout, + order_name=order_name, + barcode=barcode, + ) + assert plate.root_dir == Path(root_dir) + assert plate.name == name + assert plate.layout == layout + assert plate.order_name == order_name + assert plate.barcode == barcode + + +@pytest.fixture +def tmp_dir(tmpdir_factory): + return tmpdir_factory.mktemp("hcs_plate") + + +@pytest.fixture +def hcs_plate(tmp_dir): + return NGFFPlate( + root_dir=tmp_dir, + name="plate_name", + layout=PlateLayout.I96, + order_name="order_name", + barcode="barcode", + ) + + +@pytest.fixture +def plate_acquisition(): + return StackAcquisition( + acquisition_dir=Path(__file__).parent.parent.parent + / "resources" + / "CV8000" + / "CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839" + / "CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack", + alignment=TileAlignmentOptions.GRID, + ) + + +def test__create_zarr_plate(tmp_dir, plate_acquisition, hcs_plate): + converter = ConvertToNGFFPlate(hcs_plate) + zarr_plate = converter._create_zarr_plate(plate_acquisition) + + assert exists(join(tmp_dir, "plate_name.zarr")) + assert zarr_plate.attrs["plate"]["name"] == "plate_name" + assert zarr_plate.attrs["order_name"] == "order_name" + assert zarr_plate.attrs["barcode"] == "barcode" + assert zarr_plate.attrs["plate"]["field_count"] == 1 + assert zarr_plate.attrs["plate"]["wells"] == [ + {"columnIndex": 7, "path": "D/08", "rowIndex": 3}, + {"columnIndex": 2, "path": "E/03", "rowIndex": 4}, + {"columnIndex": 7, "path": "F/08", "rowIndex": 5}, + ] + assert exists(join(tmp_dir, "plate_name.zarr", ".zgroup")) + assert exists(join(tmp_dir, "plate_name.zarr", ".zattrs")) + assert not exists(join(tmp_dir, "plate_name.zarr", "D")) + assert not exists(join(tmp_dir, "plate_name.zarr", "E")) + assert not exists(join(tmp_dir, "plate_name.zarr", "F")) + + zarr_plate_1 = converter._create_zarr_plate(plate_acquisition) + assert zarr_plate_1 == zarr_plate + + +def test__out_chunks(): + out_chunks = ConvertToNGFFPlate._out_chunks( + shape=(1, 2, 5, 10, 10), + chunks=(1, 1, 5, 10, 5), + ) + assert out_chunks == (1, 1, 5, 10, 5) + + out_chunks = ConvertToNGFFPlate._out_chunks( + shape=(1, 2, 5, 10, 10), + chunks=(5, 10, 10), + ) + assert out_chunks == (1, 1, 5, 10, 10) + + +def test__get_storage_options(): + storage_options = ConvertToNGFFPlate._get_storage_options( + storage_options=None, + output_shape=(1, 2, 5, 10, 10), + chunks=(5, 10, 5), + ) + assert storage_options == { + "dimension_separator": "/", + "compressor": Blosc(cname="zstd", clevel=6, shuffle=Blosc.BITSHUFFLE), + "chunks": (1, 1, 5, 10, 5), + "write_empty_chunks": False, + } + + storage_options = ConvertToNGFFPlate._get_storage_options( + storage_options={ + "dimension_separator": ".", + }, + output_shape=(1, 2, 5, 10, 10), + chunks=(5, 10, 5), + ) + assert storage_options == { + "dimension_separator": ".", + } + + +def test__mean_cast_to(): + mean_cast_to = ConvertToNGFFPlate._mean_cast_to(np.uint8) + input = np.array([1.0, 2.0], dtype=np.float32) + assert input.dtype == np.float32 + assert mean_cast_to(input).dtype == np.uint8 + assert mean_cast_to(input) == 1 + + +def test__create_well_group(tmp_dir, plate_acquisition, hcs_plate): + converter = ConvertToNGFFPlate(hcs_plate) + zarr_plate = converter._create_zarr_plate(plate_acquisition) + well_group = converter._create_well_group( + plate=zarr_plate, + well_acquisition=plate_acquisition.get_well_acquisitions()[0], + well_sub_group="0", + ) + assert exists(join(tmp_dir, "plate_name.zarr", "D", "08", "0")) + assert isinstance(well_group, zarr.Group) + + +def test__stitch_well_image(tmp_dir, plate_acquisition, hcs_plate): + converter = ConvertToNGFFPlate(hcs_plate) + well_acquisition = plate_acquisition.get_well_acquisitions()[0] + well_img_da = converter._stitch_well_image( + chunks=(1, 1, 10, 1000, 1000), + well_acquisition=well_acquisition, + output_shape=plate_acquisition.get_common_well_shape(), + ) + assert isinstance(well_img_da, dask.array.core.Array) + assert well_img_da.shape == (1, 2, 4, 2000, 2000) + assert well_img_da.dtype == np.uint16 + + +def test__bin_yx(tmp_dir, plate_acquisition, hcs_plate): + converter = ConvertToNGFFPlate( + hcs_plate, + yx_binning=2, + ) + well_acquisition = plate_acquisition.get_well_acquisitions()[0] + well_img_da = converter._stitch_well_image( + chunks=(1, 1, 10, 1000, 1000), + well_acquisition=well_acquisition, + output_shape=plate_acquisition.get_common_well_shape(), + ) + binned_yx = converter._bin_yx(well_img_da) + assert isinstance(binned_yx, dask.array.core.Array) + assert binned_yx.shape == (1, 2, 4, 1000, 1000) + assert binned_yx.dtype == np.uint16 + + converter._yx_binning = 1 + binned_yx = converter._bin_yx(well_img_da) + assert isinstance(binned_yx, dask.array.core.Array) + assert binned_yx.shape == (1, 2, 4, 2000, 2000) + assert binned_yx.dtype == np.uint16 + + +def test_run(tmp_dir, plate_acquisition, hcs_plate): + converter = ConvertToNGFFPlate( + hcs_plate, + yx_binning=2, + ) + plate = converter.run(plate_acquisition, max_layer=2) + for well in ["D08", "E03", "F08"]: + row, col = well[0], well[1:] + path = join(tmp_dir, "plate_name.zarr", row, col, "0") + assert exists(path) + + assert exists(join(path, "0")) + assert exists(join(path, "1")) + assert exists(join(path, ".zattrs")) + assert exists(join(path, ".zgroup")) + + assert "acquisition_metadata" in plate[row][col]["0"].attrs.keys() + assert "multiscales" in plate[row][col]["0"].attrs.keys() + assert "omero" in plate[row][col]["0"].attrs.keys() + + assert exists(join(path, "0", ".zarray")) + assert exists(join(path, "1", ".zarray")) + + assert plate[row][col]["0"]["0"].shape == (2, 4, 1000, 1000) + assert plate[row][col]["0"]["1"].shape == (2, 4, 500, 500) diff --git a/tests/hcs/test_plate.py b/tests/hcs/test_plate.py new file mode 100644 index 00000000..f36b423f --- /dev/null +++ b/tests/hcs/test_plate.py @@ -0,0 +1,86 @@ +import pytest + +from faim_hcs.hcs.plate import PlateLayout, get_rows_and_columns + + +def test_plate_layout(): + assert PlateLayout.I18 == 18 + assert PlateLayout.I24 == 24 + assert PlateLayout.I96 == 96 + assert PlateLayout.I384 == 384 + + +def test_get_rows_and_columns(): + rows, cols = get_rows_and_columns(PlateLayout.I18) + assert rows == ["A", "B", "C"] + assert cols == ["01", "02", "03", "04", "05", "06"] + + rows, cols = get_rows_and_columns(PlateLayout.I24) + assert rows == ["A", "B", "C", "D"] + assert cols == ["01", "02", "03", "04", "05", "06"] + + rows, cols = get_rows_and_columns(PlateLayout.I96) + assert rows == ["A", "B", "C", "D", "E", "F", "G", "H"] + assert cols == [ + "01", + "02", + "03", + "04", + "05", + "06", + "07", + "08", + "09", + "10", + "11", + "12", + ] + + rows, cols = get_rows_and_columns(PlateLayout.I384) + assert rows == [ + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + ] + assert cols == [ + "01", + "02", + "03", + "04", + "05", + "06", + "07", + "08", + "09", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "19", + "20", + "21", + "22", + "23", + "24", + ] + + with pytest.raises(NotImplementedError): + get_rows_and_columns("I42") diff --git a/tests/io/test_MetaSeriesTiff.py b/tests/io/test_MetaSeriesTiff.py index fee3f9bc..2f2d9a6e 100644 --- a/tests/io/test_MetaSeriesTiff.py +++ b/tests/io/test_MetaSeriesTiff.py @@ -35,7 +35,7 @@ def test_load_metaseries_tiff(self): assert metadata["spatial-calibration-units"] == "um" assert metadata["stage-position-x"] == 79813.4 assert metadata["stage-position-y"] == 41385.4 - assert metadata["z-position"] == 9343.19 + assert metadata["stage-position-z"] == 9318.24 assert metadata["PixelType"] == "uint16" assert metadata["_MagNA_"] == 0.75 assert metadata["_MagSetting_"] == "20X Plan Apo Lambda" diff --git a/tests/io/test_MolecularDevicesImageXpress.py b/tests/io/test_MolecularDevicesImageXpress.py deleted file mode 100644 index 187edd6c..00000000 --- a/tests/io/test_MolecularDevicesImageXpress.py +++ /dev/null @@ -1,83 +0,0 @@ -# SPDX-FileCopyrightText: 2023 Friedrich Miescher Institute for Biomedical Research (FMI), Basel (Switzerland) -# -# SPDX-License-Identifier: BSD-3-Clause - -import unittest -from os.path import exists, join -from pathlib import Path - -from faim_hcs.io.MolecularDevicesImageXpress import ( - parse_files, - parse_multi_field_stacks, - parse_single_plane_multi_fields, -) - -ROOT_DIR = Path(__file__).parent.parent - - -class TestMolecularDevicesImageXpress(unittest.TestCase): - def test_parse_single_plane_multi_fields(self): - acquisition_dir = join(ROOT_DIR.parent, "resources", "Projection-Mix") - - files = parse_single_plane_multi_fields(acquisition_dir=acquisition_dir) - - assert len(files) == 12 - assert files["name"].unique() == ["Projection-Mix"] - self.assertCountEqual(files["well"].unique(), ["E07", "E08"]) - self.assertCountEqual(files["field"].unique(), ["s1", "s2"]) - self.assertCountEqual(files["channel"].unique(), ["w1", "w2", "w3"]) - for item in files["path"]: - assert exists(item) - assert "thumb" not in item - - def test_parse_multi_field_stacks(self): - acquisition_dir = ROOT_DIR.parent / "resources" / "Projection-Mix" - - files = parse_multi_field_stacks(acquisition_dir=acquisition_dir) - - assert len(files) == 84 - assert files["name"].unique() == ["Projection-Mix"] - self.assertCountEqual(files["well"].unique(), ["E07", "E08"]) - self.assertCountEqual(files["field"].unique(), ["s1", "s2"]) - self.assertCountEqual(files["channel"].unique(), ["w1", "w2", "w4"]) - - for item in files["path"]: - assert exists(item) - assert "thumb" not in item - - def test_parse_files(self): - acquisition_dir = ROOT_DIR.parent / "resources" / "Projection-Mix" - - files = parse_files(acquisition_dir=acquisition_dir) - - assert len(files) == (2 * 2 * (10 + 1)) + (2 * 2 * (10 + 1)) + (2 * 2 * 1) + ( - 2 * 2 * 1 - ) - assert files["name"].unique() == ["Projection-Mix"] - assert len(files[files["channel"] == "w1"]) == 2 * 2 * (10 + 1) - assert len(files[files["channel"] == "w2"]) == 2 * 2 * (10 + 1) - assert len(files[files["channel"] == "w3"]) == 2 * 2 - assert len(files[files["channel"] == "w4"]) == 2 * 2 - - assert ( - len(files[files["z"].isnull()]) == 2 * 2 * 3 - ) # 2 well, 2 fields, 3 channels (1,2,3) - assert ( - len(files[files["z"] == "1"]) == 2 * 2 * 3 - ) # 2 well, 2 fields, 3 channels (1,2,4) - assert ( - len(files[files["z"] == "10"]) == 2 * 2 * 2 - ) # 2 well, 2 fields, 2 channels (1,2) - - assert sorted(files[~files["z"].isnull()]["z"].unique(), key=int) == [ - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - "10", - ] diff --git a/tests/io/test_YokogawaCellVoyager.py b/tests/io/test_YokogawaCellVoyager.py deleted file mode 100644 index 9ce97256..00000000 --- a/tests/io/test_YokogawaCellVoyager.py +++ /dev/null @@ -1,22 +0,0 @@ -from pathlib import Path - -import pytest - -from faim_hcs.io.YokogawaCellVoyager import parse_files - - -@pytest.fixture -def acquisition_dir(): - return ( - Path(__file__).parent.parent.parent - / "resources" - / "CV8000" - / "CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839" - / "CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack" - ) - - -def test_parse_files(acquisition_dir): - files = parse_files(acquisition_dir=acquisition_dir) - assert len(files) == 96 - assert len(files.columns) == 13 diff --git a/tests/roitable/test_FractalROITable.py b/tests/roitable/test_FractalROITable.py new file mode 100644 index 00000000..5f2a70e8 --- /dev/null +++ b/tests/roitable/test_FractalROITable.py @@ -0,0 +1,150 @@ +from os.path import join +from pathlib import Path + +import pytest +import zarr +from numpy.testing import assert_array_almost_equal +from ome_zarr.io import parse_url + +from faim_hcs.hcs.acquisition import TileAlignmentOptions +from faim_hcs.hcs.imagexpress import StackAcquisition +from faim_hcs.roitable.FractalROITable import ( + create_fov_ROI_table, + create_ROI_tables, + create_well_ROI_table, + write_roi_table, +) + + +@pytest.fixture +def plate_acquisition(): + return StackAcquisition( + acquisition_dir=Path(__file__).parent.parent.parent + / "resources" + / "Projection-Mix", + alignment=TileAlignmentOptions.GRID, + ) + + +def test_create_fov_ROI_table(plate_acquisition): + tiles = plate_acquisition.get_well_acquisitions()[0].get_tiles() + roi_table = create_fov_ROI_table( + tiles=tiles, + columns=[ + "FieldIndex", + "x_micrometer", + "y_micrometer", + "z_micrometer", + "len_x_micrometer", + "len_y_micrometer", + "len_z_micrometer", + ], + calibration_dict={ + "spatial-calibration-x": 1.3668, + "spatial-calibration-y": 1.3668, + "spatial-calibration-z": 5.0, + }, + ) + target_values = [ + 0.0, + 0.0, + 0.0, + 699.8016, + 699.8016, + 50.0, + ] + assert roi_table.iloc[0].values.flatten().tolist() == target_values + target_values = [ + 699.8016, + 0.0, + 0.0, + 699.8016, + 699.8016, + 50.0, + ] + assert roi_table.iloc[1].values.flatten().tolist() == target_values + + +def test_create_well_ROI_table(plate_acquisition): + well_acquisition = plate_acquisition.get_well_acquisitions()[0] + roi_table = create_well_ROI_table( + well_acquisition=well_acquisition, + columns=[ + "FieldIndex", + "x_micrometer", + "y_micrometer", + "z_micrometer", + "len_x_micrometer", + "len_y_micrometer", + "len_z_micrometer", + ], + calibration_dict={ + "spatial-calibration-x": 1.3668, + "spatial-calibration-y": 1.3668, + "spatial-calibration-z": 5.0, + }, + ) + target_values = [ + 0.0, + 0.0, + 0.0, + 1399.6032, + 699.8016, + 50.0, + ] + assert roi_table.iloc[0].values.flatten().tolist() == target_values + + +def test_create_ROI_tables(plate_acquisition): + roi_tables = create_ROI_tables( + plate_acquistion=plate_acquisition, + calibration_dict={ + "spatial-calibration-x": 1.3668, + "spatial-calibration-y": 1.3668, + "spatial-calibration-z": 5.0, + }, + ) + assert len(roi_tables) == 2 + assert roi_tables["E07"]["FOV_ROI_table"].shape == (2, 6) + assert roi_tables["E07"]["well_ROI_table"].shape == (1, 6) + assert roi_tables["E08"]["FOV_ROI_table"].shape == (2, 6) + assert roi_tables["E08"]["well_ROI_table"].shape == (1, 6) + + +@pytest.fixture +def tmp_dir(tmpdir_factory): + return tmpdir_factory.mktemp("hcs_plate") + + +def test_write_roi_table(tmp_dir, plate_acquisition): + roi_tables = create_ROI_tables( + plate_acquistion=plate_acquisition, + calibration_dict={ + "spatial-calibration-x": 1.3668, + "spatial-calibration-y": 1.3668, + "spatial-calibration-z": 5.0, + }, + ) + + store = parse_url(join(tmp_dir, "tmp.zarr"), mode="w").store + group = zarr.group(store=store) + + write_roi_table( + roi_table=roi_tables["E07"]["FOV_ROI_table"], + table_name="FOV_ROI_table", + group=group, + ) + + import anndata as ad + + df_fov = ad.read_zarr( + join( + tmp_dir, + "tmp.zarr", + "tables", + "FOV_ROI_table", + ) + ).to_df() + assert_array_almost_equal( + df_fov.to_numpy(), roi_tables["E07"]["FOV_ROI_table"].to_numpy(), decimal=4 + ) diff --git a/tests/stitching/test_Tile.py b/tests/stitching/test_Tile.py index df0c2d28..c85a1f90 100644 --- a/tests/stitching/test_Tile.py +++ b/tests/stitching/test_Tile.py @@ -1,3 +1,8 @@ +import numpy as np +import pytest +from numpy.testing import assert_array_equal +from tifffile import imwrite + from faim_hcs.stitching import Tile from faim_hcs.stitching.Tile import TilePosition @@ -15,3 +20,76 @@ def test_fields(): assert tile.position.z == 0 assert tile.position.y == 0 assert tile.position.x == 0 + + assert ( + str(tile) + == "Tile(path='path', shape=(10, 10), position=TilePosition(time=0, channel=0, z=0, y=0, x=0))" + ) + + +@pytest.fixture +def tmp_dir(tmp_path_factory): + return tmp_path_factory.mktemp("tile") + + +@pytest.fixture +def test_img(tmp_dir): + img = np.random.rand(10, 10) * 255 + img = img.astype(np.uint8) + imwrite(tmp_dir / "test_img.tif", img) + return tmp_dir / "test_img.tif", img + + +@pytest.fixture +def bgcm(tmp_dir): + img = np.ones((10, 10)) * 50 + img = img.astype(np.uint8) + imwrite(tmp_dir / "bgcm.tif", img) + return tmp_dir / "bgcm.tif", img + + +@pytest.fixture +def icm(tmp_dir): + img = np.ones((10, 10)) * 2 + img = img.astype(np.uint8) + imwrite(tmp_dir / "icm.tif", img) + return tmp_dir / "icm.tif", img + + +def test_load_data(test_img, bgcm, icm): + tile = Tile( + path=test_img[0], + shape=(10, 10), + position=TilePosition(time=0, channel=0, z=0, y=0, x=0), + ) + + assert_array_equal(tile.load_data(), test_img[1]) + + tile = Tile( + path=test_img[0], + shape=(10, 10), + position=TilePosition(time=0, channel=0, z=0, y=0, x=0), + background_correction_matrix_path=bgcm[0], + ) + + assert_array_equal(tile.load_data(), test_img[1] - bgcm[1]) + + tile = Tile( + path=test_img[0], + shape=(10, 10), + position=TilePosition(time=0, channel=0, z=0, y=0, x=0), + illumination_correction_matrix_path=icm[0], + ) + + assert_array_equal(tile.load_data(), (test_img[1] / icm[1]).astype(np.uint8)) + + +def test_get_position(): + tile = Tile( + path="path", + shape=(10, 10), + position=TilePosition(time=10, channel=20, z=-1, y=2, x=7), + ) + assert tile.get_position() == (10, 20, -1, 2, 7) + assert tile.get_zyx_position() == (-1, 2, 7) + assert tile.get_yx_position() == (2, 7) diff --git a/tests/stitching/test_TileStitcher.py b/tests/stitching/test_TileStitcher.py index 3f311c09..620f2b3e 100644 --- a/tests/stitching/test_TileStitcher.py +++ b/tests/stitching/test_TileStitcher.py @@ -9,10 +9,26 @@ @pytest.fixture def tiles(): tiles = [ - Tile(path="path1", shape=(10, 10), position=TilePosition(0, 0, 0, 0, 0)), - Tile(path="path2", shape=(10, 10), position=TilePosition(0, 0, 0, 0, 10)), - Tile(path="path3", shape=(10, 10), position=TilePosition(0, 0, 0, 10, 0)), - Tile(path="path4", shape=(10, 10), position=TilePosition(0, 0, 0, 10, 10)), + Tile( + path="path1", + shape=(10, 10), + position=TilePosition(time=0, channel=0, z=0, y=0, x=0), + ), + Tile( + path="path2", + shape=(10, 10), + position=TilePosition(time=0, channel=0, z=0, y=0, x=10), + ), + Tile( + path="path3", + shape=(10, 10), + position=TilePosition(time=0, channel=0, z=0, y=10, x=0), + ), + Tile( + path="path4", + shape=(10, 10), + position=TilePosition(time=0, channel=0, z=0, y=10, x=10), + ), ] for i, tile in enumerate(tiles): @@ -111,14 +127,26 @@ def test_stitch_exact(tiles): @pytest.fixture def overlapping_tiles(): tiles = [ - Tile(path="path1", shape=(10, 10), position=TilePosition(0, 0, 0, 0, 0)), + Tile( + path="path1", + shape=(10, 10), + position=TilePosition(time=0, channel=0, z=0, y=0, x=0), + ), Tile( path="path2", shape=(10, 10), - position=TilePosition(0, 0, 0, 0, 5), + position=TilePosition(time=0, channel=0, z=0, y=0, x=5), + ), + Tile( + path="path3", + shape=(10, 10), + position=TilePosition(time=0, channel=0, z=0, y=5, x=0), + ), + Tile( + path="path4", + shape=(10, 10), + position=TilePosition(time=0, channel=0, z=0, y=5, x=5), ), - Tile(path="path3", shape=(10, 10), position=TilePosition(0, 0, 0, 5, 0)), - Tile(path="path4", shape=(10, 10), position=TilePosition(0, 0, 0, 5, 5)), ] for i, tile in enumerate(tiles): tile.i = i diff --git a/tests/stitching/test_stitching_utils.py b/tests/stitching/test_stitching_utils.py index 96e16b50..ccb081f6 100644 --- a/tests/stitching/test_stitching_utils.py +++ b/tests/stitching/test_stitching_utils.py @@ -1,8 +1,19 @@ +from dataclasses import dataclass + import numpy as np import pytest from numpy.testing import assert_array_equal -from faim_hcs.stitching.stitching_utils import fuse_mean, fuse_sum +from faim_hcs.stitching import Tile +from faim_hcs.stitching.stitching_utils import ( + assemble_chunk, + fuse_linear, + fuse_mean, + fuse_sum, + shift_to_origin, + translate_tiles_2d, +) +from faim_hcs.stitching.Tile import TilePosition @pytest.fixture @@ -39,3 +50,141 @@ def test_fuse_sum(tiles, masks): assert_array_equal(fused_result[:, :5], 1) assert_array_equal(fused_result[:, 5:15], 5) assert_array_equal(fused_result[:, 15:], 4) + + +def test_fuse_linear(tiles, masks): + fused_result = fuse_linear(warped_tiles=tiles, warped_masks=masks) + assert fused_result.shape == (10, 20) + assert fused_result.dtype == np.uint16 + assert_array_equal(fused_result[:, :5], 1) + assert_array_equal(fused_result[:, 5], int(1 * 10 / 11 + 4 * 1 / 11)) + assert_array_equal(fused_result[:, 6], int(1 * 9 / 11 + 4 * 2 / 11)) + assert_array_equal(fused_result[:, 7], int(1 * 8 / 11 + 4 * 3 / 11)) + assert_array_equal(fused_result[:, 8], int(1 * 7 / 11 + 4 * 4 / 11)) + assert_array_equal(fused_result[:, 9], int(1 * 6 / 11 + 4 * 5 / 11)) + assert_array_equal(fused_result[:, 10], int(1 * 5 / 11 + 4 * 6 / 11)) + assert_array_equal(fused_result[:, 11], int(1 * 4 / 11 + 4 * 7 / 11)) + assert_array_equal(fused_result[:, 12], int(1 * 3 / 11 + 4 * 8 / 11)) + assert_array_equal(fused_result[:, 13], int(1 * 2 / 11 + 4 * 9 / 11)) + assert_array_equal(fused_result[:, 14], int(1 * 1 / 11 + 4 * 10 / 11)) + assert_array_equal(fused_result[:, 15:], 4) + + fused_result = fuse_linear(warped_tiles=tiles[:1], warped_masks=masks[:1]) + assert fused_result.shape == (10, 20) + assert fused_result.dtype == np.uint16 + assert_array_equal(fused_result, tiles[0]) + + +@dataclass +class DummyTile: + def __init__(self, yx_position, data): + self._yx_position = yx_position + self._data = data + + def get_yx_position(self): + return self._yx_position + + def load_data(self): + return self._data + + +def test_assemble_chunk(tiles): + tile_map = { + (0, 0, 0, 0, 0): [ + DummyTile(yx_position=(0, 0), data=tiles[0][..., :15]), + DummyTile(yx_position=(0, 5), data=tiles[1][..., 5:]), + ], + (1, 0, 0, 0, 0): [], + } + + block_info = { + None: { + "array-location": [(0, 1), (0, 1), (0, 1), (0, 10), (0, 20)], + "chunk-location": (0, 0, 0, 0, 0), + "chunk-shape": (1, 1, 1, 10, 20), + "dtype": "uint16", + "num-chunks": (1, 1, 1, 1, 1), + "shape": (1, 1, 1, 10, 20), + } + } + + stitched_img = assemble_chunk( + block_info=block_info, + tile_map=tile_map, + warp_func=translate_tiles_2d, + fuse_func=fuse_sum, + dtype=np.uint16, + ) + assert stitched_img.shape == (1, 1, 1, 10, 20) + assert_array_equal(stitched_img[0, 0, 0], tiles[0] + tiles[1]) + + block_info = { + None: { + "array-location": [(0, 1), (0, 1), (0, 1), (0, 10), (0, 20)], + "chunk-location": (1, 0, 0, 0, 0), + "chunk-shape": (1, 1, 1, 10, 20), + "dtype": "uint16", + "num-chunks": (1, 1, 1, 1, 1), + "shape": (1, 1, 1, 10, 20), + } + } + stitched_img = assemble_chunk( + block_info=block_info, + tile_map=tile_map, + warp_func=translate_tiles_2d, + fuse_func=fuse_mean, + dtype=np.uint16, + ) + assert stitched_img.shape == (1, 1, 1, 10, 20) + assert_array_equal(stitched_img[0, 0, 0], np.zeros_like(tiles[0], dtype=np.uint16)) + + +def test_shift_to_origin(): + result = shift_to_origin( + [ + Tile( + path="path", + shape=(10, 10), + position=TilePosition(time=20, channel=1, z=10, y=-1, x=2), + ) + ] + ) + + assert result[0].get_position() == (0, 0, 0, 0, 0) + + +def test_translate_tiles_2d(tiles): + tile_map = { + (0, 0, 0, 0, 0): [ + DummyTile(yx_position=(0, 0), data=tiles[0][..., :15]), + DummyTile(yx_position=(0, 5), data=tiles[1][..., 5:]), + ], + (1, 0, 0, 0, 0): [], + } + + block_info = { + None: { + "array-location": [(0, 1), (0, 1), (0, 1), (0, 10), (0, 20)], + "chunk-location": (0, 0, 0, 0, 0), + "chunk-shape": (1, 1, 1, 10, 20), + "dtype": "uint16", + "num-chunks": (1, 1, 1, 1, 1), + "shape": (1, 1, 1, 10, 20), + } + } + + warped_tiles, warped_masks = translate_tiles_2d( + block_info=block_info, + yx_chunk_shape=(10, 20), + dtype=np.uint16, + tiles=tile_map[(0, 0, 0, 0, 0)], + ) + + assert warped_tiles.shape == (2, 10, 20) + assert warped_masks.shape == (2, 10, 20) + + assert warped_tiles.dtype == np.uint16 + assert warped_masks.dtype == bool + + assert_array_equal(warped_tiles[0], tiles[0]) + assert_array_equal(warped_tiles[1], tiles[1]) diff --git a/tests/test_CellVoyagerUtils.py b/tests/test_CellVoyagerUtils.py deleted file mode 100644 index fb458c35..00000000 --- a/tests/test_CellVoyagerUtils.py +++ /dev/null @@ -1,121 +0,0 @@ -from pathlib import Path - -import pytest - -from faim_hcs.CellVoyagerUtils import ( - get_img_YX, - get_well_image_CYX, - get_well_image_CZYX, -) -from faim_hcs.io.YokogawaCellVoyager import parse_files, parse_metadata -from faim_hcs.MontageUtils import montage_stage_pos_image_YX - - -@pytest.fixture -def acquisition_dir(): - return ( - Path(__file__).parent.parent - / "resources" - / "CV8000" - / "CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839" - / "CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack" - ) - - -@pytest.fixture -def files(acquisition_dir): - return parse_files(acquisition_dir=acquisition_dir) - - -@pytest.fixture -def channel_metadata(acquisition_dir): - _, channel_metadata = parse_metadata(acquistion_dir=acquisition_dir) - return channel_metadata - - -def test_get_img_YX(files, channel_metadata): - with pytest.raises( - ValueError, match=".*get_img_YX requires files for a single channel only.*" - ): - get_img_YX( - assemble_fn=montage_stage_pos_image_YX, - files=files, - channel_metadata=channel_metadata, - ) - - img, z_position, roi_tables = get_img_YX( - assemble_fn=montage_stage_pos_image_YX, - files=files[ - (files["well"] == "E03") & (files["ZIndex"] == "2") & (files["Ch"] == "1") - ], - channel_metadata=channel_metadata, - ) - assert img.shape == (4000, 4000) - assert z_position == 3.0 - assert roi_tables - - -def test_get_well_image_CYX(files, channel_metadata): - files_Z2 = files[files["ZIndex"] == "2"] - for well in files_Z2["well"].unique(): - img, hists, ch_metadata, metadata, roi_tables = get_well_image_CYX( - well_files=files_Z2[files_Z2["well"] == well], - channel_metadata_source=channel_metadata, - channels=[ - "1", - "2", - "4", # channel 4 is absent from files, but present in channel metadata - ], - ) - assert img.shape == (3, 4000, 4000) - assert len(hists) == 3 - assert len(ch_metadata) == 3 - assert ch_metadata[0] == { - "wavelength": "405", - "exposure-time": 100.0, - "exposure-time-unit": "ms", - "channel-name": "405", - "objective": "20x v2", - "display-color": "002FFF", - } - assert ch_metadata[1] == { - "wavelength": "488", - "exposure-time": 100.0, - "exposure-time-unit": "ms", - "channel-name": "488", - "objective": "20x v2", - "display-color": "00FFA1", - } - assert ch_metadata[2] == { - "channel-name": "empty", - "display-color": "000000", - } - assert metadata == { - "pixel-type": "uint16", - "spatial-calibration-units": "um", - "spatial-calibration-x": 0.325, - "spatial-calibration-y": 0.325, - } - assert roi_tables - - -def test_get_well_image_ZCYX(files, channel_metadata): - for well in files["well"].unique(): - well_files = files[files["well"] == well] - img, hists, ch_metadata, metadata, roi_tables = get_well_image_CZYX( - well_files=well_files, - channel_metadata_source=channel_metadata, - channels=["1", "2"], - ) - assert img.shape == (2, 4, 4000, 4000) - assert len(hists) == 2 - assert len(ch_metadata) == 2 - assert metadata == { - "pixel-type": "uint16", - "spatial-calibration-units": "um", - "spatial-calibration-x": 0.325, - "spatial-calibration-y": 0.325, - "z-scaling": 3.0, - } - assert "FOV_ROI_table" in roi_tables - assert "well_ROI_table" in roi_tables diff --git a/tests/test_MetaSeriesUtils.py b/tests/test_MetaSeriesUtils.py deleted file mode 100644 index 489459ef..00000000 --- a/tests/test_MetaSeriesUtils.py +++ /dev/null @@ -1,162 +0,0 @@ -# SPDX-FileCopyrightText: 2023 Friedrich Miescher Institute for Biomedical Research (FMI), Basel (Switzerland) -# -# SPDX-License-Identifier: BSD-3-Clause - -from pathlib import Path - -import numpy as np -import pytest - -from faim_hcs.io.MolecularDevicesImageXpress import parse_files -from faim_hcs.MetaSeriesUtils import get_well_image_CYX, get_well_image_CZYX -from faim_hcs.MontageUtils import ( - _stage_label, - montage_grid_image_YX, - montage_stage_pos_image_YX, -) - -ROOT_DIR = Path(__file__).parent.parent - - -@pytest.fixture -def files(): - return parse_files(ROOT_DIR / "resources" / "Projection-Mix") - - -@pytest.fixture -def roi_columns(): - return [ - "x_micrometer", - "y_micrometer", - "z_micrometer", - "len_x_micrometer", - "len_y_micrometer", - "len_z_micrometer", - ] - - -def test_get_well_image_CYX(files): - files2d = files[(files["z"].isnull()) & (files["channel"].isin(["w1", "w2"]))] - for well in files2d["well"].unique(): - img, hists, ch_metadata, metadata, roi_tables = get_well_image_CYX( - files2d[files2d["well"] == well], - channels=["w1", "w2"], - assemble_fn=montage_stage_pos_image_YX, - ) - assert img.shape == (2, 512, 1024) - assert len(hists) == 2 - assert "z-scaling" not in metadata - for ch_meta in ch_metadata: - assert "z-projection-method" in ch_meta - # TODO: Make some checks on roi_tables (from monaging with actual - # positions => different coordiantes) - - -def test_get_well_image_CYX_well_E07(files, roi_columns): - files2d = files[(files["z"].isnull()) & (files["channel"].isin(["w1", "w2"]))] - cyx, hists, ch_meta, metadata, roi_tables = get_well_image_CYX( - well_files=files2d[files2d["well"] == "E07"], channels=["w1", "w2"] - ) - - assert cyx.shape == (2, 512, 1024) - assert cyx.dtype == np.uint16 - assert len(hists) == 2 - assert hists[0].min() == 114 - assert hists[1].min() == 69 - assert ch_meta[0] == { - "channel-name": "FITC_05", - "display-color": "73ff00", - "exposure-time": 15.0, - "exposure-time-unit": "ms", - "objective": "20X Plan Apo Lambda", - "objective-numerical-aperture": 0.75, - "power": 5.09804, - "shading-correction": False, - "wavelength": "cyan", - "z-projection-method": "Maximum", - } - assert ch_meta[1] == { - "channel-name": "FITC_05", - "display-color": "73ff00", - "exposure-time": 15.0, - "exposure-time-unit": "ms", - "objective": "20X Plan Apo Lambda", - "objective-numerical-aperture": 0.75, - "power": 5.09804, - "shading-correction": False, - "wavelength": "cyan", - "z-projection-method": "Best Focus", - } - assert metadata == { - "pixel-type": "uint16", - "spatial-calibration-units": "um", - "spatial-calibration-x": 1.3668, - "spatial-calibration-y": 1.3668, - } - - assert list(roi_tables["well_ROI_table"].columns) == roi_columns - assert len(roi_tables["well_ROI_table"]) == 1 - target_values = [0.0, 0.0, 0.0, 1399.6032, 699.8016, 1.0] - assert ( - roi_tables["well_ROI_table"].loc["well_1"].values.flatten().tolist() - == target_values - ) - - assert list(roi_tables["FOV_ROI_table"].columns) == roi_columns - assert len(roi_tables["FOV_ROI_table"]) == 2 - target_values = [699.8016, 0.0, 0.0, 699.8016, 699.8016, 1.0] - assert ( - roi_tables["FOV_ROI_table"].loc["Site 2"].values.flatten().tolist() - == target_values - ) - - -def test_get_well_image_ZCYX(files): - files3d = files[(~files["z"].isnull()) & (files["channel"].isin(["w1", "w2"]))] - z_len = {"E08": 45.0, "E07": 45.17999999999999} - for well in files3d["well"].unique(): - img, hists, ch_metadata, metadata, roi_tables = get_well_image_CZYX( - files3d[files3d["well"] == well], - channels=["w1", "w2"], - assemble_fn=montage_grid_image_YX, - ) - assert img.shape == (2, 10, 512, 1024) - assert len(hists) == 2 - assert "z-scaling" in metadata - - roi_columns = [ - "x_micrometer", - "y_micrometer", - "z_micrometer", - "len_x_micrometer", - "len_y_micrometer", - "len_z_micrometer", - ] - assert list(roi_tables["well_ROI_table"].columns) == roi_columns - assert len(roi_tables["well_ROI_table"]) == 1 - target_values = [0.0, 0.0, 0.0, 1399.6032, 699.8016, z_len[well]] - assert ( - roi_tables["well_ROI_table"].loc["well_1"].values.flatten().tolist() - == target_values - ) - - assert list(roi_tables["FOV_ROI_table"].columns) == roi_columns - assert len(roi_tables["FOV_ROI_table"]) == 2 - target_values = [699.8016, 0.0, 0.0, 699.8016, 699.8016, z_len[well]] - assert ( - roi_tables["FOV_ROI_table"].loc["Site 2"].values.flatten().tolist() - == target_values - ) - - -test_stage_labels = [ - ({"stage-label": "E07 : Site 1"}, "Site 1"), - ({"stage-label": "E07 : Site 2"}, "Site 2"), - ({"stage-labels": "E07 : Site 2"}, ""), - ({}, ""), -] - - -@pytest.mark.parametrize("data,expected", test_stage_labels) -def test_stage_label_parser(data, expected): - assert _stage_label(data) == expected diff --git a/tests/test_MetaSeriesUtils_dask.py b/tests/test_MetaSeriesUtils_dask.py index 6e124c0e..22d166a9 100644 --- a/tests/test_MetaSeriesUtils_dask.py +++ b/tests/test_MetaSeriesUtils_dask.py @@ -1,25 +1,7 @@ -from pathlib import Path - -import dask.array as da import numpy as np import pytest -from faim_hcs.io.MolecularDevicesImageXpress import parse_files -from faim_hcs.MetaSeriesUtils_dask import ( - _fuse_xy, - _read_images, - create_filename_structure_FC, - create_filename_structure_FCZ, - fuse_dask, - fuse_fw, - fuse_mean_gradient, - fuse_random_gradient, - fuse_rev, - read_FCYX, - read_FCZYX, -) - -ROOT_DIR = Path(__file__).parent.parent +from faim_hcs.MetaSeriesUtils_dask import fuse_fw, fuse_random_gradient, fuse_rev def tiles(): @@ -35,131 +17,6 @@ def positions(): return np.array([[0, 0], [0, 4], [3, 1]]) -def block_FYX(): - return tiles() - - -def block_FCYX(): - return np.reshape(tiles(), (tiles().shape[0],) + (1,) + tiles().shape[-2:]) - - -def block_FCZYX(): - return np.reshape(tiles(), (tiles().shape[0],) + (1, 1) + tiles().shape[-2:]) - - -def block_FTCZYX(): - return np.reshape(tiles(), (tiles().shape[0],) + (1, 1, 1) + tiles().shape[-2:]) - - -def block_FCZYX_2(): - return np.reshape( - np.stack([tiles(), tiles()]), (tiles().shape[0],) + (1, 2) + tiles().shape[-2:] - ) - - -def dask_data_FCYX(): - data = np.stack( - [ - tiles(), - ] - * 4, - axis=1, - ) - return da.from_array( - data, - chunks=( - data.shape[0], - 1, - ) - + data.shape[-2:], - ) - - -def dask_data_FCZYX(): - data = np.stack( - [ - tiles(), - ] - * 4, - axis=1, - ) - data = np.stack( - [ - data, - ] - * 5, - axis=2, - ) - return da.from_array( - data, - chunks=( - data.shape[0], - 1, - 1, - ) - + data.shape[-2:], - ) - - -def dask_data_FTCZYX(): - data = np.stack( - [ - tiles(), - ] - * 4, - axis=1, - ) - data = np.stack( - [ - data, - ] - * 5, - axis=2, - ) - data = np.stack( - [ - data, - ] - * 6, - axis=1, - ) - return da.from_array( - data, - chunks=( - data.shape[0], - 1, - 1, - 1, - ) - + data.shape[-2:], - ) - - -def files(): - output = parse_files(ROOT_DIR / "resources" / "Projection-Mix") - output.field = [el[1:] for el in output.field] - output.channel = [el[1:] for el in output.channel] - return output - - -def files_FCZ(): - files_all = files() - return files_all[np.logical_and(files_all.z.notnull(), files_all.well == "E07")] - - -def files_FC(): - files_all = files() - return files_all[np.logical_and(files_all.z.isnull(), files_all.well == "E07")] - - -def fns_np_FC(): - return create_filename_structure_FC(files_FC(), ["1", "2", "3", "4"]) - - -def fns_np_FCZ(): - return create_filename_structure_FCZ(files_FCZ(), ["1", "2", "3", "4"]) - - @pytest.mark.parametrize( "tiles,positions", [ @@ -202,27 +59,6 @@ def test_fuse_fw(tiles, positions): assert fused_result[7, 7] == 3 -@pytest.mark.parametrize( - "tiles,positions", - [ - (tiles(), positions()), - ], -) -def test_fuse_mean_gradient(tiles, positions): - fused_result = fuse_mean_gradient(tiles=tiles, positions=positions) - # should be the same for all fuse-functions: - assert fused_result.shape == (11, 12) - assert fused_result[2, 3] == 1 - assert fused_result[2, 8] == 2 - assert fused_result[8, 3] == 3 - assert fused_result[8, 9] == 0 - # depends on fuse-functions: - assert fused_result[3, 4] == 1 - assert fused_result[3, 7] == 2 - assert fused_result[7, 4] == 2 - assert fused_result[7, 7] == 2 - - @pytest.mark.parametrize( "tiles,positions", [ @@ -242,138 +78,3 @@ def test_fuse_random_gradient(tiles, positions): assert fused_result[3, 7] == 1 assert fused_result[7, 4] == 3 assert fused_result[7, 7] == 3 - - -@pytest.mark.parametrize( - "x,assemble_fun,positions,ny_tot,nx_tot,expected", - [ - (block_FYX(), fuse_rev, positions(), 11, 12, ((11, 12),)), - (block_FCYX(), fuse_rev, positions(), 11, 12, ((1, 11, 12),)), - (block_FCZYX(), fuse_rev, positions(), 11, 12, ((1, 1, 11, 12),)), - (block_FTCZYX(), fuse_rev, positions(), 11, 12, ((1, 1, 1, 11, 12),)), - (block_FCZYX_2(), fuse_rev, positions(), 11, 12, ((1, 2, 11, 12),)), - ], -) -def test__fuse_xy(x, assemble_fun, positions, ny_tot, nx_tot, expected): - ims_fused = _fuse_xy(x, assemble_fun, positions, ny_tot, nx_tot) - assert ims_fused.shape == expected[0] - - -@pytest.mark.parametrize( - "data,positions,assemble_fun,expected", - [ - ( - dask_data_FCYX(), - positions(), - fuse_rev, - ( - (4, 11, 12), - (1, 11, 12), - ), - ), - ( - dask_data_FCZYX(), - positions(), - fuse_rev, - ( - (4, 5, 11, 12), - (1, 1, 11, 12), - ), - ), - ( - dask_data_FTCZYX(), - positions(), - fuse_rev, - ( - (6, 4, 5, 11, 12), - (1, 1, 1, 11, 12), - ), - ), - ], -) -def test_fuse_dask(data, positions, assemble_fun, expected): - imgs_fused_da = fuse_dask(data, positions, assemble_fun) - assert imgs_fused_da.shape == expected[0] - assert imgs_fused_da.chunksize == expected[1] - assert imgs_fused_da.compute().shape == expected[0] - - -@pytest.mark.parametrize( - "well_files,channels", - [ - (files_FCZ(), ["1", "2", "3", "4"]), - ], -) -def test_create_filename_structure_FCZ(well_files, channels): - fns_np = create_filename_structure_FCZ(well_files, channels) - assert fns_np.shape == (2, 4, 10) - assert "" not in fns_np[:, :2] - assert np.unique(fns_np[:, 2]) == [""] - assert len(np.unique(fns_np[:, 3])) == 3 - - -@pytest.mark.parametrize( - "well_files,channels", - [ - (files_FC(), ["1", "2", "3", "4"]), - ], -) -def test_create_filename_structure_FC(well_files, channels): - fns_np = create_filename_structure_FC(well_files, channels) - assert fns_np.shape == (2, 4) - assert "" not in fns_np[:, :-1] - assert np.unique(fns_np[:, 3]) == [""] - - -@pytest.mark.parametrize( - "x,ny,nx,dtype", - [ - (fns_np_FC(), 512, 512, np.uint16), - (fns_np_FCZ(), 512, 512, np.uint16), - ], -) -def test__read_images(x, ny, nx, dtype): - images = _read_images(x, ny, nx, dtype) - assert images.shape == x.shape + (ny, nx) - assert np.unique(images[x == ""]) == [ - 0, - ] - - -@pytest.mark.parametrize( - "well_files, channels, ny, nx, dtype", - [ - (files_FCZ(), ["1", "2", "3", "4"], 512, 512, np.uint16), - ], -) -def test_read_FCZYX(well_files, channels, ny, nx, dtype): - images_da = read_FCZYX(well_files, channels, ny, nx, dtype) - images = images_da.compute() - assert images_da.shape == (2, 4, 10, ny, nx) - assert np.unique(images[:, 2]) == [ - 0, - ] - assert np.unique(images[:, 3, 1:]) == [ - 0, - ] - assert [ - 0, - ] not in [list(np.unique(images[i])) for i in np.ndindex((2, 2, 10))] - - -@pytest.mark.parametrize( - "well_files, channels, ny, nx, dtype", - [ - (files_FC(), ["1", "2", "3", "4"], 512, 512, np.uint16), - ], -) -def test_read_FCYX(well_files, channels, ny, nx, dtype): - images_da = read_FCYX(well_files, channels, ny, nx, dtype) - images = images_da.compute() - assert images_da.shape == (2, 4, ny, nx) - assert np.unique(images[:, 3]) == [ - 0, - ] - assert [ - 0, - ] not in [list(np.unique(images[i])) for i in np.ndindex((2, 3))] diff --git a/tests/test_Zarr.py b/tests/test_Zarr.py deleted file mode 100644 index b50a7028..00000000 --- a/tests/test_Zarr.py +++ /dev/null @@ -1,471 +0,0 @@ -# SPDX-FileCopyrightText: 2023 Friedrich Miescher Institute for Biomedical Research (FMI), Basel (Switzerland) -# -# SPDX-License-Identifier: BSD-3-Clause - -import shutil -import tempfile -import unittest -from os.path import exists, join -from pathlib import Path - -import anndata as ad - -from faim_hcs.io.MolecularDevicesImageXpress import ( - parse_multi_field_stacks, - parse_single_plane_multi_fields, -) -from faim_hcs.MetaSeriesUtils import get_well_image_CYX, get_well_image_CZYX -from faim_hcs.Zarr import ( - PlateLayout, - build_zarr_scaffold, - write_cyx_image_to_well, - write_czyx_image_to_well, - write_labels_to_group, - write_roi_table, -) - -ROOT_DIR = Path(__file__).parent - - -class TestZarr(unittest.TestCase): - def setUp(self) -> None: - self.tmp_dir = tempfile.mkdtemp() - - self.files = parse_single_plane_multi_fields( - join(ROOT_DIR.parent, "resources", "Projection-Mix") - ) - - self.files3d = parse_multi_field_stacks( - join(ROOT_DIR.parent, "resources", "Projection-Mix") - ) - - self.zarr_root = Path(self.tmp_dir, "zarr-files") - self.zarr_root.mkdir() - - def tearDown(self) -> None: - shutil.rmtree(self.tmp_dir) - - def test_plate_scaffold_96(self): - plate = build_zarr_scaffold( - root_dir=self.zarr_root, - files=self.files, - layout=PlateLayout.I96, - order_name="test-order", - barcode="test-barcode", - ) - - assert exists(join(self.zarr_root, "Projection-Mix.zarr", "E", "7", "0")) - assert exists(join(self.zarr_root, "Projection-Mix.zarr", "E", "8", "0")) - - attrs = plate.attrs.asdict() - assert attrs["order_name"] == "test-order" - assert attrs["barcode"] == "test-barcode" - assert len(attrs["plate"]["columns"]) * len(attrs["plate"]["rows"]) == 96 - - def test_plate_scaffold_384(self): - plate = build_zarr_scaffold( - root_dir=self.zarr_root, - files=self.files, - layout=PlateLayout.I384, - order_name="test-order", - barcode="test-barcode", - ) - - assert exists(join(self.zarr_root, "Projection-Mix.zarr", "E", "7", "0")) - assert exists(join(self.zarr_root, "Projection-Mix.zarr", "E", "8", "0")) - - attrs = plate.attrs.asdict() - assert attrs["order_name"] == "test-order" - assert attrs["barcode"] == "test-barcode" - assert len(attrs["plate"]["columns"]) * len(attrs["plate"]["rows"]) == 384 - - def test_plate_scaffold_24(self): - self.assertRaises( - NotImplementedError, - build_zarr_scaffold, - root_dir=self.zarr_root, - files=self.files, - layout=24, - order_name="test-order", - barcode="test-barcode", - ) - - def test_write_cyx_image_to_well(self): - plate = build_zarr_scaffold( - root_dir=self.zarr_root, - files=self.files, - layout=96, - order_name="test-order", - barcode="test-barcode", - ) - - for well in self.files["well"].unique(): - well_files = self.files[self.files["well"] == well] - img, hists, ch_metadata, metadata, roi_tables = get_well_image_CYX( - well_files=well_files, channels=["w1", "w2", "w3", "w4"] - ) - - field = plate[well[0]][str(int(well[1:]))][0] - write_cyx_image_to_well(img, hists, ch_metadata, metadata, field) - - # Write all ROI tables - for roi_table in roi_tables: - write_roi_table(roi_tables[roi_table], roi_table, field) - - e07 = plate["E"]["7"]["0"].attrs.asdict() - assert ( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "7" - / "0" - / "C00_FITC_05_histogram.npz" - ).exists() - assert ( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "7" - / "0" - / "C01_FITC_05_histogram.npz" - ).exists() - assert ( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "7" - / "0" - / "C02_FITC_05_histogram.npz" - ).exists() - assert ( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "7" - / "0" - / "C03_empty_histogram.npz" - ).exists() - assert "histograms" in e07.keys() - assert "acquisition_metadata" in e07.keys() - assert e07["multiscales"][0]["datasets"][0]["coordinateTransformations"][0][ - "scale" - ] == [1.0, 1.3668, 1.3668] - - e08 = plate["E"]["8"]["0"].attrs.asdict() - assert ( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "8" - / "0" - / "C00_FITC_05_histogram.npz" - ).exists() - assert ( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "8" - / "0" - / "C01_FITC_05_histogram.npz" - ).exists() - assert ( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "8" - / "0" - / "C02_FITC_05_histogram.npz" - ).exists() - assert ( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "8" - / "0" - / "C03_empty_histogram.npz" - ).exists() - assert ( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "7" - / "0" - / "tables" - / "well_ROI_table" - ).exists() - assert ( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "7" - / "0" - / "tables" - / "FOV_ROI_table" - ).exists() - assert "histograms" in e08.keys() - assert "acquisition_metadata" in e08.keys() - assert e08["multiscales"][0]["datasets"][0]["coordinateTransformations"][0][ - "scale" - ] == [1.0, 1.3668, 1.3668] - - # Check ROI table content - table = ad.read_zarr( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "7" - / "0" - / "tables" - / "well_ROI_table" - ) - df_well = table.to_df() - roi_columns = [ - "x_micrometer", - "y_micrometer", - "z_micrometer", - "len_x_micrometer", - "len_y_micrometer", - "len_z_micrometer", - ] - assert list(df_well.columns) == roi_columns - assert len(df_well) == 1 - target_values = [0.0, 0.0, 0.0, 1399.6031494140625, 699.8015747070312, 1.0] - assert df_well.loc["well_1"].values.flatten().tolist() == target_values - - table = ad.read_zarr( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "7" - / "0" - / "tables" - / "FOV_ROI_table" - ) - df_fov = table.to_df() - assert list(df_fov.columns) == roi_columns - assert len(df_fov) == 2 - target_values = [ - 699.8015747070312, - 0.0, - 0.0, - 699.8015747070312, - 699.8015747070312, - 1.0, - ] - assert df_fov.loc["Site 2"].values.flatten().tolist() == target_values - - def test_write_czyx_image_to_well(self): - plate = build_zarr_scaffold( - root_dir=self.zarr_root, - files=self.files3d, - layout=96, - order_name="test-order", - barcode="test-barcode", - ) - - for well in self.files3d["well"].unique(): - well_files = self.files3d[self.files3d["well"] == well] - img, hists, ch_metadata, metadata, roi_tables = get_well_image_CZYX( - well_files=well_files, channels=["w1", "w2", "w3", "w4"] - ) - - field = plate[well[0]][str(int(well[1:]))][0] - write_czyx_image_to_well(img, hists, ch_metadata, metadata, field) - - # Write all ROI tables - for roi_table in roi_tables: - write_roi_table(roi_tables[roi_table], roi_table, field) - - e07 = plate["E"]["7"]["0"].attrs.asdict() - assert ( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "7" - / "0" - / "C00_FITC_05_histogram.npz" - ).exists() - assert ( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "7" - / "0" - / "C01_FITC_05_histogram.npz" - ).exists() - assert ( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "7" - / "0" - / "C02_empty_histogram.npz" - ).exists() - assert ( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "7" - / "0" - / "C03_FITC_05_histogram.npz" - ).exists() - assert "histograms" in e07.keys() - assert "acquisition_metadata" in e07.keys() - assert e07["multiscales"][0]["datasets"][0]["coordinateTransformations"][0][ - "scale" - ] == [1.0, 5.02, 1.3668, 1.3668] - - e08 = plate["E"]["8"]["0"].attrs.asdict() - assert ( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "8" - / "0" - / "C00_FITC_05_histogram.npz" - ).exists() - assert ( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "8" - / "0" - / "C01_FITC_05_histogram.npz" - ).exists() - assert ( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "8" - / "0" - / "C02_empty_histogram.npz" - ).exists() - assert ( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "8" - / "0" - / "C03_FITC_05_histogram.npz" - ).exists() - assert ( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "7" - / "0" - / "tables" - / "well_ROI_table" - ).exists() - assert ( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "7" - / "0" - / "tables" - / "FOV_ROI_table" - ).exists() - assert "histograms" in e08.keys() - assert "acquisition_metadata" in e08.keys() - assert e08["multiscales"][0]["datasets"][0]["coordinateTransformations"][0][ - "scale" - ] == [1.0, 5.0, 1.3668, 1.3668] - - # Check ROI table content - table = ad.read_zarr( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "7" - / "0" - / "tables" - / "well_ROI_table" - ) - df_well = table.to_df() - roi_columns = [ - "x_micrometer", - "y_micrometer", - "z_micrometer", - "len_x_micrometer", - "len_y_micrometer", - "len_z_micrometer", - ] - assert list(df_well.columns) == roi_columns - assert len(df_well) == 1 - target_values = [ - 0.0, - 0.0, - 0.0, - 1399.6031494140625, - 699.8015747070312, - 45.18000030517578, - ] - assert df_well.loc["well_1"].values.flatten().tolist() == target_values - - table = ad.read_zarr( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "7" - / "0" - / "tables" - / "FOV_ROI_table" - ) - df_fov = table.to_df() - assert list(df_fov.columns) == roi_columns - assert len(df_fov) == 2 - target_values = [ - 699.8015747070312, - 0.0, - 0.0, - 699.8015747070312, - 699.8015747070312, - 45.18000030517578, - ] - assert df_fov.loc["Site 2"].values.flatten().tolist() == target_values - - def test_write_labels(self): - plate = build_zarr_scaffold( - root_dir=self.zarr_root, - files=self.files3d, - layout=96, - order_name="test-order", - barcode="test-barcode", - ) - well_files = self.files3d[self.files3d["well"] == "E07"] - img, hists, ch_metadata, metadata, roi_tables = get_well_image_CZYX( - well_files=well_files, channels=["w1", "w2", "w3", "w4"] - ) - field = plate["E"]["7"][0] - write_czyx_image_to_well(img, hists, ch_metadata, metadata, field) - threshold = 100 - labels = img > threshold - labels_name = "my_segmentation" - write_labels_to_group( - labels=labels, labels_name=labels_name, parent_group=field - ) - original_multiscales = plate["E/7/0"].attrs.asdict()["multiscales"] - labels_multiscales = plate["E/7/0/labels/my_segmentation/"].attrs.asdict()[ - "multiscales" - ] - assert ( - self.zarr_root - / "Projection-Mix.zarr" - / "E" - / "7" - / "0" - / "labels" - / "my_segmentation" - ).exists() - assert len(original_multiscales) == len(labels_multiscales) - assert original_multiscales[0]["axes"] == labels_multiscales[0]["axes"] - assert original_multiscales[0]["datasets"] == labels_multiscales[0]["datasets"] - assert ( - plate["E/7/0/0"][0, :, :, :].shape - == plate["E/7/0/labels/my_segmentation/0"][0, :, :, :].shape - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..05f1f1a7 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,13 @@ +from faim_hcs.utils import wavelength_to_rgb + + +def test_wavelength_to_rgb(): + assert wavelength_to_rgb(370) == (0, 0, 0) + assert wavelength_to_rgb(380) == (97, 0, 97) + assert wavelength_to_rgb(440) == (0, 0, 255) + assert wavelength_to_rgb(490) == (0, 255, 255) + assert wavelength_to_rgb(510) == (0, 255, 0) + assert wavelength_to_rgb(580) == (255, 255, 0) + assert wavelength_to_rgb(645) == (255, 0, 0) + assert wavelength_to_rgb(750) == (97, 0, 0) + assert wavelength_to_rgb(751) == (0, 0, 0) diff --git a/tox.ini b/tox.ini index a8f9a8a3..c24dce37 100644 --- a/tox.ini +++ b/tox.ini @@ -14,6 +14,11 @@ PLATFORM = macos-latest: macos windows-latest: windows +[coverage:run] +branch = True +omit = + */_version.py + [testenv] platform = macos: darwin