diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index a37d7ac..c2a0d64 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -2,7 +2,7 @@ # used for running tests name: tests -on: +"on": push: branches: [main] pull_request: @@ -28,7 +28,7 @@ jobs: run: | poetry version 99.0.0 - name: Install environment - run: poetry install --no-interaction --no-ansi + run: poetry install --with dev --no-interaction --no-ansi # run pre-commit - uses: pre-commit/action@v3.0.1 id: pre_commit @@ -57,15 +57,17 @@ jobs: run: | poetry version 99.0.0 - name: Install environment - run: poetry install --no-interaction --no-ansi + run: poetry install --with dev --no-interaction --no-ansi - name: Run pytest (non-3.13) if: ${{ matrix.python_version != '3.13' }} # run all tests - run: poetry run pytest + run: poetry run pytest -o addopts= - name: Run pytest (py 3.13) if: ${{ matrix.python_version == '3.13' }} # run all tests except integration tests with cosmicqc - run: poetry run pytest --ignore=tests/test_project_integration.py + run: | + poetry run pytest -o addopts= \ + --ignore=tests/test_project_integration.py - name: Reset version to 0.0.0 run: | poetry version 0.0.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1fc66e9..7792a8f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,7 +31,8 @@ repos: .*\.lock | .*\.json | .*\.ipynb | - .*\.cppipe + .*\.cppipe | + tests/data/CP_tutorial_3D_noise_nuclei_segmentation/output/MyExpt_Experiment\.csv )$ - repo: https://github.com/executablebooks/mdformat rev: 0.7.18 @@ -53,25 +54,26 @@ repos: hooks: - id: actionlint - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.14.14" + rev: "v0.15.0" hooks: - id: ruff-format - id: ruff-check - repo: https://github.com/software-gardening/almanack - rev: v0.1.13 + rev: v0.1.15 hooks: - id: almanack-check - repo: https://gitlab.com/vojko.pribudic.foss/pre-commit-update rev: v0.6.0 hooks: - id: pre-commit-update - args: ["--keep", "mdformat", "--keep", "pre-commit-update", "--keep", "cffconvert"] + args: ["--keep", "mdformat", "--keep", "pre-commit-update", "--keep", "cffconvert", + "--keep", "pyproject-fmt"] - repo: local hooks: - id: code-cov-gen name: Generate code coverage language: system - entry: poetry run coverage run -m pytest + entry: poetry run coverage run -m pytest -o addopts= pass_filenames: false always_run: true - repo: https://github.com/Weird-Sheep-Labs/coverage-pre-commit diff --git a/CITATION.cff b/CITATION.cff index e99d468..7321a32 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -168,3 +168,39 @@ references: JUMP (cpg0000-jump-pilot) was used to help demonstrate CytoDataFrame performance with large data. See here for more information: https://github.com/broadinstitute/cellpainting-gallery + - type: article + authors: + - family-names: Blin + given-names: Guillaume + - family-names: Sadurska + given-names: Dominika + - family-names: Portero Migueles + given-names: Rafael + - family-names: Chen + given-names: Ni + - family-names: Watson + given-names: James A. + - family-names: Lowell + given-names: Sally + title: "Nessys: A new set of tools for the automated detection of nuclei within intact tissues and dense 3D cultures" + journal: PLoS Biology + volume: "17" + issue: "8" + pages: e3000388 + year: 2019 + doi: "10.1371/journal.pbio.3000388" + url: "https://doi.org/10.1371/journal.pbio.3000388" + notes: > + This work used the file "6001240_labels.zarr" from the DISCEPTS imaging + dataset, available through the Image Data Resource (IDR) under accession + number idr0062. + - type: data + title: "3D Noise Nuclei Segmentation Tutorial" + authors: + - name: "CellProfiler Tutorials Team" + url: "https://tutorials.cellprofiler.org/#3d-noise-nuclei-segmentation" + publisher: + name: "CellProfiler Organization" + notes: > + This work uses data that were slightly modified from the + CellProfiler 3D Noise Nuclei Segmentation tutorial. diff --git a/README.md b/README.md index 4ae5d3e..f60c2bd 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,13 @@ With CytoDataFrame you can: - View image objects alongside their feature data using a Pandas DataFrame-like interface. - Highlight image objects using mask or outline files to understand their segmentation. - Adjust image displays on-the-fly using interactive slider widgets. +- Automatically detect 3D image volumes and render interactive [trame](https://github.com/Kitware/trame) views in notebooks when 3D dependencies are installed (with graceful fallback otherwise). + +For 3D notebook display behavior: + +- 3D-aware rendering is enabled by default (`display_options={"auto_trame_for_3d": True}`). +- Disable automatic trame switching with `display_options={"auto_trame_for_3d": False}`. +- Force trame layout regardless of auto-detection with `display_options={"view": "trame"}`. 📓 ___Want to see CytoDataFrame in action?___ Check out our [example notebook](docs/src/examples/cytodataframe_at_a_glance.ipynb) for a quick tour of its key features. diff --git a/docs/src/contributing.md b/docs/src/contributing.md index daa7cea..f1ad45e 100644 --- a/docs/src/contributing.md +++ b/docs/src/contributing.md @@ -53,6 +53,9 @@ You can run pytest on your work using the following example: % poetry run pytest ``` +Pytest output includes a terminal coverage summary to help track current +coverage and uncovered lines. + ## Making changes to this repository We welcome anyone to use [GitHub issues](https://docs.github.com/en/issues/tracking-your-work-with-issues/about-issues) (requires a GitHub login) or create [pull requests](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests) (to directly make changes within this repository) to modify content found within this repository. diff --git a/docs/src/examples/cytodataframe_at_a_glance.ipynb b/docs/src/examples/cytodataframe_at_a_glance.ipynb index 3492abb..333f652 100644 --- a/docs/src/examples/cytodataframe_at_a_glance.ipynb +++ b/docs/src/examples/cytodataframe_at_a_glance.ipynb @@ -49,14 +49,14 @@ "name": "stdout", "output_type": "stream", "text": [ - "CPU times: user 877 ms, sys: 542 ms, total: 1.42 s\n", - "Wall time: 585 ms\n" + "CPU times: user 857 ms, sys: 555 ms, total: 1.41 s\n", + "Wall time: 604 ms\n" ] }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "90a70b1fe81949c2bfd4c58cc1f6f39d", + "model_id": "102613dfc81e4b0ba3bf8ac8983cf1cf", "version_major": 2, "version_minor": 0 }, @@ -67,75 +67,6 @@ "metadata": {}, "output_type": "display_data" }, - { - "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", - "
Metadata_ImageNumberCells_Number_Object_NumberImage_FileName_OrigAGPImage_FileName_OrigDNAImage_FileName_OrigRNA
011
112
213
\n", - "
\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/plain": [] @@ -175,14 +106,14 @@ "name": "stdout", "output_type": "stream", "text": [ - "CPU times: user 845 ms, sys: 567 ms, total: 1.41 s\n", - "Wall time: 482 ms\n" + "CPU times: user 892 ms, sys: 657 ms, total: 1.55 s\n", + "Wall time: 505 ms\n" ] }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "cf8e8baaf3e64bd78263db0734eadcbf", + "model_id": "36c853bbb4ed4a4994c26799eb7f1c73", "version_major": 2, "version_minor": 0 }, @@ -193,75 +124,6 @@ "metadata": {}, "output_type": "display_data" }, - { - "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", - "
Metadata_ImageNumberCells_Number_Object_NumberImage_FileName_OrigAGPImage_FileName_OrigDNAImage_FileName_OrigRNA
011
112
213
\n", - "
\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/plain": [] @@ -300,14 +162,14 @@ "name": "stdout", "output_type": "stream", "text": [ - "CPU times: user 844 ms, sys: 530 ms, total: 1.37 s\n", - "Wall time: 485 ms\n" + "CPU times: user 831 ms, sys: 531 ms, total: 1.36 s\n", + "Wall time: 475 ms\n" ] }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "3931051a74524ba481b50911b96a8921", + "model_id": "3201a63c00d04448ad8ebf446d33d14d", "version_major": 2, "version_minor": 0 }, @@ -318,75 +180,6 @@ "metadata": {}, "output_type": "display_data" }, - { - "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", - "
Metadata_ImageNumberCells_Number_Object_NumberImage_FileName_OrigAGPImage_FileName_OrigDNAImage_FileName_OrigRNA
011
112
213
\n", - "
\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/plain": [] @@ -426,14 +219,14 @@ "name": "stdout", "output_type": "stream", "text": [ - "CPU times: user 850 ms, sys: 532 ms, total: 1.38 s\n", + "CPU times: user 890 ms, sys: 634 ms, total: 1.52 s\n", "Wall time: 507 ms\n" ] }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "0370cc7dd475438da444fee2c48463d7", + "model_id": "d8db4dc4d6c847f3b81c7e2d0898300a", "version_major": 2, "version_minor": 0 }, @@ -444,75 +237,6 @@ "metadata": {}, "output_type": "display_data" }, - { - "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", - "
Metadata_ImageNumberCells_Number_Object_NumberImage_FileName_OrigAGPImage_FileName_OrigDNAImage_FileName_OrigRNA
011
112
213
\n", - "
\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/plain": [] @@ -561,14 +285,14 @@ "name": "stdout", "output_type": "stream", "text": [ - "CPU times: user 873 ms, sys: 573 ms, total: 1.45 s\n", - "Wall time: 492 ms\n" + "CPU times: user 792 ms, sys: 553 ms, total: 1.34 s\n", + "Wall time: 456 ms\n" ] }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "9548cc409e5147a99234750f155de932", + "model_id": "5a9c76ebeccc40f1a9c3c6a2aeefe39b", "version_major": 2, "version_minor": 0 }, @@ -579,75 +303,6 @@ "metadata": {}, "output_type": "display_data" }, - { - "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", - "
Metadata_ImageNumberCells_Number_Object_NumberImage_FileName_OrigAGPImage_FileName_OrigDNAImage_FileName_OrigRNA
011
112
213
\n", - "
\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/plain": [] @@ -685,14 +340,14 @@ "name": "stdout", "output_type": "stream", "text": [ - "CPU times: user 829 ms, sys: 527 ms, total: 1.36 s\n", - "Wall time: 485 ms\n" + "CPU times: user 863 ms, sys: 598 ms, total: 1.46 s\n", + "Wall time: 509 ms\n" ] }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "743365d93b084a99ad90d8af6c3a65aa", + "model_id": "e60665d6f7bf43718315a4bf8c2bd422", "version_major": 2, "version_minor": 0 }, @@ -703,75 +358,6 @@ "metadata": {}, "output_type": "display_data" }, - { - "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", - "
Metadata_ImageNumberCells_Number_Object_NumberImage_FileName_OrigAGPImage_FileName_OrigDNAImage_FileName_OrigRNA
011
112
213
\n", - "
\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/plain": [] @@ -811,14 +397,14 @@ "name": "stdout", "output_type": "stream", "text": [ - "CPU times: user 868 ms, sys: 536 ms, total: 1.4 s\n", + "CPU times: user 898 ms, sys: 665 ms, total: 1.56 s\n", "Wall time: 507 ms\n" ] }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "97a07fa56cfb4067a0d072b999b9d706", + "model_id": "104c8cb6bf2d4d4eae40f132831353fd", "version_major": 2, "version_minor": 0 }, @@ -829,75 +415,6 @@ "metadata": {}, "output_type": "display_data" }, - { - "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", - "
Metadata_ImageNumberCells_Number_Object_NumberImage_FileName_OrigAGPImage_FileName_OrigDNAImage_FileName_OrigRNA
011
112
213
\n", - "
\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/plain": [] @@ -936,14 +453,14 @@ "name": "stdout", "output_type": "stream", "text": [ - "CPU times: user 826 ms, sys: 480 ms, total: 1.31 s\n", - "Wall time: 498 ms\n" + "CPU times: user 897 ms, sys: 609 ms, total: 1.51 s\n", + "Wall time: 528 ms\n" ] }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "d3e367dd536a44e198c099b47dc289f9", + "model_id": "4ccb0071b002431ebf7b01469ce00ba2", "version_major": 2, "version_minor": 0 }, @@ -954,91 +471,6 @@ "metadata": {}, "output_type": "display_data" }, - { - "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", - "
01234
Metadata_ImageNumber11111
Cells_Number_Object_Number12345
Image_FileName_OrigAGP
Image_FileName_OrigDNA
Image_FileName_OrigRNA
\n", - "
\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/plain": [] @@ -1078,14 +510,14 @@ "name": "stdout", "output_type": "stream", "text": [ - "CPU times: user 2.09 s, sys: 473 ms, total: 2.56 s\n", - "Wall time: 5.45 s\n" + "CPU times: user 535 ms, sys: 100 ms, total: 636 ms\n", + "Wall time: 850 ms\n" ] }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "cab6bdff53ff441894866b56c3a6d179", + "model_id": "3dec9a3d58674454964f226b2d8b59d5", "version_major": 2, "version_minor": 0 }, @@ -1096,111 +528,6 @@ "metadata": {}, "output_type": "display_data" }, - { - "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", - "
Metadata_ImageNumberCells_Number_Object_NumberImage_FileName_OrigAGPImage_FileName_OrigDNAImage_FileName_OrigRNAImage_FileName_OrigAGP_OMEArrow_ORIGImage_FileName_OrigAGP_OMEArrow_LABLImage_FileName_OrigAGP_OMEArrow_COMPImage_FileName_OrigDNA_OMEArrow_ORIGImage_FileName_OrigDNA_OMEArrow_LABLImage_FileName_OrigDNA_OMEArrow_COMPImage_FileName_OrigRNA_OMEArrow_ORIGImage_FileName_OrigRNA_OMEArrow_LABLImage_FileName_OrigRNA_OMEArrow_COMP
011r01c01f01p01-ch2sk1fk1fl1.tiffr01c01f01p01-ch5sk1fk1fl1.tiffr01c01f01p01-ch3sk1fk1fl1.tiffNone
112r01c01f01p01-ch2sk1fk1fl1.tiffr01c01f01p01-ch5sk1fk1fl1.tiffr01c01f01p01-ch3sk1fk1fl1.tiffNone
213r01c01f01p01-ch2sk1fk1fl1.tiffr01c01f01p01-ch5sk1fk1fl1.tiffr01c01f01p01-ch3sk1fk1fl1.tiffNone
\n", - "
\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/plain": [] @@ -1230,14 +557,14 @@ "name": "stdout", "output_type": "stream", "text": [ - "CPU times: user 881 ms, sys: 550 ms, total: 1.43 s\n", - "Wall time: 514 ms\n" + "CPU times: user 847 ms, sys: 491 ms, total: 1.34 s\n", + "Wall time: 518 ms\n" ] }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "c19a8f9c323842a4a3af799a2b2f7f90", + "model_id": "1d8b5494c34a431db9ee26c55c1f1235", "version_major": 2, "version_minor": 0 }, @@ -1248,91 +575,6 @@ "metadata": {}, "output_type": "display_data" }, - { - "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", - "
Metadata_ImageNumberCells_Number_Object_NumberImage_FileName_OrigAGPImage_FileName_OrigDNAImage_FileName_OrigRNA
011
112
213
314
415
\n", - "
\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/plain": [] @@ -1379,88 +621,19 @@ "name": "stdout", "output_type": "stream", "text": [ - "CPU times: user 244 ms, sys: 162 ms, total: 406 ms\n", - "Wall time: 148 ms\n" + "CPU times: user 243 ms, sys: 157 ms, total: 400 ms\n", + "Wall time: 142 ms\n" ] }, { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "c7b58a7f6e8c48868cdfa39cf6263e69", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "VBox(children=(IntSlider(value=50, continuous_update=False, description='Image adjustment:', style=SliderStyle…" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "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", - "
Metadata_ImageNumberMetadata_Cells_Number_Object_NumberImage_FileName_GFPImage_FileName_RFPImage_FileName_DAPI
353314
156411317
1275945
\n", - "
\n", - " " - ], + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "9606b7fa148b425aaebb53f030718599", + "version_major": 2, + "version_minor": 0 + }, "text/plain": [ - "" + "VBox(children=(IntSlider(value=50, continuous_update=False, description='Image adjustment:', style=SliderStyle…" ] }, "metadata": {}, @@ -1502,14 +675,14 @@ "name": "stdout", "output_type": "stream", "text": [ - "CPU times: user 326 ms, sys: 182 ms, total: 508 ms\n", - "Wall time: 239 ms\n" + "CPU times: user 257 ms, sys: 190 ms, total: 448 ms\n", + "Wall time: 146 ms\n" ] }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "12489f60233f4875922d660eca4fbc77", + "model_id": "0da32ba2d9a744428155eb98c2116967", "version_major": 2, "version_minor": 0 }, @@ -1520,75 +693,6 @@ "metadata": {}, "output_type": "display_data" }, - { - "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", - "
Metadata_ImageNumberMetadata_Cells_Number_Object_NumberImage_FileName_GFPImage_FileName_RFPImage_FileName_DAPI
353314
156411317
1275945
\n", - "
\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/plain": [] @@ -1627,14 +731,14 @@ "name": "stdout", "output_type": "stream", "text": [ - "CPU times: user 261 ms, sys: 177 ms, total: 437 ms\n", + "CPU times: user 247 ms, sys: 161 ms, total: 408 ms\n", "Wall time: 149 ms\n" ] }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "a6b51158acd4499395d2ce1f83c716b7", + "model_id": "308af9becf36472c88f6a98959bd2ab0", "version_major": 2, "version_minor": 0 }, @@ -1645,75 +749,6 @@ "metadata": {}, "output_type": "display_data" }, - { - "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", - "
Metadata_ImageNumberMetadata_Cells_Number_Object_NumberImage_FileName_GFPImage_FileName_RFPImage_FileName_DAPI
353314
156411317
1275945
\n", - "
\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/plain": [] @@ -1762,14 +797,14 @@ "name": "stdout", "output_type": "stream", "text": [ - "CPU times: user 958 ms, sys: 182 ms, total: 1.14 s\n", - "Wall time: 1.15 s\n" + "CPU times: user 918 ms, sys: 123 ms, total: 1.04 s\n", + "Wall time: 1.1 s\n" ] }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "419763b9c98544dfb6a340470f34bf2d", + "model_id": "e3b2f17e87ca43c8991d03e9089ee5fb", "version_major": 2, "version_minor": 0 }, @@ -1780,111 +815,6 @@ "metadata": {}, "output_type": "display_data" }, - { - "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", - "
Metadata_ImageNumberMetadata_Cells_Number_Object_NumberImage_FileName_GFPImage_FileName_RFPImage_FileName_DAPIImage_FileName_GFP_OMEArrow_ORIGImage_FileName_GFP_OMEArrow_LABLImage_FileName_GFP_OMEArrow_COMPImage_FileName_RFP_OMEArrow_ORIGImage_FileName_RFP_OMEArrow_LABLImage_FileName_RFP_OMEArrow_COMPImage_FileName_DAPI_OMEArrow_ORIGImage_FileName_DAPI_OMEArrow_LABLImage_FileName_DAPI_OMEArrow_COMP
353314B7_01_2_3_GFP_001.tifB7_01_3_3_RFP_001.tifB7_01_1_3_DAPI_001.tifNone
156411317H12_01_2_1_GFP_001.tifH12_01_3_1_RFP_001.tifH12_01_1_1_DAPI_001.tifNone
1275945F7_01_2_2_GFP_001.tifF7_01_3_2_RFP_001.tifF7_01_1_2_DAPI_001.tifNone
\n", - "
\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/plain": [] @@ -1914,14 +844,14 @@ "name": "stdout", "output_type": "stream", "text": [ - "CPU times: user 92.2 ms, sys: 37.9 ms, total: 130 ms\n", - "Wall time: 66.1 ms\n" + "CPU times: user 103 ms, sys: 50.6 ms, total: 154 ms\n", + "Wall time: 71.4 ms\n" ] }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "0e19834874994411ab3dbbb3dff790d5", + "model_id": "ab856f8093b6410cb216cdb7529d9e43", "version_major": 2, "version_minor": 0 }, @@ -1932,75 +862,6 @@ "metadata": {}, "output_type": "display_data" }, - { - "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", - "
Metadata_ImageNumberNuclei_Number_Object_NumberImage_FileName_A647Image_FileName_DAPIImage_FileName_GOLD
011slide1_A1_M10_CH1_Z09_illumcorrect.tiffslide1_A1_M10_CH2_Z09_illumcorrect.tiff
112slide1_A1_M10_CH1_Z09_illumcorrect.tiffslide1_A1_M10_CH2_Z09_illumcorrect.tiff
213slide1_A1_M10_CH1_Z09_illumcorrect.tiffslide1_A1_M10_CH2_Z09_illumcorrect.tiff
\n", - "
\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/plain": [] @@ -2038,14 +899,14 @@ "name": "stdout", "output_type": "stream", "text": [ - "CPU times: user 336 ms, sys: 233 ms, total: 570 ms\n", - "Wall time: 185 ms\n" + "CPU times: user 335 ms, sys: 220 ms, total: 555 ms\n", + "Wall time: 183 ms\n" ] }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "9a2d765c8ca043d1964aa51c32a0b19f", + "model_id": "a4503b2c56ff4b66abc0220a426092aa", "version_major": 2, "version_minor": 0 }, @@ -2056,85 +917,6 @@ "metadata": {}, "output_type": "display_data" }, - { - "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", - "
Metadata_ImageNumberMetadata_Nuclei_Number_Object_NumberImage_FileName_OrigAGPImage_FileName_OrigDNA
033
134
236
337
438
\n", - "
\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/plain": [] @@ -2177,97 +959,48 @@ "output_type": "stream", "text": [ "CPU times: user 1e+03 ns, sys: 0 ns, total: 1e+03 ns\n", - "Wall time: 3.1 μs\n" + "Wall time: 2.15 μs\n" ] }, { "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "646b26ef8b5644fc8094789430209d0d", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "VBox(children=(IntSlider(value=50, continuous_update=False, description='Image adjustment:', style=SliderStyle…" - ] + "text/plain": [] }, + "execution_count": 18, "metadata": {}, - "output_type": "display_data" + "output_type": "execute_result" + } + ], + "source": [ + "%%time\n", + "# show that we can use the cytodataframe again\n", + "# by quick variable reference.\n", + "cdf" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "0892633a-fdd2-448a-a96a-54dad4b5caf8", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CPU times: user 856 ms, sys: 226 ms, total: 1.08 s\n", + "Wall time: 1.05 s\n" + ] }, { "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", - "
Metadata_ImageNumberMetadata_Nuclei_Number_Object_NumberImage_FileName_OrigAGPImage_FileName_OrigDNA
033
134
236
337
438
\n", - "
\n", - " " - ], + "application/vnd.jupyter.widget-view+json": { + "model_id": "b26e7932e78d4afb9bfd68dd2b66bc20", + "version_major": 2, + "version_minor": 0 + }, "text/plain": [ - "" + "VBox(children=(IntSlider(value=50, continuous_update=False, description='Image adjustment:', style=SliderStyle…" ] }, "metadata": {}, @@ -2277,41 +1010,46 @@ "data": { "text/plain": [] }, - "execution_count": 18, + "execution_count": 19, "metadata": {}, "output_type": "execute_result" } ], "source": [ "%%time\n", - "# show that we can use the cytodataframe again\n", - "# by quick variable reference.\n", - "cdf" + "# export to OME Parquet, a format which uses OME Arrow\n", + "# to store OME-spec images as values within the table.\n", + "cdf.to_ome_parquet(file_path=\"example.ome.parquet\")\n", + "\n", + "# read OME Parquet file into the CytoDataFrame\n", + "CytoDataFrame(data=\"example.ome.parquet\")" ] }, { "cell_type": "code", - "execution_count": 19, - "id": "0892633a-fdd2-448a-a96a-54dad4b5caf8", - "metadata": {}, + "execution_count": 20, + "id": "881e0542", + "metadata": { + "lines_to_next_cell": 0 + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "CPU times: user 895 ms, sys: 236 ms, total: 1.13 s\n", - "Wall time: 1.05 s\n" + "CPU times: user 6.28 ms, sys: 2.74 ms, total: 9.01 ms\n", + "Wall time: 8.42 ms\n" ] }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "0402208f8d74443b845c657b7fd58954", + "model_id": "66ee81391967476281d6181cfcf99f8b", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "VBox(children=(IntSlider(value=50, continuous_update=False, description='Image adjustment:', style=SliderStyle…" + "GridspecLayout(children=(HTML(value=\"
coverage: 83.83%coverage83.83% \ No newline at end of file +coverage: 90.32%coverage90.32% \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index d7b0b3c..84371ff 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "accessible-pygments" @@ -670,13 +670,32 @@ urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version > [package.extras] crt = ["awscrt (==0.27.6)"] +[[package]] +name = "bqplot" +version = "0.12.45" +description = "Interactive plotting for the Jupyter notebook, using d3.js and ipywidgets." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "bqplot-0.12.45-py2.py3-none-any.whl", hash = "sha256:cf2e046adb401670902ab53a18d9f63540091279bc45c4ef281bfdadf6e7e92c"}, + {file = "bqplot-0.12.45.tar.gz", hash = "sha256:ede00e9fdf7d92e43cc2d1b9691c7da176b6216fdd187c8e92f19d7beaca5e2a"}, +] + +[package.dependencies] +ipywidgets = ">=7.5.0,<9" +numpy = ">=1.10.4" +pandas = ">=1.0.0,<3.0.0" +traitlets = ">=4.3.0" +traittypes = ">=0.0.6" + [[package]] name = "certifi" version = "2024.7.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" -groups = ["dev", "docs"] +groups = ["main", "dev", "docs"] files = [ {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, @@ -769,7 +788,7 @@ version = "3.3.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" -groups = ["dev", "docs"] +groups = ["main", "dev", "docs"] files = [ {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, @@ -2003,6 +2022,27 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "ipydatawidgets" +version = "4.3.5" +description = "A set of widgets to help facilitate reuse of large datasets across widgets" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "ipydatawidgets-4.3.5-py2.py3-none-any.whl", hash = "sha256:d590cdb7c364f2f6ab346f20b9d2dd661d27a834ef7845bc9d7113118f05ec87"}, + {file = "ipydatawidgets-4.3.5.tar.gz", hash = "sha256:394f2489576587cfd755377a09a067f46cad22081965092021fd1abcbe7852a8"}, +] + +[package.dependencies] +ipywidgets = ">=7.0.0" +numpy = "*" +traittypes = ">=0.2.0" + +[package.extras] +docs = ["recommonmark", "sphinx", "sphinx-rtd-theme"] +test = ["nbval (>=0.9.2)", "pytest (>=4)", "pytest-cov"] + [[package]] name = "ipykernel" version = "6.29.4" @@ -2085,6 +2125,83 @@ files = [ [package.dependencies] pygments = "*" +[[package]] +name = "ipyvolume" +version = "0.6.3" +description = "IPython widget for rendering 3d volumes" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "ipyvolume-0.6.3-py3-none-any.whl", hash = "sha256:550761b5cc1a9fb0e8931056fd523b2f0074ddea46633a248f996168e5b0d7f6"}, + {file = "ipyvolume-0.6.3.tar.gz", hash = "sha256:823226f90a59ce08b1da2699a9ec505f34f65f01ce43accd80e7d3554082d035"}, +] + +[package.dependencies] +bqplot = "*" +ipyvue = ">=1.7.0" +ipyvuetify = "*" +ipywebrtc = "*" +ipywidgets = ">=7.0.0" +matplotlib = "*" +numpy = "*" +Pillow = "*" +pythreejs = ">=2.4.0" +requests = "*" +traitlets = "*" +traittypes = "*" + +[[package]] +name = "ipyvue" +version = "1.12.0" +description = "Jupyter widgets base for Vue libraries" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "ipyvue-1.12.0-py2.py3-none-any.whl", hash = "sha256:c7f555a71c28724ceda344af294bdc48407eace17222065cfb7b4cff80665362"}, + {file = "ipyvue-1.12.0.tar.gz", hash = "sha256:408b5e6a64e203fc679f447a071e3dbc178ab2906982f248adf722fc84773ffa"}, +] + +[package.dependencies] +ipywidgets = ">=7.0.0" + +[package.extras] +dev = ["pre-commit"] +test = ["solara[pytest]"] + +[[package]] +name = "ipyvuetify" +version = "1.11.3" +description = "Jupyter widgets based on vuetify UI components" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "ipyvuetify-1.11.3-py2.py3-none-any.whl", hash = "sha256:fa83aaf9f4ce669172d532094d60bd7c40d3cb9c5d6bb2f4a14565da2b09a8d8"}, + {file = "ipyvuetify-1.11.3.tar.gz", hash = "sha256:3580afa76d9add4ae04ccb7fd57d4a0cf03a261705742e7137def3ebb65ac71d"}, +] + +[package.dependencies] +ipyvue = ">=1.7,<2" + +[package.extras] +dev = ["mypy", "nox", "pre-commit"] +doc = ["ipykernel", "jupyter-sphinx", "pydata-sphinx-theme", "sphinx (<7)", "sphinx-design", "sphinx_rtd_theme"] +test = ["jupyterlab (<4)", "nbformat (<5.10)", "pytest", "pytest-playwright (<0.6)", "solara[pytest]"] + +[[package]] +name = "ipywebrtc" +version = "0.6.0" +description = "WebRTC for Jupyter notebook/lab" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "ipywebrtc-0.6.0-py2.py3-none-any.whl", hash = "sha256:01a6c9d79ab937c280ce4635a149c7b681457e99ea779c00c7a6aa44ee6916f8"}, + {file = "ipywebrtc-0.6.0.tar.gz", hash = "sha256:f8ac3cc02b3633b59f388aef67961cff57f90028fd303bb3886c63c3d631da13"}, +] + [[package]] name = "ipywidgets" version = "8.1.8" @@ -3165,6 +3282,18 @@ files = [ {file = "mistune-3.0.2.tar.gz", hash = "sha256:fc7f93ded930c92394ef2cb6f04a8aabab4117a91449e72dcc8dfa646a508be8"}, ] +[[package]] +name = "more-itertools" +version = "10.8.0" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b"}, + {file = "more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd"}, +] + [[package]] name = "msgpack" version = "1.1.2" @@ -3549,7 +3678,7 @@ version = "1.6.0" description = "Patch asyncio to allow nested event loops" optional = false python-versions = ">=3.5" -groups = ["dev", "docs"] +groups = ["main", "dev", "docs"] files = [ {file = "nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c"}, {file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"}, @@ -4099,7 +4228,7 @@ version = "4.2.2" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" -groups = ["dev", "docs"] +groups = ["main", "dev", "docs"] files = [ {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, @@ -4145,6 +4274,29 @@ pyyaml = ">=6.0.3,<7.0" [package.extras] poetry-plugin = ["poetry (>=1.2.0,<3.0.0) ; python_version < \"4.0\""] +[[package]] +name = "pooch" +version = "1.9.0" +description = "A friend to fetch your data files" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pooch-1.9.0-py3-none-any.whl", hash = "sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b"}, + {file = "pooch-1.9.0.tar.gz", hash = "sha256:de46729579b9857ffd3e741987a2f6d5e0e03219892c167c6578c0091fb511ed"}, +] + +[package.dependencies] +packaging = ">=20.0" +platformdirs = ">=2.5.0" +requests = ">=2.19.0" + +[package.extras] +progress = ["tqdm (>=4.41.0,<5.0.0)"] +sftp = ["paramiko (>=2.7.0)"] +test = ["pytest-httpserver", "pytest-localftpserver"] +xxhash = ["xxhash (>=1.4.3)"] + [[package]] name = "prometheus-client" version = "0.21.0" @@ -4706,6 +4858,29 @@ files = [ {file = "python_json_logger-2.0.7-py3-none-any.whl", hash = "sha256:f380b826a991ebbe3de4d897aeec42760035ac760345e57b812938dc8b35e2bd"}, ] +[[package]] +name = "pythreejs" +version = "2.4.2" +description = "Interactive 3D graphics for the Jupyter Notebook and JupyterLab, using Three.js and Jupyter Widgets." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "pythreejs-2.4.2-py3-none-any.whl", hash = "sha256:8418807163ad91f4df53b58c4e991b26214852a1236f28f1afeaadf99d095818"}, + {file = "pythreejs-2.4.2.tar.gz", hash = "sha256:a568bfdc4c3797c4c2339158928edc7dcf6fa4a267b08e3cec5121e2078b5bd6"}, +] + +[package.dependencies] +ipydatawidgets = ">=1.1.1" +ipywidgets = ">=7.2.1" +numpy = "*" +traitlets = "*" + +[package.extras] +docs = ["nbsphinx (>=0.2.13)", "nbsphinx-link", "sphinx (>=1.5)", "sphinx-rtd-theme"] +examples = ["ipywebrtc", "matplotlib", "scikit-image", "scipy"] +test = ["nbval", "numpy (>=1.14)", "pytest-check-links"] + [[package]] name = "pytokens" version = "0.3.0" @@ -4734,6 +4909,33 @@ files = [ ] markers = {dev = "python_version < \"3.13\""} +[[package]] +name = "pyvista" +version = "0.46.5" +description = "Easier Pythonic interface to VTK" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyvista-0.46.5-py3-none-any.whl", hash = "sha256:d254e1e32e1df0dc04b409f989bd56b24d9e94086d868597af6c501151ec17fa"}, + {file = "pyvista-0.46.5.tar.gz", hash = "sha256:b637bfa32136b95e5e5a6d972871606a68e3c625fc652ff5d9f00390294e99c0"}, +] + +[package.dependencies] +matplotlib = ">=3.0.1" +numpy = ">=1.21.0" +pillow = "*" +pooch = "*" +scooby = ">=0.5.1" +typing-extensions = ">=4.10" +vtk = "<9.4.0 || >9.4.0,<9.4.1 || >9.4.1,<9.6.0" + +[package.extras] +all = ["pyvista[colormaps,io,jupyter]"] +colormaps = ["cmcrameri", "cmocean", "colorcet"] +io = ["imageio", "meshio (>=5.2)"] +jupyter = ["ipywidgets", "jupyter-server-proxy", "nest_asyncio", "trame (>=2.5.2)", "trame-client (>=2.12.7)", "trame-server (>=2.11.7)", "trame-vtk (>=2.5.8)", "trame-vuetify (>=2.3.1)"] + [[package]] name = "pywavelets" version = "1.9.0" @@ -5035,7 +5237,7 @@ version = "2.32.4" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" -groups = ["dev", "docs"] +groups = ["main", "dev", "docs"] files = [ {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, @@ -5330,6 +5532,21 @@ dev = ["cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy (==1.10.0)", "pycodest doc = ["intersphinx_registry", "jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.19.1)", "jupytext", "linkify-it-py", "matplotlib (>=3.5)", "myst-nb (>=1.2.0)", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0,<8.2.0)", "sphinx-copybutton", "sphinx-design (>=0.4.0)"] test = ["Cython", "array-api-strict (>=2.3.1)", "asv", "gmpy2", "hypothesis (>=6.30)", "meson", "mpmath", "ninja ; sys_platform != \"emscripten\"", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] +[[package]] +name = "scooby" +version = "0.11.0" +description = "A Great Dane turned Python environment detective" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "scooby-0.11.0-py3-none-any.whl", hash = "sha256:a79663d1a7711eb104e4b2935988ea1ed5f7be6b7288fad23b4fba7462832f9d"}, + {file = "scooby-0.11.0.tar.gz", hash = "sha256:3dfacc6becf2d6558efa4b625bae3b844ced5d256f3143ebf774e005367e712a"}, +] + +[package.extras] +cpu = ["mkl", "psutil"] + [[package]] name = "seaborn" version = "0.13.2" @@ -5924,6 +6141,138 @@ files = [ docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"] +[[package]] +name = "traittypes" +version = "0.2.3" +description = "Scipy trait types" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "traittypes-0.2.3-py2.py3-none-any.whl", hash = "sha256:49016082ce740d6556d9bb4672ee2d899cd14f9365f17cbb79d5d96b47096d4e"}, + {file = "traittypes-0.2.3.tar.gz", hash = "sha256:212feed38d566d772648768b78d3347c148ef23915b91c02078188e631316c86"}, +] + +[package.dependencies] +traitlets = ">=4.2.2" + +[package.extras] +test = ["numpy", "pandas", "pytest", "xarray"] + +[[package]] +name = "trame" +version = "3.12.0" +description = "Trame, a framework to build applications in plain Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "trame-3.12.0-py3-none-any.whl", hash = "sha256:9b33020625e0d1710d060c0fabe7b3be0e31b5e5138439ec9a796faf6fe96915"}, + {file = "trame-3.12.0.tar.gz", hash = "sha256:88b861162cb8b025e84e93f17dcfd43a84d02d2c1608c9f6d58e3cd646a50c05"}, +] + +[package.dependencies] +pyyaml = "*" +trame-client = ">=3.10.1,<4" +trame-common = ">=1,<2" +trame-server = ">=3.4,<4" +wslink = ">=2.3.3" + +[package.extras] +app = ["pywebview"] +dev = ["pre-commit", "pytest", "ruff"] +jupyter = ["jupyterlab"] + +[[package]] +name = "trame-client" +version = "3.11.2" +description = "Internal client of trame" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "trame_client-3.11.2-py3-none-any.whl", hash = "sha256:f4d9364ea89cdb9d128fcebe4ab5034e5c20662feb5fee858cfe2eca3dea4771"}, + {file = "trame_client-3.11.2.tar.gz", hash = "sha256:98b3f09d0fbdb09cd29eac61c945a76dcad4a08cfb4843abce5a148fd6fc7316"}, +] + +[package.dependencies] +trame-common = ">=0.2.0" + +[package.extras] +dev = ["pre-commit", "ruff"] +test = ["Pillow", "pixelmatch", "pytest", "pytest-playwright", "pytest-xprocess"] + +[[package]] +name = "trame-common" +version = "1.1.1" +description = "Dependency less classes and functions for trame" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "trame_common-1.1.1-py3-none-any.whl", hash = "sha256:6fb7d0c2488b0ef4728774be5f982116a599ef3c088d82678b1c29c7648a9a6d"}, + {file = "trame_common-1.1.1.tar.gz", hash = "sha256:6254970b75700510c58265e90fd38ba852b99c0e71293d24eed54819902bb01c"}, +] + +[package.extras] +dev = ["nox", "pre-commit", "pytest (>=6)", "pytest-cov (>=3)", "ruff"] +test = ["nox", "pytest (>=6)", "pytest-cov (>=3)"] + +[[package]] +name = "trame-server" +version = "3.10.0" +description = "Internal server side implementation of trame" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "trame_server-3.10.0-py3-none-any.whl", hash = "sha256:eb282f6bc6fa8fdbb2c65b8e6d22e088a27b56fe0b7a12f07cf2d9ea546bd935"}, + {file = "trame_server-3.10.0.tar.gz", hash = "sha256:0c341de976f758ff8e6076991e7f30be180384d4f386cf29aefa3915b801d118"}, +] + +[package.dependencies] +more-itertools = "*" +wslink = ">=2.5,<3" + +[package.extras] +dev = ["nox", "pre-commit", "pytest", "pytest-asyncio", "ruff"] + +[[package]] +name = "trame-vtk" +version = "2.11.1" +description = "VTK widgets for trame" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "trame_vtk-2.11.1-py3-none-any.whl", hash = "sha256:871d7fdd98731083ba958977a1bdf0a09f39f32051e13fa1032c5305e6927e88"}, + {file = "trame_vtk-2.11.1.tar.gz", hash = "sha256:db1f316ba69c29b9292775c3f73567604aa366742c06030d8507d5bd56424492"}, +] + +[package.dependencies] +trame-client = ">=3.4,<4" + +[package.extras] +dev = ["coverage", "nox", "pre-commit", "pytest", "pytest-asyncio", "ruff"] + +[[package]] +name = "trame-vuetify" +version = "3.2.1" +description = "Vuetify widgets for trame" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "trame_vuetify-3.2.1-py3-none-any.whl", hash = "sha256:45239b9972bcfb8f121487efe7f537c0dc44174a87535bbd2131a9c86e4053bf"}, + {file = "trame_vuetify-3.2.1.tar.gz", hash = "sha256:1578904a8fc5313ba8033076ea2d9338a050a26c68ceebb207fb6b15e18c0a45"}, +] + +[package.dependencies] +trame-client = ">=3.7,<4" + +[package.extras] +dev = ["pre-commit", "pytest", "ruff"] + [[package]] name = "types-python-dateutil" version = "2.9.0.20241003" @@ -6103,6 +6452,52 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] +[[package]] +name = "vtk" +version = "9.5.2" +description = "VTK is an open-source toolkit for 3D computer graphics, image processing, and visualization" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "vtk-9.5.2-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:9ca87122352cf3c8748fee73c48930efa46fe1a868149a1f760bc17e8fae27ba"}, + {file = "vtk-9.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6da02d69dcf2d42472ec8c227e6a8406cedea53d3928af97f8d4e776ff89c95f"}, + {file = "vtk-9.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c0ba9cc4b5cd463a1984dfac6d0a9eeef888b273208739f8ebc46d392ddabb93"}, + {file = "vtk-9.5.2-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:c9254f864ebef3d69666a1feedf09cad129e4c91f85ca804c38cf8addedb2748"}, + {file = "vtk-9.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:7c56dbd02e5b4ec0422886bf9e26059ad2d4622857dbfb90d9ed254104fd9d6c"}, + {file = "vtk-9.5.2-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:afcbc6dc122ebba877793940fda8fd2cbe14e1dae590e6872ea74894abdab9be"}, + {file = "vtk-9.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:005877a568b96cf00ceb5bec268cf102db756bed509cb240fa40ada414a24bf0"}, + {file = "vtk-9.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2e2fe2535483adb1ba8cc83a0dc296faaffa2505808a3b04f697084f656e5f84"}, + {file = "vtk-9.5.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0248aab2ee51a69fadcdcf74697a045e2d525009a35296100eed2211f0cca2bb"}, + {file = "vtk-9.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:f78674fd265022499ea6b7f03d7f11a861e89e1df043592a82e4f5235c537ef5"}, + {file = "vtk-9.5.2-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:cf5dbc93b6806b08799204430a4fc4bea74290c1c101fa64f1a4703144087fa3"}, + {file = "vtk-9.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cce212b911d13fb0ca36d339f658c9db1ff27a5a730cdddd5d0c6b2ec24c15b1"}, + {file = "vtk-9.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:454711c51038824ddc75f955e1064c4e214b452c2e67083f01a8b43fc0ed62cb"}, + {file = "vtk-9.5.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9fce9688f0dede00dc6f3b046037c5fa8378479fa8303a353fd69afae4078d9a"}, + {file = "vtk-9.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:5357bccdf8629373195cab871e45c50383d052d316192aa48f45bd9f87bafccb"}, + {file = "vtk-9.5.2-cp313-cp313-macosx_10_10_x86_64.whl", hash = "sha256:1eae5016620a5fd78f4918256ea65dbe100a7c3ce68f763b64523f06aaaeafbc"}, + {file = "vtk-9.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:29ad766e308dcaa23b36261180cd9960215f48815b31c7ac2aa52edc88e21ef7"}, + {file = "vtk-9.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:11cf870c05539e9f82f4a5adf450384e0be4ee6cc80274f9502715a4139e2777"}, + {file = "vtk-9.5.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:3c4b658d61815cb87177f4e94281396c9be5a28798464a2c6fa0897b1bba282f"}, + {file = "vtk-9.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:974783b8865e2ddc2818d3090705b6bc6bf8ae40346d67f9a43485fabcfb3a99"}, + {file = "vtk-9.5.2-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:227c5e6e9195aa9d92a64d6d07d09f000576b5df231522b5c156a3c4c4190d69"}, + {file = "vtk-9.5.2-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b5d0a89e893d9279ba9742d0bbd47d7dfac96fccd8fb9d024bb8aa098fde5637"}, + {file = "vtk-9.5.2-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:371d9068f5cb25861aa51c1d1792fffce5a44032dbece55412562429c5f257cc"}, + {file = "vtk-9.5.2-cp38-cp38-win_amd64.whl", hash = "sha256:7cf2e2e12184c018388f06fbffcb93ea9e478ca4bf636c3f66bd7503e2230298"}, + {file = "vtk-9.5.2-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:9b148e57837d1fd2a8a72f171a0fb40872837dea191f673f2b7ec397935c754e"}, + {file = "vtk-9.5.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6a3b27f22d7e15f6a2d60510e70d75dac4ed2a53600e31275b67fedc45afbcc0"}, + {file = "vtk-9.5.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b6b91968581132b0d96142a08d50028efa5aa7a876d4aff6de1664e99e006c89"}, + {file = "vtk-9.5.2-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:2b37670d56de32935eeadee58e1a9a0b5d3847294ca24ea9329101089be5de83"}, + {file = "vtk-9.5.2-cp39-cp39-win_amd64.whl", hash = "sha256:1ac9ff528892e585f8f3286b26a90250bd6ea9107c38e6e194939f6f28269ad6"}, +] + +[package.dependencies] +matplotlib = ">=2.0.0" + +[package.extras] +numpy = ["numpy (>=1.9)"] +web = ["wslink (>=1.0.4)"] + [[package]] name = "wcwidth" version = "0.2.13" @@ -6263,6 +6658,25 @@ files = [ {file = "wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0"}, ] +[[package]] +name = "wslink" +version = "2.5.0" +description = "Python/JavaScript library for communicating over WebSocket" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "wslink-2.5.0-py3-none-any.whl", hash = "sha256:e5738958cc6cbe95581108df066be31a9ead0c485d2b27ca3f3f4865fc08b761"}, + {file = "wslink-2.5.0.tar.gz", hash = "sha256:61f79460affeeeb05284821f5ec5bc927153d587b661d6cfe33cbe260f9cfae3"}, +] + +[package.dependencies] +aiohttp = "<4" +msgpack = ">=1,<2" + +[package.extras] +ssl = ["cryptography"] + [[package]] name = "xarray" version = "2025.6.1" @@ -6537,4 +6951,4 @@ test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.funct [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.14" -content-hash = "13d0ca01ac9606a7a78043552d8f457f4cbfb70b2b37a8d2f320055122b95ecd" +content-hash = "fd22c4234e81348f80438bf1a4f723a25385e707b966b817ae908737186e76d5" diff --git a/pyproject.toml b/pyproject.toml index 7d670f2..b78ad7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,15 +5,15 @@ requires = [ "poetry-core>=1", "poetry-dynamic-versioning>=1,<2" ] [tool.poetry] name = "CytoDataFrame" version = "99.0.0" -description = "An in-memory data analysis format for single-cell profiles alongside their corresponding images and segmentation masks." +description = """\ + An in-memory data analysis format for single-cell profiles alongside their corresponding images and segmentation \ + masks.\ + """ authors = [ "Way Science Community" ] license = "BSD-3-Clause" readme = "README.md" packages = [ { include = "cytodataframe", from = "src" } ] -[tool.poetry.requires-plugins] -poetry-dynamic-versioning = { version = ">=1.0.0,<2.0.0", extras = [ "plugin" ] } - [tool.poetry.dependencies] python = ">=3.11,<3.14" # used for data management @@ -42,6 +42,12 @@ matplotlib = "^3.9.3" ipywidgets = "^8.1.7" imageio = "^2.37.0" ome-arrow = ">=0.0.3,<0.0.6" +pyvista = ">=0.46.4" +trame = ">=3.12" +trame-vtk = ">=2.10" +trame-vuetify = ">=3.1" +ipyvolume = "^0.6.3" +nest-asyncio = "^1.6.0" [tool.poetry.group.dev.dependencies] # provides testing capabilities for project @@ -74,13 +80,14 @@ dunamai = "^1.22.0" # used for theming the docsite pydata-sphinx-theme = "^0.16.0" +[tool.poetry.requires-plugins] +poetry-dynamic-versioning = { version = ">=1.0.0,<2.0.0", extras = [ "plugin" ] } + [tool.poetry-dynamic-versioning] enable = true style = "pep440" vcs = "git" - -[tool.poetry-dynamic-versioning.substitution] -files = [ "src/cytodataframe/__init__.py" ] +substitution.files = [ "src/cytodataframe/__init__.py" ] # defines various development tasks @@ -91,7 +98,6 @@ root = "." target-version = "py38" line-length = 88 fix = true - lint.select = [ # flake8-builtins "A", @@ -122,10 +128,13 @@ lint.per-file-ignores."src/cytodataframe/image.py" = [ "PLR2004" ] # ignore typing rules for tests lint.per-file-ignores."tests/*" = [ "ANN201", "PLR0913", "PLR2004", "SIM105" ] -[tool.coverage.run] +[tool.pytest] +ini_options.addopts = "--cov=src/cytodataframe --cov-report=term-missing:skip-covered --no-cov-on-fail" + +[tool.coverage] # settings to avoid errors with cv2 and coverage # see here for more: https://github.com/nedbat/coveragepy/issues/1653 -omit = [ +run.omit = [ "config.py", "config-3.py", ] @@ -138,16 +147,14 @@ formats = "ipynb,py:light" [tool.bandit] exclude_dirs = [ "tests" ] -[tool.poe.tasks] +[tool.poe] # note: quarto commands below expect quarto installed on the local system. # see here for more information: https://quarto.org/docs/download/ -poster-preview.shell = """ +tasks.poster-preview.shell = """ quarto preview \ docs/presentations/2025-sbi2/poster.qmd """ -poster-render.shell = """ - quarto render \ - docs/presentations/2025-sbi2/poster.qmd - mv docs/presentations/2025-sbi2/cytodataframe-2025-poster.pdf \ - docs/src/_static/cytodataframe-2025-poster.pdf -""" +tasks.poster-render.shell = """\ + quarto render \\\n docs/presentations/2025-sbi2/poster.qmd\n mv \ + docs/presentations/2025-sbi2/cytodataframe-2025-poster.pdf \\\n docs/src/_static/cytodataframe-2025-poster.pdf\n\ + """ diff --git a/src/cytodataframe/frame.py b/src/cytodataframe/frame.py index 246e888..2b3ddb1 100644 --- a/src/cytodataframe/frame.py +++ b/src/cytodataframe/frame.py @@ -3,13 +3,16 @@ """ import base64 +import contextlib import logging +import os import pathlib import re import sys import tempfile import uuid import warnings +from collections import OrderedDict from io import BytesIO, StringIO from typing import ( Any, @@ -29,7 +32,7 @@ import pandas as pd import skimage from IPython import get_ipython -from IPython.display import HTML, display +from IPython.display import HTML, Javascript, display from pandas._config import ( get_option, ) @@ -45,8 +48,19 @@ draw_outline_on_image_from_outline, get_pixel_bbox_from_offsets, ) +from .volume import ( + build_3d_html_from_path, + build_3d_image_html_stub, + build_3d_image_html_view, + build_3d_vtk_js_initializer, + extract_volume_from_ome_arrow, +) logger = logging.getLogger(__name__) +MIN_VOLUME_NDIM = 3 +RGB_LIKE_CHANNEL_COUNTS = (MIN_VOLUME_NDIM, 4) +MIN_RGB_SPATIAL_DIM = 8 +MAX_RGB_ASPECT_RATIO = 4.0 # provide backwards compatibility for Self type in earlier Python versions. # see: https://peps.python.org/pep-0484/#annotating-instance-and-class-methods @@ -72,6 +86,7 @@ class CytoDataFrame(pd.DataFrame): """ _metadata: ClassVar = ["_custom_attrs"] + _HTML_3D_STUB_KEY: ClassVar[str] = "_cyto_3d_html_stub" def __init__( # noqa: PLR0913 self: CytoDataFrame_type, @@ -164,6 +179,11 @@ def __init__( # noqa: PLR0913 } - Alternatively, set a global pixel size in 'display_options': {'um_per_pixel': 0.325} # used if not provided under 'scale_bar' + - 'ignore_image_path_columns': When True and a data_context_dir is set, + ignore any PathName_* or other image path columns and resolve images + only via data_context_dir + filename. + - 'view': Optional UI preference for 3D rendering. Use "trame" to + prefer the trame backend when backend is not explicitly set. **kwargs: Additional keyword arguments to pass to the pandas read functions. """ @@ -208,6 +228,8 @@ def __init__( # noqa: PLR0913 "shown": False, # whether VBox has been displayed "observing": False, # whether slider observer is attached }, + "_snapshot_cache": {}, + "_volume_cache": {}, "_scale_slider": widgets.IntSlider( value=initial_brightness, min=0, @@ -220,6 +242,12 @@ def __init__( # noqa: PLR0913 "_output": widgets.Output(), } + if self._custom_attrs["data_context_dir"] is not None: + logger.debug( + "CytoDataFrame data_context_dir set to: %s", + self._custom_attrs["data_context_dir"], + ) + if isinstance(data, CytoDataFrame): self._custom_attrs["data_source"] = data._custom_attrs["data_source"] self._custom_attrs["data_context_dir"] = data._custom_attrs[ @@ -490,10 +518,10 @@ def get_bounding_box_from_data( on predefined column groups. This method identifies specific groups of columns representing bounding box - coordinates for different cellular components (cytoplasm, nuclei, cells) and - checks for their presence in the DataFrame. If all required columns are present, - it filters and returns a new CytoDataFrame instance containing only these - columns. + coordinates for different cellular components (cytoplasm, nuclei, cells). If + those are not present, it falls back to a generic AreaShape bounding box. If + all required columns are present, it filters and returns a new CytoDataFrame + instance containing only these columns. Returns: Optional[CytoDataFrame_type]: @@ -522,17 +550,46 @@ def get_bounding_box_from_data( "Cells_AreaShape_BoundingBoxMinimum_X", "Cells_AreaShape_BoundingBoxMinimum_Y", ], + "generic": [ + "AreaShape_BoundingBoxMaximum_X", + "AreaShape_BoundingBoxMaximum_Y", + "AreaShape_BoundingBoxMinimum_X", + "AreaShape_BoundingBoxMinimum_Y", + ], + } + column_groups_z = { + "cyto": [ + "Cytoplasm_AreaShape_BoundingBoxMaximum_Z", + "Cytoplasm_AreaShape_BoundingBoxMinimum_Z", + ], + "nuclei": [ + "Nuclei_AreaShape_BoundingBoxMaximum_Z", + "Nuclei_AreaShape_BoundingBoxMinimum_Z", + ], + "cells": [ + "Cells_AreaShape_BoundingBoxMaximum_Z", + "Cells_AreaShape_BoundingBoxMinimum_Z", + ], + "generic": [ + "AreaShape_BoundingBoxMaximum_Z", + "AreaShape_BoundingBoxMinimum_Z", + ], } # Determine which group of columns to select based on availability in self.data selected_group = None - for group, cols in column_groups.items(): + ordered_groups = ("cyto", "nuclei", "cells", "generic") + for group in ordered_groups: + cols = column_groups[group] if all(col in self.columns.tolist() for col in cols): selected_group = group break # Assign the selected columns to self.bounding_box_df if selected_group: + z_cols = column_groups_z.get(selected_group, []) + if z_cols and all(col in self.columns.tolist() for col in z_cols): + column_groups[selected_group] = column_groups[selected_group] + z_cols logger.debug( "Bounding box columns found: %s", column_groups[selected_group], @@ -746,6 +803,12 @@ def to_ome_parquet( # noqa: PLR0915, PLR0912, C901 image_path_cols_str = self.find_image_path_columns( image_cols=image_cols_str, all_cols=all_cols_str ) + display_options = self._custom_attrs.get("display_options", {}) or {} + if self._custom_attrs.get("data_context_dir") and display_options.get( + "ignore_image_path_columns" + ): + logger.debug("Ignoring image path columns due to display option.") + image_path_cols_str = {} image_path_cols = {} for image_col in image_cols: key = str(image_col) @@ -972,8 +1035,10 @@ def find_image_columns(self: CytoDataFrame_type) -> List[str]: for column in self.columns if self[column] .apply( - lambda value: isinstance(value, str) - and re.match(pattern, value, flags=re.IGNORECASE) + lambda value: ( + isinstance(value, (str, os.PathLike)) + and re.match(pattern, str(value), flags=re.IGNORECASE) + ) ) .any() ] @@ -1203,10 +1268,13 @@ def _extract_array_from_ome_arrow( # noqa: PLR0911 pixels_meta = data_value.get("pixels_meta", {}) size_x = int(pixels_meta.get("size_x")) size_y = int(pixels_meta.get("size_y")) + size_z = int(pixels_meta.get("size_z") or 1) planes = data_value.get("planes") if size_x <= 0 or size_y <= 0 or planes is None: return None + if size_z > 1: + return None if isinstance(planes, np.ndarray): plane_entries = planes.tolist() @@ -1250,16 +1318,23 @@ def _ensure_uint8(array: np.ndarray) -> np.ndarray: return img_as_ubyte(arr) @staticmethod - def _ensure_uint8(array: np.ndarray) -> np.ndarray: - """Convert the provided array to uint8 without unnecessary warnings.""" + def _is_3d_image_array(array: np.ndarray) -> bool: + if array.ndim < MIN_VOLUME_NDIM: + return False + if array.ndim != MIN_VOLUME_NDIM: + return True + if array.shape[-1] not in RGB_LIKE_CHANNEL_COUNTS: + return True - arr = np.asarray(array) - if np.issubdtype(arr.dtype, np.integer): - min_val = arr.min(initial=0) - max_val = arr.max(initial=0) - if min_val >= 0 and max_val <= 255: # noqa: PLR2004 - return arr.astype(np.uint8, copy=False) - return img_as_ubyte(arr) + height, width = int(array.shape[0]), int(array.shape[1]) + short_side = min(height, width) + long_side = max(height, width) + if short_side < MIN_RGB_SPATIAL_DIM: + return True + + aspect_ratio = long_side / max(short_side, 1) + looks_like_rgb_2d = aspect_ratio <= MAX_RGB_ASPECT_RATIO + return not looks_like_rgb_2d def _prepare_cropped_image_layers( # noqa: C901, PLR0915, PLR0912, PLR0913 self: CytoDataFrame_type, @@ -1296,6 +1371,18 @@ def _prepare_cropped_image_layers( # noqa: C901, PLR0915, PLR0912, PLR0913 return layers data_value = str(data_value) + + display_options = self._custom_attrs.get("display_options", {}) or {} + if display_options.get("ignore_image_path_columns"): + image_path = None + + if self._custom_attrs.get("data_context_dir"): + normalized = data_value + if normalized.startswith("file:"): + normalized = normalized[len("file:") :] + if "/" in normalized or "\\" in normalized: + normalized = pathlib.Path(normalized).name + data_value = normalized candidate_path = None if image_path is not None and pd.isna(image_path): @@ -1327,6 +1414,12 @@ def _prepare_cropped_image_layers( # noqa: C901, PLR0915, PLR0912, PLR0913 ) candidate_path = candidate_paths[0] else: + if self._custom_attrs.get("data_context_dir") is not None: + logger.debug( + "Checked data_context_dir %s for %s but found no matches.", + self._custom_attrs["data_context_dir"], + data_value, + ) logger.debug("No candidate file found for: %s", data_value) return layers @@ -1336,6 +1429,50 @@ def _prepare_cropped_image_layers( # noqa: C901, PLR0915, PLR0912, PLR0913 logger.error(exc) return layers + if self._is_3d_image_array(orig_image_array): + logger.debug( + "Detected 3D image at %s; returning HTML view.", candidate_path + ) + html_view = None + volume_array = np.asarray(orig_image_array) + if ( + volume_array.ndim > MIN_VOLUME_NDIM + and volume_array.shape[-1] in RGB_LIKE_CHANNEL_COUNTS + ): + volume_array = volume_array[..., 0] + + if volume_array.ndim == MIN_VOLUME_NDIM: + with contextlib.suppress(Exception): + volume = self._ensure_uint8(volume_array) + dims = (volume.shape[2], volume.shape[1], volume.shape[0]) + html_view = build_3d_image_html_view( + volume=volume, + dims=dims, + data_value=data_value, + candidate_path=candidate_path, + display_options=self._custom_attrs.get("display_options"), + ) + + if html_view is None: + html_view = build_3d_html_from_path( + data_value=data_value, + candidate_path=candidate_path, + display_options=self._custom_attrs.get("display_options"), + ensure_uint8=self._ensure_uint8, + is_ome_arrow_value=self._is_ome_arrow_value, + logger=logger, + ) + layers[self._HTML_3D_STUB_KEY] = ( + html_view + if html_view is not None + else build_3d_image_html_stub( + data_value=data_value, + candidate_path=candidate_path, + display_options=self._custom_attrs.get("display_options"), + ) + ) + return layers + if self._custom_attrs["image_adjustment"] is not None: logger.debug("Adjusting image with custom image adjustment function.") orig_image_array = self._custom_attrs["image_adjustment"]( @@ -1563,7 +1700,7 @@ def _prepare_cropped_image_array( bounding_box: Tuple[int, int, int, int], compartment_center_xy: Optional[Tuple[int, int]] = None, image_path: Optional[str] = None, - ) -> Optional[np.ndarray]: + ) -> Tuple[Optional[np.ndarray], Optional[str]]: layers = self._prepare_cropped_image_layers( data_value=data_value, bounding_box=bounding_box, @@ -1571,7 +1708,7 @@ def _prepare_cropped_image_array( image_path=image_path, include_composite=True, ) - return layers.get("composite") + return layers.get("composite"), layers.get(self._HTML_3D_STUB_KEY) def _image_array_to_html(self: CytoDataFrame_type, image_array: np.ndarray) -> str: """Encode an image array as an HTML tag.""" @@ -1610,12 +1747,27 @@ def process_ome_arrow_data_as_html_display( array = self._extract_array_from_ome_arrow(data_value) if array is None: - return data_value + volume_data = extract_volume_from_ome_arrow( + data_value, + self._ensure_uint8, + self._is_ome_arrow_value, + logger, + ) + if volume_data is None: + return str(data_value) + volume, dims = volume_data + return build_3d_image_html_view( + volume=volume, + dims=dims, + data_value="ome-arrow", + candidate_path=pathlib.Path("ome-arrow"), + display_options=self._custom_attrs.get("display_options"), + ) try: return self._image_array_to_html(array) except Exception: - return data_value + return str(data_value) def process_image_data_as_html_display( self: CytoDataFrame_type, @@ -1659,13 +1811,16 @@ def process_image_data_as_html_display( ) data_value = str(data_value) - cropped_img_array = self._prepare_cropped_image_array( + cropped_img_array, html_stub = self._prepare_cropped_image_array( data_value=data_value, bounding_box=bounding_box, compartment_center_xy=compartment_center_xy, image_path=image_path, ) + if html_stub is not None: + return html_stub + if cropped_img_array is None: return data_value @@ -1676,6 +1831,783 @@ def process_image_data_as_html_display( except Exception: return data_value + def _get_3d_volume_from_cell( # noqa: C901, PLR0912, PLR0915 + self: CytoDataFrame_type, + row: Any, + column: Any, + ) -> Tuple[np.ndarray, Tuple[int, int, int]]: + display_options = self._custom_attrs.get("display_options", {}) or {} + cache_disabled = bool(display_options.get("volume_disable_cache")) + cache_max_entries_raw = display_options.get("volume_cache_max_entries", 32) + try: + cache_max_entries = max(1, int(cache_max_entries_raw)) + except (TypeError, ValueError): + cache_max_entries = 32 + + cache: "OrderedDict[str, Tuple[np.ndarray, Tuple[int, int, int]]]" = ( + OrderedDict() + ) + if not cache_disabled: + raw_cache = self._custom_attrs.get("_volume_cache", {}) + if isinstance(raw_cache, OrderedDict): + cache = raw_cache + else: + cache = OrderedDict(raw_cache or {}) + self._custom_attrs["_volume_cache"] = cache + cache_key = f"{row}::{column}" + if not cache_disabled and cache_key in cache: + cached = cache.pop(cache_key) + cache[cache_key] = cached + return cached + + try: + value = self.loc[row, column] + except Exception: + value = self.iloc[row][column] + + volume = None + dims = None + + if isinstance(value, np.ndarray) and self._is_3d_image_array(value): + volume = np.asarray(value) + dims = (volume.shape[2], volume.shape[1], volume.shape[0]) + elif self._is_ome_arrow_value(value): + volume_data = extract_volume_from_ome_arrow( + value, + self._ensure_uint8, + self._is_ome_arrow_value, + logger, + ) + if volume_data is not None: + volume, dims = volume_data + elif isinstance(value, (str, pathlib.Path)): + volume_ndim = 3 + color_channel_counts = (1, volume_ndim, 4) + data_value = str(value) + context_dir = self._custom_attrs.get("data_context_dir") + if context_dir: + normalized = data_value + if normalized.startswith("file:"): + normalized = normalized[len("file:") :] + if "/" in normalized or "\\" in normalized: + normalized = pathlib.Path(normalized).name + data_value = normalized + + data_path = pathlib.Path(data_value) + candidate_paths: List[pathlib.Path] = [] + seen_candidates: set[str] = set() + + def _add_candidate(path_value: pathlib.Path) -> None: + if not path_value.is_file(): + return + key = str(path_value.resolve()) + if key not in seen_candidates: + seen_candidates.add(key) + candidate_paths.append(path_value) + + _add_candidate(data_path) + if context_dir: + _add_candidate(pathlib.Path(context_dir) / data_path) + + candidate_filenames = {pathlib.Path(data_value).name} + needs_path_column_lookup = context_dir is None or not candidate_paths + if needs_path_column_lookup: + image_cols = self.find_image_columns() or [] + all_cols = self.columns.tolist() + path_df = self._custom_attrs.get("data_image_paths") + if path_df is not None: + all_cols = list( + dict.fromkeys([*all_cols, *path_df.columns.tolist()]) + ) + image_path_cols = self.find_image_path_columns(image_cols, all_cols) + + def _row_value(col_name: str) -> Any: + if col_name in self.columns: + try: + return self.loc[row, col_name] + except Exception: + return self.iloc[row][col_name] + if ( + path_df is not None + and col_name in path_df.columns + and row in path_df.index + ): + return path_df.loc[row, col_name] + return None + + for image_col, path_col in image_path_cols.items(): + row_filename = _row_value(image_col) + row_path = _row_value(path_col) + if row_filename is not None and not pd.isna(row_filename): + candidate_filenames.add(pathlib.Path(str(row_filename)).name) + if ( + row_filename is not None + and not pd.isna(row_filename) + and row_path is not None + and not pd.isna(row_path) + ): + _add_candidate(pathlib.Path(str(row_path)) / str(row_filename)) + if ( + image_col == str(column) + and row_path is not None + and not pd.isna(row_path) + ): + _add_candidate( + pathlib.Path(str(row_path)) / pathlib.Path(data_value).name + ) + + if context_dir: + context_root = pathlib.Path(context_dir) + for filename in sorted(candidate_filenames): + _add_candidate(context_root / filename) + if not candidate_paths: + for found in context_root.rglob(filename): + _add_candidate(found) + + if candidate_paths: + data_path = candidate_paths[0] + + # First attempt direct image loading for TIFF/Zarr-backed 3D arrays. + for file_candidate in candidate_paths: + with contextlib.suppress(Exception): + image_volume = np.asarray(imageio.imread(file_candidate)) + if self._is_3d_image_array(image_volume): + if ( + image_volume.ndim > volume_ndim + and image_volume.shape[-1] in color_channel_counts + ): + image_volume = image_volume[..., 0] + if image_volume.ndim == volume_ndim: + volume = image_volume + dims = (volume.shape[2], volume.shape[1], volume.shape[0]) + data_path = file_candidate + break + + # Fallback to OME-Arrow path decoding for string/path cells. + try: + from ome_arrow import OMEArrow # type: ignore + + if volume is None: + decode_candidates = candidate_paths or [data_path] + for decode_path in decode_candidates: + ome_struct = OMEArrow(data=str(decode_path)).data + if hasattr(ome_struct, "as_py"): + ome_struct = ome_struct.as_py() + volume_data = extract_volume_from_ome_arrow( + ome_struct, + self._ensure_uint8, + self._is_ome_arrow_value, + logger, + ) + if volume_data is not None: + volume, dims = volume_data + data_path = decode_path + break + except Exception as exc: + logger.debug( + ( + "OME-Arrow fallback decode failed for row=%s, " + "column=%s, path=%s: %s" + ), + row, + column, + data_path, + exc, + ) + + if volume is None or dims is None: + raise ValueError("Selected cell does not contain a 3D volume.") + + # Apply per-row bounding box cropping when available (XYZ). + try: + display_options = self._custom_attrs.get("display_options", {}) or {} + if display_options.get("volume_disable_bbox_crop"): + return volume, dims + + bbox_source = self._custom_attrs.get("data_bounding_box") + bbox_cols = ( + bbox_source.columns.tolist() + if bbox_source is not None + else self.columns.tolist() + ) + + def _find_col(tag: str) -> Optional[str]: + return next((col for col in bbox_cols if tag in str(col)), None) + + x_min_col = _find_col("Minimum_X") + x_max_col = _find_col("Maximum_X") + y_min_col = _find_col("Minimum_Y") + y_max_col = _find_col("Maximum_Y") + z_min_col = _find_col("Minimum_Z") + z_max_col = _find_col("Maximum_Z") + + if all( + col is not None for col in (x_min_col, x_max_col, y_min_col, y_max_col) + ): + try: + row_data = ( + bbox_source.loc[row] + if bbox_source is not None and row in bbox_source.index + else self.loc[row] + ) + except Exception: + row_data = self.iloc[row] + + x_min = int(row_data[x_min_col]) + x_max = int(row_data[x_max_col]) + y_min = int(row_data[y_min_col]) + y_max = int(row_data[y_max_col]) + + z_min = 0 + z_max = volume.shape[0] + if z_min_col is not None and z_max_col is not None: + z_min = int(row_data[z_min_col]) + z_max = int(row_data[z_max_col]) + + # Clamp to volume bounds + z_min = max(0, min(z_min, volume.shape[0])) + z_max = max(z_min + 1, min(z_max, volume.shape[0])) + y_min = max(0, min(y_min, volume.shape[1])) + y_max = max(y_min + 1, min(y_max, volume.shape[1])) + x_min = max(0, min(x_min, volume.shape[2])) + x_max = max(x_min + 1, min(x_max, volume.shape[2])) + + volume = volume[z_min:z_max, y_min:y_max, x_min:x_max] + dims = (volume.shape[2], volume.shape[1], volume.shape[0]) + logger.debug( + "Applied 3D bounding box crop: x(%s,%s) y(%s,%s) z(%s,%s)", + x_min, + x_max, + y_min, + y_max, + z_min, + z_max, + ) + except Exception as exc: + logger.debug("Skipping 3D bounding box crop due to error: %s", exc) + + if not cache_disabled: + cache[cache_key] = (volume, dims) + while len(cache) > cache_max_entries: + cache.popitem(last=False) + return volume, dims + + def _find_3d_columns_for_display( + self: CytoDataFrame_type, + max_rows: int = 5, + ) -> List[Any]: + """Find columns that contain at least one renderable 3D value.""" + + image_cols = self.find_image_columns() or [] + ome_cols = self.find_ome_arrow_columns(self) + candidate_columns = list(dict.fromkeys([*image_cols, *ome_cols])) + if not candidate_columns: + return [] + + sample_rows = [ + row for row in self.get_displayed_rows() if str(row) != "\u2026" + ][:max_rows] + if not sample_rows: + sample_rows = self.index.tolist()[:max_rows] + + columns_3d: List[Any] = [] + for column in candidate_columns: + for row in sample_rows: + try: + self._get_3d_volume_from_cell(row=row, column=column) + columns_3d.append(column) + break + except Exception: + continue + + return columns_3d + + def _build_pyvista_viewer( # noqa: C901, PLR0912, PLR0913, PLR0915 + self: CytoDataFrame_type, + volume: np.ndarray, + backend: str, + widget_height: str, + spacing: Tuple[float, float, float] = (1.0, 1.0, 1.0), + opacity: Any = "sigmoid", + shade: bool = False, + **kwargs: Any, + ) -> Any: + try: + import pyvista as pv # type: ignore + except Exception as exc: + raise RuntimeError( + "PyVista is required for trame-based 3D rendering." + ) from exc + + display_options = self._custom_attrs.get("display_options", {}) or {} + cmap = kwargs.pop("cmap", display_options.get("volume_cmap", "gray")) + background = display_options.get("volume_background", "black") + percentile_clim = display_options.get("volume_percentile_clim", (1.0, 99.9)) + interpolation = display_options.get("volume_interpolation", "nearest") + sampling_scale = display_options.get("volume_sampling_scale", 0.5) + show_axes = display_options.get("volume_show_axes", True) + + vol_xyz = np.transpose(volume, (2, 1, 0)) + if vol_xyz.dtype != np.float32: + vol_xyz = vol_xyz.astype(np.float32, copy=False) + grid = pv.ImageData() + grid.dimensions = tuple(int(v) for v in vol_xyz.shape) + grid.spacing = spacing + grid.origin = (0.0, 0.0, 0.0) + grid.point_data.clear() + grid.point_data["scalars"] = np.asfortranarray(vol_xyz).ravel(order="F") + try: + grid.point_data.set_active_scalars("scalars") + except AttributeError: + try: + grid.point_data.active_scalars_name = "scalars" + except Exception: + grid.set_active_scalars("scalars") + + plotter = pv.Plotter(notebook=True) + plotter.set_background(background) + + if vol_xyz.size: + try: + nz = vol_xyz[vol_xyz > 0] + data_for_clim = nz if nz.size else vol_xyz + vmin, vmax = np.percentile(data_for_clim, percentile_clim) + vmin = float(vmin) + vmax = float(vmax if vmax > vmin else vmin + 1.0) + except Exception: + vmin = float(np.min(vol_xyz)) + vmax = float(np.max(vol_xyz) if np.max(vol_xyz) > vmin else vmin + 1.0) + else: + vmin, vmax = 0.0, 1.0 + + if opacity == "sigmoid" and vmax <= 1.0: + opacity = "linear" + + base_sample = max(min(spacing), 1e-6) + vol_actor = plotter.add_volume( + grid, + scalars="scalars", + opacity=opacity, + shade=shade, + cmap=cmap, + clim=(vmin, vmax), + show_scalar_bar=False, + opacity_unit_distance=base_sample, + ) + try: + prop = getattr(vol_actor, "prop", None) or vol_actor.GetProperty() + if interpolation.lower().startswith("near"): + prop.SetInterpolationTypeToNearest() + else: + prop.SetInterpolationTypeToLinear() + if hasattr(prop, "SetInterpolateScalarsBeforeMapping"): + prop.SetInterpolateScalarsBeforeMapping(False) + if hasattr(prop, "SetScalarOpacityUnitDistance"): + prop.SetScalarOpacityUnitDistance(base_sample) + except Exception as exc: + logger.debug("Unable to configure volume property interpolation: %s", exc) + try: + mapper = getattr(vol_actor, "mapper", None) or vol_actor.GetMapper() + if hasattr(mapper, "SetAutoAdjustSampleDistances"): + mapper.SetAutoAdjustSampleDistances(False) + if hasattr(mapper, "SetUseJittering"): + mapper.SetUseJittering(False) + if hasattr(mapper, "SetSampleDistance"): + mapper.SetSampleDistance(float(base_sample * sampling_scale)) + except Exception as exc: + logger.debug("Unable to configure volume mapper sampling: %s", exc) + + if show_axes: + with contextlib.suppress(Exception): + plotter.add_axes() + jupyter_kwargs = kwargs.pop("jupyter_kwargs", {}) + jupyter_kwargs.setdefault("width", "100%") + jupyter_kwargs.setdefault("height", "100%") + jupyter_kwargs.setdefault("add_menu", False) + jupyter_kwargs.setdefault("collapse_menu", True) + viewer = plotter.show( + jupyter_backend=backend, + return_viewer=True, + jupyter_kwargs=jupyter_kwargs, + **kwargs, + ) + with contextlib.suppress(Exception): + setattr(viewer, "_cdf_plotter", plotter) + if hasattr(viewer, "layout"): + try: + import ipywidgets as widgets # type: ignore + + viewer.layout = widgets.Layout( + width="100%", + height="100%", + min_height=widget_height, + margin="0", + padding="0", + ) + except Exception as exc: + logger.debug("Unable to assign viewer widget layout: %s", exc) + if hasattr(viewer, "value") and isinstance(viewer.value, str): + updated = viewer.value.replace("border: 1px solid", "border: 0 solid") + if "width:" in updated or "height:" in updated: + updated = re.sub(r"width:\\s*[^;]+;", "width: 100%;", updated) + updated = re.sub(r"height:\\s*[^;]+;", "height: 100%;", updated) + updated = re.sub( + r"class=\"pyvista\"", + 'class="pyvista" style="width: 100%; height: 100%; ' + 'display: block; position: absolute; top: 0; left: 0;"', + updated, + ) + viewer.value = updated + return viewer + + def show_trame( # noqa: PLR0915 + self: CytoDataFrame_type, + row: Any, + column: Any, + backend: Optional[str] = "trame", + **kwargs: Any, + ) -> Any: + """Render the dataframe HTML with a trame-backed 3D view. + + Args: + row: Row label or index of the 3D cell. + column: Column label containing the 3D data. + backend: PyVista Jupyter backend name. + **kwargs: Extra options forwarded to the viewer builder. + + Returns: + A trame layout when available, otherwise an ipywidgets container. + """ + + volume, _dims = self._get_3d_volume_from_cell(row=row, column=column) + html_content = self._generate_jupyter_dataframe_html() + + if backend is None: + display_options = self._custom_attrs.get("display_options", {}) or {} + if display_options.get("view") == "trame": + backend = "trame" + + try: + import pyvista as pv # type: ignore + except Exception as exc: + raise RuntimeError( + "PyVista is required for trame-based 3D rendering." + ) from exc + + if hasattr(pv, "set_jupyter_backend"): + with contextlib.suppress(Exception): + pv.set_jupyter_backend(backend) + + spacing = kwargs.pop("spacing", (1.0, 1.0, 1.0)) + opacity = kwargs.pop("opacity", "sigmoid") + shade = kwargs.pop("shade", False) + widget_height = kwargs.pop("widget_height", "700px") + table_width = kwargs.pop("table_width", "60%") + view_width = kwargs.pop("view_width", "40%") + table_max_height = kwargs.pop( + "table_max_height", + (self._custom_attrs.get("display_options") or {}).get( + "table_max_height", "700px" + ), + ) + + viewer = self._build_pyvista_viewer( + volume=volume, + backend=backend, + widget_height=widget_height, + spacing=spacing, + opacity=opacity, + shade=shade, + ) + + try: + from trame.app import get_server # type: ignore + from trame.widgets import html as trame_html # type: ignore + + server = ( + getattr(viewer, "server", None) + or getattr(viewer, "_server", None) + or get_server() + ) + client_type = getattr(server, "client_type", "vue2") + if client_type == "vue3": + from trame.ui.vuetify3 import SinglePageLayout # type: ignore + from trame.widgets import vuetify3 as vuetify # type: ignore + else: + from trame.ui.vuetify import SinglePageLayout # type: ignore + from trame.widgets import vuetify # type: ignore + + with SinglePageLayout(server) as layout: + layout.content.children = [] + with layout.content: # noqa: SIM117 + with vuetify.VContainer( + fluid=True, + style="padding:0;height:100%;overflow:auto;", + ): + with vuetify.VRow(style="margin:0;height:100%;"): + with vuetify.VCol(cols=12, md=7, style="padding: 0;"): + trame_html.Div( + v_html=html_content, + style=( + f"width:{table_width};max-width:100%;" + f"max-height:{table_max_height};" + "overflow:auto;border:1px solid #e0e0e0;" + ), + ) + with vuetify.VCol(cols=12, md=5, style="padding: 0;"): + trame_html.Div( + children=[viewer], + style=f"width:{view_width};", + ) + try: + url = getattr(server, "url", None) + if callable(url): + logger.debug("Trame server URL: %s", url()) + except Exception as exc: + logger.debug("Unable to fetch trame server URL: %s", exc) + display(layout) + return layout + except Exception as exc: + logger.debug("Falling back to ipywidgets layout: %s", exc) + try: + import ipywidgets as widgets # type: ignore + except Exception as widget_exc: + raise RuntimeError( + "ipywidgets is required for notebook layout." + ) from widget_exc + + html_widget = widgets.HTML( + value=html_content, + layout=widgets.Layout( + width=table_width, + height=table_max_height, + max_height=table_max_height, + overflow="auto", + border="1px solid #e0e0e0", + ), + ) + view_box = widgets.Box( + [viewer], + layout=widgets.Layout( + width=view_width, + height=table_max_height, + max_height=table_max_height, + overflow="auto", + ), + ) + container = widgets.HBox( + [html_widget, view_box], + layout=widgets.Layout( + width="100%", + height=table_max_height, + max_height=table_max_height, + overflow="auto", + ), + ) + return container + + def show_widget_table( # noqa: C901, PLR0912, PLR0915 + self: CytoDataFrame_type, + column: Any, + rows: Optional[List[Any]] = None, + backend: Optional[str] = "trame", + **kwargs: Any, + ) -> Any: + """Render a widget-based table with 3D views embedded in columns.""" + + if backend is None: + display_options = self._custom_attrs.get("display_options", {}) or {} + if display_options.get("view") == "trame": + backend = "trame" + + try: + import ipywidgets as widgets # type: ignore + except Exception as exc: + raise RuntimeError("ipywidgets is required for widget tables.") from exc + + import html as html_lib + + columns_3d = kwargs.pop("columns_3d", None) + if columns_3d is None: + columns_3d = [column] + if not columns_3d: + raise ValueError("columns_3d must include at least one column.") + target_columns = set(columns_3d) + + def _coerce_scalar(value: Any) -> Any: + if isinstance(value, (np.integer, np.floating)): + return value.item() + return value + + display_rows = rows + if display_rows is None: + display_rows = self.get_displayed_rows() + display_rows = [_coerce_scalar(v) for v in display_rows] + max_rows_setting = pd.get_option("display.max_rows") + if len(self) > max_rows_setting and display_rows: + ellipsis_marker = "\u2026" + if display_rows[-1] != ellipsis_marker: + display_rows.insert(len(display_rows) // 2, ellipsis_marker) + max_rows = kwargs.pop("max_rows", None) + if max_rows is not None: + display_rows = display_rows[:max_rows] + + columns = kwargs.pop("columns", list(self.columns)) + max_cols = kwargs.pop("max_columns", None) + if max_cols is not None and len(columns) > max_cols: + head = max_cols // 2 + tail = max_cols - head + columns = columns[:head] + columns[-tail:] + + display_options = self._custom_attrs.get("display_options") or {} + default_height = display_options.get("height") or display_options.get("width") + default_width = display_options.get("width") or "300px" + + def _css_size(value: Any, default: str) -> str: + if value is None: + return default + if isinstance(value, (np.integer, np.floating)): + value = value.item() + if isinstance(value, (int, float)): + return f"{int(value)}px" + return str(value) + + widget_height = _css_size(kwargs.pop("widget_height", "100%"), "100%") + cell_width = kwargs.pop("cell_width", None) + index_width = _css_size(kwargs.pop("index_width", "140px"), "140px") + row_height = _css_size( + kwargs.pop("row_height", default_height or "300px"), "300px" + ) + debug = kwargs.pop("debug", False) + + grid = widgets.GridspecLayout( + len(display_rows) + 1, + len(columns) + 1, + layout=widgets.Layout( + width="auto", + max_width="100%", + max_height=_css_size(kwargs.pop("table_max_height", "700px"), "700px"), + overflow="auto", + ), + ) + column_width = _css_size( + kwargs.pop("column_width", cell_width or default_width), "300px" + ) + grid.layout.grid_template_columns = f"{index_width} " + " ".join( + [column_width] * len(columns) + ) + grid.layout.grid_auto_rows = row_height + grid.layout.grid_column_gap = _css_size( + kwargs.pop("column_gap", "12px"), "12px" + ) + grid.layout.grid_row_gap = _css_size(kwargs.pop("row_gap", "8px"), "8px") + grid.layout.align_items = "stretch" + grid.layout.justify_items = "flex-start" + + header_row_height = kwargs.pop("header_row_height", "28px") + + def _safe_text(value: Any) -> str: + if isinstance(value, (np.integer, np.floating)): + return str(value.item()) + return str(value) + + grid[0, 0] = widgets.HTML( + value=( + "
" + ), + layout=widgets.Layout(height=header_row_height, width="100%"), + ) + for col_idx, col in enumerate(columns, start=1): + grid[0, col_idx] = widgets.HTML( + value=( + "
" + f"{html_lib.escape(_safe_text(col))}
" + ), + layout=widgets.Layout( + height=header_row_height, + width="100%", + ), + ) + + for row_idx, row_label in enumerate(display_rows, start=1): + row_bg = "#FFFFFF" if row_idx % 2 == 1 else "#F5F5F5" + row_label_html = ( + f"
" + f"{html_lib.escape(_safe_text(row_label))}
" + ) + grid[row_idx, 0] = widgets.HTML( + value=row_label_html, + layout=widgets.Layout( + height="100%", + width="100%", + ), + ) + for col_idx, col in enumerate(columns, start=1): + if str(row_label) == "\u2026": + grid[row_idx, col_idx] = widgets.HTML( + value=( + f"
\u2026
" + ), + layout=widgets.Layout(width="100%", height="100%"), + ) + continue + if col in target_columns: + try: + volume, _dims = self._get_3d_volume_from_cell( + row=row_label, column=col + ) + effective_height = ( + row_height if widget_height == "100%" else widget_height + ) + viewer = self._build_pyvista_viewer( + volume=volume, + backend=backend, + widget_height=effective_height, + ) + grid[row_idx, col_idx] = widgets.Box( + [viewer], + layout=widgets.Layout( + width="100%", + height=row_height, + position="relative", + display="flex", + align_items="stretch", + justify_content="flex-start", + overflow="hidden", + margin="0", + padding="0", + ), + ) + continue + except Exception as exc: + if debug: + raise + logger.debug("3D widget render failed: %s", exc) + grid[row_idx, col_idx] = widgets.HTML( + value="3D render failed", + layout=widgets.Layout(width="100%", height=row_height), + ) + continue + + value = self.loc[row_label, col] + text_value = html_lib.escape(_safe_text(value)) + grid[row_idx, col_idx] = widgets.HTML( + value=( + f"
{text_value}
" + ), + layout=widgets.Layout( + width="100%", + height="100%", + ), + ) + + return grid + def get_displayed_rows(self: CytoDataFrame_type) -> List[int]: """ Get the indices of the rows that are currently @@ -1832,6 +2764,12 @@ def _generate_jupyter_dataframe_html( # noqa: C901, PLR0912, PLR0915 ) or {} ) + display_options = self._custom_attrs.get("display_options", {}) or {} + if self._custom_attrs.get("data_context_dir") and display_options.get( + "ignore_image_path_columns" + ): + logger.debug("Ignoring image path columns due to display option.") + image_path_cols_str = {} # Remap any returned path-column names back to the # original (possibly non-string) labels @@ -1987,49 +2925,273 @@ def _generate_jupyter_dataframe_html( # noqa: C901, PLR0912, PLR0915 decimal=".", ) - return fmt.DataFrameRenderer(formatter).to_html() + table_html = fmt.DataFrameRenderer(formatter).to_html() + style = ( + "" + ) + return style + table_html else: return None - def _render_output(self: CytoDataFrame_type) -> str: + def _render_output(self: CytoDataFrame_type) -> None: # Return a hidden div that nbconvert will keep but Jupyter will ignore html_content = self._generate_jupyter_dataframe_html() with self._custom_attrs["_output"]: display(HTML(html_content)) + if "cyto-3d-image" in html_content and "data-volume" in html_content: + display( + Javascript( + build_3d_vtk_js_initializer( + display_options=self._custom_attrs.get("display_options") + ) + ) + ) - # We duplicate the display so that the jupyter notebook - # retains printable output (which appears in static exports - # such as PDFs or GitHub webpages). Ipywidget output - # rendering is not retained in these formats, so we must - # add this in order to retain visibility of the data. - display( - HTML( - f""" - - - """ + + + """ + ) ) + + def _pyvista_volume_snapshot_html( # noqa: C901, PLR0912, PLR0915 + self: CytoDataFrame_type, + volume: np.ndarray, + dims: Tuple[int, int, int], + ) -> Optional[str]: + """Render a static PyVista snapshot for a 3D volume.""" + try: + import pyvista as pv # type: ignore + except Exception: + return None + + display_options = self._custom_attrs.get("display_options", {}) or {} + width = display_options.get("width", "300px") + height = display_options.get("height", width) + cmap = display_options.get("volume_cmap", "gray") + background = display_options.get("volume_background", "black") + percentile_clim = display_options.get("volume_percentile_clim", (1.0, 99.9)) + interpolation = display_options.get("volume_interpolation", "nearest") + sampling_scale = display_options.get("volume_sampling_scale", 0.5) + + vol_xyz = np.transpose(volume, (2, 1, 0)) + expected_dims = (volume.shape[2], volume.shape[1], volume.shape[0]) + if dims != expected_dims: + logger.debug( + "Snapshot dims %s do not match volume-derived dims %s.", + dims, + expected_dims, + ) + if vol_xyz.dtype != np.float32: + vol_xyz = vol_xyz.astype(np.float32, copy=False) + + if vol_xyz.size: + try: + nz = vol_xyz[vol_xyz > 0] + data_for_clim = nz if nz.size else vol_xyz + vmin, vmax = np.percentile(data_for_clim, percentile_clim) + vmin = float(vmin) + vmax = float(vmax if vmax > vmin else vmin + 1.0) + except Exception: + vmin = float(np.min(vol_xyz)) + vmax = float(np.max(vol_xyz) if np.max(vol_xyz) > vmin else vmin + 1.0) + else: + vmin, vmax = 0.0, 1.0 + + spacing = (1.0, 1.0, 1.0) + base_sample = max(min(spacing), 1e-6) + grid = pv.ImageData() + grid.dimensions = tuple(int(v) for v in vol_xyz.shape) + grid.spacing = spacing + grid.origin = (0.0, 0.0, 0.0) + grid.point_data.clear() + grid.point_data["scalars"] = np.asfortranarray(vol_xyz).ravel(order="F") + try: + grid.point_data.set_active_scalars("scalars") + except AttributeError: + try: + grid.point_data.active_scalars_name = "scalars" + except Exception: + grid.set_active_scalars("scalars") + + plotter = pv.Plotter(off_screen=True) + plotter.set_background(background) + vol_actor = plotter.add_volume( + grid, + scalars="scalars", + opacity="sigmoid", + shade=False, + cmap=cmap, + clim=(vmin, vmax), + show_scalar_bar=False, + opacity_unit_distance=base_sample, + ) + try: + prop = getattr(vol_actor, "prop", None) or vol_actor.GetProperty() + if interpolation.lower().startswith("near"): + prop.SetInterpolationTypeToNearest() + else: + prop.SetInterpolationTypeToLinear() + if hasattr(prop, "SetInterpolateScalarsBeforeMapping"): + prop.SetInterpolateScalarsBeforeMapping(False) + if hasattr(prop, "SetScalarOpacityUnitDistance"): + prop.SetScalarOpacityUnitDistance(base_sample) + except Exception as exc: + logger.debug("Unable to configure snapshot volume property: %s", exc) + try: + mapper = getattr(vol_actor, "mapper", None) or vol_actor.GetMapper() + if hasattr(mapper, "SetAutoAdjustSampleDistances"): + mapper.SetAutoAdjustSampleDistances(False) + if hasattr(mapper, "SetUseJittering"): + mapper.SetUseJittering(False) + if hasattr(mapper, "SetSampleDistance"): + mapper.SetSampleDistance(float(base_sample * sampling_scale)) + except Exception as exc: + logger.debug("Unable to configure snapshot mapper sampling: %s", exc) + + try: + img = plotter.screenshot(return_img=True) + if img is None: + return None + from PIL import Image as PILImage # type: ignore + + buf = BytesIO() + PILImage.fromarray(img).save(buf, format="PNG") + b64 = base64.b64encode(buf.getvalue()).decode("ascii") + html_style = ";".join([f"width:{width}", f"height:{height}"]) + return f'' + except Exception as exc: + logger.debug("Failed to render PyVista snapshot: %s", exc) + return None + + def _snapshot_cache_key(self: CytoDataFrame_type, row: Any, column: Any) -> str: + return f"{row}::{column}" + + def _enqueue_snapshot_tasks( + self: CytoDataFrame_type, + rows: List[Any], + columns: List[Any], + ) -> None: + """Placeholder hook for async snapshot pre-rendering. + + TODO: Add optional background workers to precompute `_snapshot_cache` + entries for the provided rows/columns when async rendering is enabled. + """ + logger.debug( + "Snapshot task queueing not implemented yet (rows=%d, columns=%d).", + len(rows), + len(columns), ) + def _generate_trame_snapshot_html(self: CytoDataFrame_type) -> str: # noqa: C901 + """Generate a static HTML table with PyVista 3D snapshots.""" + html_content = self._generate_jupyter_dataframe_html() + try: + if self._custom_attrs.get("data_bounding_box") is None: + return html_content + + data = self.copy() + image_cols = self.find_image_columns() or [] + if not image_cols: + return html_content + + display_indices = self.get_displayed_rows() + cache = self._custom_attrs.get("_snapshot_cache", {}) + cache_lock = self._custom_attrs.get("_snapshot_cache_lock") + for image_col in image_cols: + + def _render_cell( + row: pd.Series, + bound_image_col: Any = image_col, + ) -> str: + try: + key = self._snapshot_cache_key(row.name, bound_image_col) + if cache_lock is not None: + with cache_lock: + snapshot = cache.get(key) + else: + snapshot = cache.get(key) + if snapshot: + return snapshot + volume, dims = self._get_3d_volume_from_cell( + row=row.name, column=bound_image_col + ) + snapshot = self._pyvista_volume_snapshot_html(volume, dims) + if cache_lock is not None: + with cache_lock: + cache[key] = snapshot + else: + cache[key] = snapshot + if snapshot: + return snapshot + except Exception as exc: + logger.debug( + "Snapshot rendering failed for row=%s column=%s: %s", + row.name, + bound_image_col, + exc, + ) + return ( + "
" + "Snapshot unavailable
" + ) + + data.loc[display_indices, image_col] = data.loc[display_indices].apply( + _render_cell, axis=1 + ) + + formatter = fmt.DataFrameFormatter( + data, + columns=None, + col_space=None, + na_rep="NaN", + formatters=None, + float_format=None, + sparsify=None, + justify=None, + index_names=True, + header=True, + index=True, + bold_rows=True, + escape=False, + max_rows=get_option("display.max_rows"), + min_rows=get_option("display.min_rows"), + max_cols=get_option("display.max_columns"), + show_dimensions=get_option("display.show_dimensions"), + decimal=".", + ) + table_html = fmt.DataFrameRenderer(formatter).to_html() + style = ( + "" + ) + return style + table_html + except Exception as exc: + logger.debug("Failed to build trame snapshot HTML: %s", exc) + return html_content + def _repr_html_(self: CytoDataFrame_type, debug: bool = False) -> str: """ Returns HTML representation of the underlying pandas DataFrame @@ -2045,17 +3207,54 @@ def _repr_html_(self: CytoDataFrame_type, debug: bool = False) -> str: str: The data in a pandas DataFrame. """ + display_options = self._custom_attrs.get("display_options", {}) or {} + force_trame = display_options.get("view") == "trame" + auto_trame_for_3d = display_options.get("auto_trame_for_3d", True) + columns_3d = self._find_3d_columns_for_display() if auto_trame_for_3d else [] + if (force_trame or columns_3d) and not debug: + if force_trame and not columns_3d: + columns_3d = list( + dict.fromkeys( + [ + *(self.find_image_columns() or []), + *self.find_ome_arrow_columns(self), + ] + ) + ) + if columns_3d: + try: + widget_table = self.show_widget_table( + column=columns_3d[0], + columns_3d=columns_3d, + backend=None, + ) + display(widget_table) + html_content = self._generate_trame_snapshot_html() + details_html = ( + '
' + "Static snapshot (for non-interactive view)" + f"{html_content}
" + ) + display(HTML(details_html)) + return None + except Exception as exc: + logger.debug( + "Trame widget table render failed, falling back to HTML: %s", + exc, + ) + # if we're in a notebook process as though in a jupyter environment if get_option("display.notebook_repr_html") and not debug: - display( - widgets.VBox( - [ - self._custom_attrs["_scale_slider"], - self._custom_attrs["_output"], - ] + if not self._custom_attrs["_widget_state"]["shown"]: + display( + widgets.VBox( + [ + self._custom_attrs["_scale_slider"], + self._custom_attrs["_output"], + ] + ) ) - ) - self._custom_attrs["_widget_state"]["shown"] = True + self._custom_attrs["_widget_state"]["shown"] = True # Attach the slider observer exactly once if not self._custom_attrs["_widget_state"]["observing"]: @@ -2064,15 +3263,8 @@ def _repr_html_(self: CytoDataFrame_type, debug: bool = False) -> str: ) self._custom_attrs["_widget_state"]["observing"] = True - # Refresh the content area (no second slider display) - self._custom_attrs["_output"].clear_output(wait=True) - # render fresh HTML for this cell self._render_output() - # ensure slider continues to control the output - self._custom_attrs["_scale_slider"].observe( - self._on_slider_change, names="value" - ) # allow for debug mode to be set which returns the HTML # without widgets. diff --git a/src/cytodataframe/volume.py b/src/cytodataframe/volume.py new file mode 100644 index 0000000..474597b --- /dev/null +++ b/src/cytodataframe/volume.py @@ -0,0 +1,394 @@ +import base64 +import html +import io +import os +import pathlib +import uuid +from typing import Any, Callable, Optional, Tuple + +import imageio.v2 as imageio +import numpy as np + +FALLBACK_NDIM = 2 +MIN_VOLUME_NDIM = 3 +DEFAULT_MAX_INLINE_VOLUME_BYTES = 16 * 1024 * 1024 +VTK_JS_CDN_URL = "https://unpkg.com/@kitware/vtk.js@34.9.1/dist/vtk.js" +VTK_JS_URL_ENV_VAR = "CYTODATAFRAME_VTK_JS_URL" +VTK_JS_TRANSFER_FUNCTION_SNIPPET = ( + "const ctfun=vtk.Rendering.Core.vtkColorTransferFunction.newInstance();" + "ctfun.addRGBPoint(0,0,0,0);" + "ctfun.addRGBPoint(1,1,1,1);" + "ctfun.addRGBPoint(255,1,1,1);" + "const ofun=vtk.Common.DataModel.vtkPiecewiseFunction.newInstance();" + "ofun.addPoint(0,0.0);" + "ofun.addPoint(1,0.15);" + "ofun.addPoint(255,0.2);" +) + + +def build_3d_image_html_stub( + data_value: str, + candidate_path: pathlib.Path, + display_options: Optional[dict], + message: str = "3D image", +) -> str: + """Build a fallback HTML block for a 3D image cell. + + Args: + data_value: Original cell value associated with the image. + candidate_path: Resolved filesystem path for the image source. + display_options: Display configuration containing optional width/height. + message: Text shown in the fallback block. + + Returns: + An HTML ``div`` string representing a non-interactive 3D placeholder. + """ + display_options = display_options or {} + width = display_options.get("width", "300px") + height = display_options.get("height", "300px") + + html_style = [f"width:{width}"] + if height is not None: + html_style.append(f"height:{height}") + html_style.extend( + [ + "display:flex", + "align-items:center", + "justify-content:center", + "background:#f6f6f6", + "border:1px solid #ddd", + "color:#555", + "font-size:12px", + ] + ) + + html_style_joined = ";".join(html_style) + path_attr = html.escape(str(candidate_path), quote=True) + value_attr = html.escape(str(data_value), quote=True) + + return ( + f'
' + f"{html.escape(message)}" + "
" + ) + + +def _resolve_vtk_js_url(display_options: Optional[dict]) -> str: + display_options = display_options or {} + configured = display_options.get("vtk_js_url") + if not configured: + configured = os.getenv(VTK_JS_URL_ENV_VAR) + return str(configured) if configured else VTK_JS_CDN_URL + + +def build_3d_image_html_view( + volume: np.ndarray, + dims: Tuple[int, int, int], + data_value: str, + candidate_path: pathlib.Path, + display_options: Optional[dict], +) -> str: + display_options = display_options or {} + width = display_options.get("width", "300px") + height = display_options.get("height", "300px") + + html_style = [ + f"width:{width}", + f"height:{height}", + "background:#f6f6f6", + "border:1px solid #ddd", + ] + html_style_joined = ";".join(html_style) + + max_inline_volume_bytes = display_options.get( + "max_inline_volume_bytes", + DEFAULT_MAX_INLINE_VOLUME_BYTES, + ) + try: + max_inline_volume_bytes = max(1, int(max_inline_volume_bytes)) + except (TypeError, ValueError): + max_inline_volume_bytes = DEFAULT_MAX_INLINE_VOLUME_BYTES + + volume_uint8 = np.array(volume, dtype=np.uint8, copy=True) + if volume_uint8.nbytes > max_inline_volume_bytes: + return build_3d_image_html_stub( + data_value=data_value, + candidate_path=candidate_path, + display_options=display_options, + message=( + "3D image too large for inline rendering " + f"({volume_uint8.nbytes} bytes > {max_inline_volume_bytes} bytes)" + ), + ) + + volume_bytes = volume_uint8.tobytes() + volume_b64 = base64.b64encode(volume_bytes).decode("utf-8") + dims_attr = ",".join(str(value) for value in dims) + element_id = f"cyto-3d-{uuid.uuid4().hex}" + path_attr = html.escape(str(candidate_path), quote=True) + value_attr = html.escape(str(data_value), quote=True) + + fallback_html = "" + try: + if volume.ndim >= MIN_VOLUME_NDIM: + fallback = volume.max(axis=0) + if fallback.ndim == FALLBACK_NDIM: + fallback = np.asarray(fallback) + if fallback.size: + try: + lo, hi = np.percentile(fallback, (1.0, 99.9)) + if hi <= lo: + hi = lo + 1.0 + fallback = np.clip((fallback - lo) / (hi - lo), 0, 1) + except Exception: + fallback = fallback.astype(np.float32, copy=False) + vmin = float(np.min(fallback)) + vmax = float(np.max(fallback)) + if vmax <= vmin: + vmax = vmin + 1.0 + fallback = np.clip((fallback - vmin) / (vmax - vmin), 0, 1) + fallback_uint8 = (fallback * 255).astype(np.uint8, copy=False) + png_bytes_io = io.BytesIO() + imageio.imwrite(png_bytes_io, fallback_uint8, format="png") + png_bytes = png_bytes_io.getvalue() + png_b64 = base64.b64encode(png_bytes).decode("utf-8") + fallback_html = ( + '' + ) + except Exception: + fallback_html = "" + + vtk_js_url = _resolve_vtk_js_url(display_options) + return ( + f'
' + f"{fallback_html}
" + + build_3d_vtk_js_script(element_id, vtk_js_url=vtk_js_url) + ) + + +def build_3d_vtk_js_script(element_id: str, vtk_js_url: Optional[str] = None) -> str: + vtk_js_url = vtk_js_url or VTK_JS_CDN_URL + return ( + "" + ) + + +def _build_vtk_js_renderer_core(*, include_container_size: bool) -> str: + size_config = "" + if include_container_size: + size_config = ( + "const width=container.clientWidth||300;" + "const height=container.clientHeight||300;" + "openGL.setSize(width,height);" + ) + return ( + "const fallback=container.querySelector('.cyto-3d-fallback');" + "if(fallback){fallback.remove();}" + "const imageData=vtk.Common.DataModel.vtkImageData.newInstance();" + "imageData.setDimensions(dims);" + "imageData.getPointData().setScalars(" + "vtk.Common.Core.vtkDataArray.newInstance({" + "name:'Scalars',values:bytes,numberOfComponents:1" + "})" + ");" + "const mapper=vtk.Rendering.Core.vtkVolumeMapper.newInstance();" + "mapper.setInputData(imageData);" + "const volume=vtk.Rendering.Core.vtkVolume.newInstance();" + "volume.setMapper(mapper);" + f"{VTK_JS_TRANSFER_FUNCTION_SNIPPET}" + "volume.getProperty().setRGBTransferFunction(0,ctfun);" + "volume.getProperty().setScalarOpacity(0,ofun);" + "volume.getProperty().setShade(false);" + "volume.getProperty().setInterpolationTypeToFastLinear();" + "const renderer=vtk.Rendering.Core.vtkRenderer.newInstance({" + "background:[1,1,1]" + "});" + "const renderWindow=vtk.Rendering.Core.vtkRenderWindow.newInstance();" + "renderWindow.addRenderer(renderer);" + "const openGL=vtk.Rendering.OpenGL.vtkRenderWindow.newInstance();" + "openGL.setContainer(container);" + f"{size_config}" + "renderWindow.addView(openGL);" + "const interactor=vtk.Rendering.Core.vtkRenderWindowInteractor.newInstance();" + "interactor.setView(openGL);" + "interactor.initialize();" + "interactor.bindEvents(container);" + "const style=vtk.Interaction.Style.vtkInteractorStyleTrackballCamera" + ".newInstance();" + "interactor.setInteractorStyle(style);" + "renderer.addVolume(volume);" + "renderer.resetCamera();" + "renderWindow.render();" + ) + + +def build_3d_vtk_js_initializer(display_options: Optional[dict] = None) -> str: + vtk_js_url = _resolve_vtk_js_url(display_options) + return ( + "(function(){" + "const init=function(container){" + "if(!container||container.dataset.vtkInit){return;}" + "container.dataset.vtkInit='1';" + "const dims=container.dataset.dims.split(',').map(Number);" + "const raw=atob(container.dataset.volume);" + "const bytes=new Uint8Array(raw.length);" + "for(let i=0;i Optional[Tuple[np.ndarray, Tuple[int, int, int]]]: + if not is_ome_arrow_value(data_value): + return None + + try: + pixels_meta = data_value.get("pixels_meta", {}) + size_x = int(pixels_meta.get("size_x") or 0) + size_y = int(pixels_meta.get("size_y") or 0) + size_z = int(pixels_meta.get("size_z") or 0) + planes = data_value.get("planes") + + if size_x <= 0 or size_y <= 0 or size_z <= 1 or planes is None: + return None + + if isinstance(planes, np.ndarray): + plane_entries = planes.tolist() + else: + plane_entries = list(planes) + if not plane_entries: + return None + + base = size_x * size_y + volume = None + filled = 0 + + for plane_idx, plane in enumerate(plane_entries): + if not isinstance(plane, dict): + continue + c_val = int(plane.get("c") or 0) + t_val = int(plane.get("t") or 0) + if c_val != 0 or t_val != 0: + continue + z_val = plane.get("z") + z_idx = int(z_val) if z_val is not None else plane_idx + if z_idx < 0 or z_idx >= size_z: + continue + pixels = plane.get("pixels") + if pixels is None: + continue + np_pixels = np.asarray(pixels) + if np_pixels.size != base: + continue + if volume is None: + volume = np.zeros((size_z, size_y, size_x), dtype=np_pixels.dtype) + volume[z_idx] = np_pixels.reshape((size_y, size_x)) + filled += 1 + + if filled == 0 or volume is None: + return None + + volume = ensure_uint8(volume) + return volume, (size_x, size_y, size_z) + except Exception as exc: + logger.debug("Unable to decode 3D OME-Arrow struct: %s", exc) + return None + + +def build_3d_html_from_path( # noqa: PLR0913 + data_value: str, + candidate_path: pathlib.Path, + display_options: Optional[dict], + ensure_uint8: Callable[[np.ndarray], np.ndarray], + is_ome_arrow_value: Callable[[Any], bool], + logger: Any, +) -> Optional[str]: + try: + from ome_arrow import OMEArrow # type: ignore + except Exception: + logger.debug("ome-arrow not available for 3D rendering.") + return None + + try: + ome_struct = OMEArrow(data=str(candidate_path)).data + if hasattr(ome_struct, "as_py"): + ome_struct = ome_struct.as_py() + except Exception as exc: + logger.debug("Failed to load OME-Arrow for 3D rendering: %s", exc) + return None + + volume_data = extract_volume_from_ome_arrow( + ome_struct, ensure_uint8, is_ome_arrow_value, logger + ) + if volume_data is None: + return None + + volume, dims = volume_data + return build_3d_image_html_view( + volume=volume, + dims=dims, + data_value=data_value, + candidate_path=candidate_path, + display_options=display_options, + ) diff --git a/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/3d-nuclei-profiling.cppipe b/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/3d-nuclei-profiling.cppipe new file mode 100755 index 0000000..0661887 --- /dev/null +++ b/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/3d-nuclei-profiling.cppipe @@ -0,0 +1,212 @@ +CellProfiler Pipeline: http://www.cellprofiler.org +Version:5 +DateRevision:421 +GitHash: +ModuleCount:17 +HasImagePlaneDetails:False + +Images:[module_num:1|svn_version:'Unknown'|variable_revision_number:2|show_window:False|notes:['To begin creating your project, use the Images module to compile a list of files and/or folders that you want to analyze. You can also specify a set of rules to include only the desired files in your selected folders.']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + : + Filter images?:Images only + Select the rule criteria:and (extension does isimage) (directory doesnot containregexp "[\\\\/]\\.") + +Metadata:[module_num:2|svn_version:'Unknown'|variable_revision_number:6|show_window:False|notes:['The Metadata module optionally allows you to extract information describing your images (i.e, metadata) which will be stored along with your measurements. This information can be contained in the file name and/or location, or in an external file.']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Extract metadata?:Yes + Metadata data type:Text + Metadata types:{} + Extraction method count:1 + Metadata extraction method:Extract from image file headers + Metadata source:File name + Regular expression to extract from file name:^(?P.*)_(?P[A-P][0-9]{2})_s(?P[0-9])_w(?P[0-9]) + Regular expression to extract from folder name:(?P[0-9]{4}_[0-9]{2}_[0-9]{2})$ + Extract metadata from:All images + Select the filtering criteria:and (file does contain "") + Metadata file location:Elsewhere...| + Match file and image metadata:[] + Use case insensitive matching?:No + Metadata file name:None + Does cached metadata exist?:Yes + +NamesAndTypes:[module_num:3|svn_version:'Unknown'|variable_revision_number:8|show_window:False|notes:['The NamesAndTypes module allows you to assign a meaningful name to each image by which other modules will refer to it.']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Assign a name to:All images + Select the image type:Grayscale image + Name to assign these images:Nuclei + Match metadata:[] + Image set matching method:Order + Set intensity range from:Image metadata + Assignments count:1 + Single images count:0 + Maximum intensity:255.0 + Process as 3D?:Yes + Relative pixel spacing in X:0.129 + Relative pixel spacing in Y:0.129 + Relative pixel spacing in Z:0.2 + Select the rule criteria:and (file does contain "c00") + Name to assign these images:c00 + Name to assign these objects:Cell + Select the image type:Grayscale image + Set intensity range from:Image metadata + Maximum intensity:255.0 + +Groups:[module_num:4|svn_version:'Unknown'|variable_revision_number:2|show_window:False|notes:['The Groups module optionally allows you to split your list of images into image subsets (groups) which will be processed independently of each other. Examples of groupings include screening batches, microtiter plates, time-lapse movies, etc.']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Do you want to group your images?:No + grouping metadata count:1 + Metadata category:FileLocation + +GaussianFilter:[module_num:5|svn_version:'Unknown'|variable_revision_number:1|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:Nuclei + Name the output image:GaussianFilter + Sigma:1 + +ReduceNoise:[module_num:6|svn_version:'Unknown'|variable_revision_number:1|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:GaussianFilter + Name the output image:ReduceNoise + Size:5 + Distance:2 + Cut-off distance:0.2 + +Threshold:[module_num:7|svn_version:'Unknown'|variable_revision_number:12|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:ReduceNoise + Name the output image:Threshold + Threshold strategy:Global + Thresholding method:Otsu + Threshold smoothing scale:1.5 + Threshold correction factor:1 + Lower and upper bounds on threshold:0,1.0 + Manual threshold:0.0 + Select the measurement to threshold with:None + Two-class or three-class thresholding?:Three classes + Log transform before thresholding?:No + Assign pixels in the middle intensity class to the foreground or the background?:Background + Size of adaptive window:50 + Lower outlier fraction:0.05 + Upper outlier fraction:0.05 + Averaging method:Mean + Variance method:Standard deviation + # of deviations:2.0 + Thresholding method:Otsu + +Watershed:[module_num:8|svn_version:'Unknown'|variable_revision_number:3|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:Threshold + Name the output object:Watershed + Use advanced settings?:No + Generate from:Distance + Markers:None + Mask:Leave blank + Connectivity:1 + Compactness:0.0 + Footprint:10 + Downsample:3 + Separate watershed labels:No + Declump method:Intensity + Reference Image:GaussianFilter + Segmentation distance transform smoothing factor:0 + Minimum distance between seeds:1 + Minimum absolute internal distance:0.0 + Pixels from border to exclude:0 + Maximum number of seeds:-1 + Structuring element for seed dilation:ball,3 + +DilateObjects:[module_num:9|svn_version:'Unknown'|variable_revision_number:1|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input object:Watershed + Name the output object:RealsizeNuclei + Structuring element:octahedron,1 + +MeasureObjectIntensity:[module_num:10|svn_version:'Unknown'|variable_revision_number:4|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select images to measure:Nuclei + Select objects to measure:RealsizeNuclei + +MeasureObjectSizeShape:[module_num:11|svn_version:'Unknown'|variable_revision_number:3|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select object sets to measure:RealsizeNuclei + Calculate the Zernike features?:No + Calculate the advanced features?:Yes + +RescaleIntensity:[module_num:12|svn_version:'Unknown'|variable_revision_number:3|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:Nuclei + Name the output image:RescaleIntensityNuclei + Rescaling method:Stretch each image to use the full intensity range + Method to calculate the minimum intensity:Custom + Method to calculate the maximum intensity:Custom + Lower intensity limit for the input image:0.0 + Upper intensity limit for the input image:1.0 + Intensity range for the input image:0.0,1.0 + Intensity range for the output image:0.0,1.0 + Select image to match in maximum intensity:None + Divisor value:1.0 + Divisor measurement:None + +OverlayObjects:[module_num:13|svn_version:'Unknown'|variable_revision_number:1|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Input:RescaleIntensityNuclei + Name the output image:OverlayObjects + Objects:RealsizeNuclei + Opacity:0.3 + +SaveImages:[module_num:14|svn_version:'Unknown'|variable_revision_number:16|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:True] + Select the type of image to save:Image + Select the image to save:OverlayObjects + Select method for constructing file names:From image filename + Select image name for file prefix:Nuclei + Enter single file name:OrigBlue + Number of digits:4 + Append a suffix to the image file name?:Yes + Text to append to the image name:Overlay + Saved file format:tiff + Output file location:Default Output Folder| + Image bit depth:8-bit integer + Overwrite existing files without warning?:Yes + When to save:Every cycle + Record the file and path information to the saved image?:No + Create subfolders in the output folder?:No + Base image folder:Elsewhere...| + How to save the series:T (Time) + Save with lossless compression?:Yes + +ConvertObjectsToImage:[module_num:15|svn_version:'Unknown'|variable_revision_number:1|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input objects:RealsizeNuclei + Name the output image:NucleiObjects3D + Select the color format:Grayscale + Select the colormap:Default + +SaveImages:[module_num:16|svn_version:'Unknown'|variable_revision_number:16|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the type of image to save:Image + Select the image to save:NucleiObjects3D + Select method for constructing file names:From image filename + Select image name for file prefix:Nuclei + Enter single file name:OrigBlue + Number of digits:4 + Append a suffix to the image file name?:Yes + Text to append to the image name:3D + Saved file format:tiff + Output file location:Default Output Folder| + Image bit depth:8-bit integer + Overwrite existing files without warning?:Yes + When to save:Every cycle + Record the file and path information to the saved image?:No + Create subfolders in the output folder?:No + Base image folder:Elsewhere...| + How to save the series:T (Time) + Save with lossless compression?:Yes + +ExportToSpreadsheet:[module_num:17|svn_version:'Unknown'|variable_revision_number:13|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the column delimiter:Comma (",") + Add image metadata columns to your object data file?:No + Add image file and folder names to your object data file?:No + Select the measurements to export:Yes + Calculate the per-image mean values for object measurements?:No + Calculate the per-image median values for object measurements?:No + Calculate the per-image standard deviation values for object measurements?:No + Output file location:Default Output Folder| + Create a GenePattern GCT file?:No + Select source of sample row name:Metadata + Select the image to use as the identifier:None + Select the metadata to use as the identifier:None + Export all measurement types?:Yes + Press button to select measurements:RealsizeNuclei|AreaShape_Center_Y,RealsizeNuclei|AreaShape_Center_Z,RealsizeNuclei|AreaShape_Center_X,RealsizeNuclei|AreaShape_Volume,RealsizeNuclei|AreaShape_BoundingBoxMinimum_X,RealsizeNuclei|AreaShape_BoundingBoxMinimum_Y,RealsizeNuclei|AreaShape_BoundingBoxMinimum_Z,RealsizeNuclei|AreaShape_BoundingBoxMaximum_X,RealsizeNuclei|AreaShape_BoundingBoxMaximum_Y,RealsizeNuclei|AreaShape_BoundingBoxMaximum_Z,RealsizeNuclei|Intensity_IntegratedIntensity_Nuclei + Representation of Nan/Inf:NaN + Add a prefix to file names?:Yes + Filename prefix:MyExpt_ + Overwrite existing files without warning?:Yes + Data to export:Do not use + Combine these object measurements with those of the previous object?:No + File name:DATA.csv + Use the object name for the file name?:Yes diff --git a/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/Dockerfile b/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/Dockerfile new file mode 100644 index 0000000..758324c --- /dev/null +++ b/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/Dockerfile @@ -0,0 +1,56 @@ +# modified from: https://github.com/CellProfiler/CellProfiler/blob/main/distribution/docker/Dockerfile +# must be amd64, wxPython wheel for arm64 is not available +FROM ubuntu:20.04 + +# bypass apt installation frontend interaction +ARG DEBIAN_FRONTEND=noninteractive + +ARG cp_version=4.2.8 + +# hadolint ignore=DL3008 +RUN apt-get update && \ + apt-get -y upgrade && \ + apt-get install --no-install-recommends -y \ + make \ + gcc \ + build-essential \ + libgtk-3-dev \ + wget \ + git \ + sqlite3 \ + python3.9-dev \ + python3.9-venv \ + python3-pip \ + openjdk-11-jdk-headless \ + default-libmysqlclient-dev \ + libnotify-dev \ + libsdl2-dev \ + libwebkit2gtk-4.0-dev && \ + apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# set environment variables +ENV JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64 +ENV VIRTUAL_ENV=/opt/venv +ENV PATH="$VIRTUAL_ENV/bin:$PATH" + +# hadolint ignore=DL3013 +RUN python3.9 -m venv $VIRTUAL_ENV && \ + pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir \ + wheel \ + cython \ + numpy \ + https://extras.wxpython.org/wxPython4/extras/linux/gtk3/ubuntu-20.04/wxPython-4.2.1-cp39-cp39-linux_x86_64.whl \ + cellprofiler==$cp_version + +RUN groupadd --gid 10001 cpuser && \ + useradd --uid 10001 --gid cpuser --create-home --shell /bin/bash cpuser && \ + mkdir -p /usr/local/src && \ + chown -R cpuser:cpuser /usr/local/src + +WORKDIR /usr/local/src +USER cpuser +ENTRYPOINT ["cellprofiler"] + +CMD ["--run", "--run-headless", "--help"] diff --git a/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/input/nuclei1_out_c00_dr90_image.tif b/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/input/nuclei1_out_c00_dr90_image.tif new file mode 100755 index 0000000..990e892 Binary files /dev/null and b/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/input/nuclei1_out_c00_dr90_image.tif differ diff --git a/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/input/nuclei2_out_c90_dr90_image.tif b/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/input/nuclei2_out_c90_dr90_image.tif new file mode 100755 index 0000000..147dea5 Binary files /dev/null and b/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/input/nuclei2_out_c90_dr90_image.tif differ diff --git a/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/input/nuclei3_out_c00_dr10_image.tif b/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/input/nuclei3_out_c00_dr10_image.tif new file mode 100755 index 0000000..a81e364 Binary files /dev/null and b/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/input/nuclei3_out_c00_dr10_image.tif differ diff --git a/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/input/nuclei4_out_c90_dr10_image.tif b/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/input/nuclei4_out_c90_dr10_image.tif new file mode 100755 index 0000000..a65c71f Binary files /dev/null and b/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/input/nuclei4_out_c90_dr10_image.tif differ diff --git a/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/output/MyExpt_Experiment.csv b/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/output/MyExpt_Experiment.csv new file mode 100644 index 0000000..ef3a4be --- /dev/null +++ b/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/output/MyExpt_Experiment.csv @@ -0,0 +1,218 @@ +Key,Value +CellProfiler_Version,4.2.8 +ChannelType_Nuclei,Grayscale +ImageSet_Zip_Dictionary,b'PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48T01FIHhtbG5zPSJodHRwOi8vd3d3Lm9wZW5taWNyb3Njb3B5Lm9yZy9TY2hlbWFzL09NRS8yMDE2LTA2Ij48SW1hZ2UgSUQ9Ik51Y2xlaSI+PFBpeGVscyBEaW1lbnNpb25PcmRlcj0iWFlDWlQiIElEPSJQaXhlbHM6TnVjbGVpIiBTaXplVD0iMSIgU2l6ZVg9IjEiIFNpemVZPSIxIj48VGlmZkRhdGEgRmlyc3RDPSIwIiBGaXJzdFQ9IjAiIEZpcnN0Wj0iMCIgSUZEPSIwIiBQbGFuZUNvdW50PSIxIj48VVVJRCBGaWxlTmFtZT0iZmlsZTovYXBwL2lucHV0ZGF0YS9udWNsZWlfb3V0X2MwXz48ZmUgVGhlQz0iMCIgVGhlVD0iMCJaPSIwIi8+PC9QeD48L21hT3V0XzkwX19vdXRfYzAwX2RyeG1scnNpb24uMCJlbmNvaW5GP09NRW5zOi93dy5vcGdTaG0vT01FMi02Ij48SW1hZ2UgSUQ9InVjbGVpPjxQaXhlbHMgZXJUIiBJTnVjbGVpIiBTaXplPSIxIiBTaXplWD0iMSJZPSIxIj48VGlmZkRhdGEgRmlyc3RDPSIwIiBGaXJzdFQ9IjAiIFo9IjAiRD0ib3V0PSIxVUlEIEZpTm1laWxlOnBkdV9vdXRfYzkwX2Ry' +Pipeline_Pipeline,"CellProfiler Pipeline: http://www.cellprofiler.org +Version:5 +DateRevision:428 +GitHash: +ModuleCount:17 +HasImagePlaneDetails:False + +Images:[module_num:1|svn_version:'Unknown'|variable_revision_number:2|show_window:False|notes:['To begin creating your project, use the Images module to compile a list of files and/or folders that you want to analyze. You can also specify a set of rules to include only the desired files in your selected folders.']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + : + Filter images?:Images only + Select the rule criteria:and (extension does isimage) (directory doesnot containregexp ""[\\\\/]\\."") + +Metadata:[module_num:2|svn_version:'Unknown'|variable_revision_number:6|show_window:False|notes:['The Metadata module optionally allows you to extract information describing your images (i.e, metadata) which will be stored along with your measurements. This information can be contained in the file name and/or location, or in an external file.']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Extract metadata?:Yes + Metadata data type:Text + Metadata types:{} + Extraction method count:1 + Metadata extraction method:Extract from file/folder names + Metadata source:File name + Regular expression to extract from file name:^(?P.*)_(?P[A-P][0-9]{2})_s(?P[0-9])_w(?P[0-9]) + Regular expression to extract from folder name:(?P[0-9]{4}_[0-9]{2}_[0-9]{2})$ + Extract metadata from:All images + Select the filtering criteria:and (file does contain """") + Metadata file location:Elsewhere...| + Match file and image metadata:[] + Use case insensitive matching?:No + Metadata file name:None + Does cached metadata exist?:Yes + +NamesAndTypes:[module_num:3|svn_version:'Unknown'|variable_revision_number:8|show_window:False|notes:['The NamesAndTypes module allows you to assign a meaningful name to each image by which other modules will refer to it.']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Assign a name to:All images + Select the image type:Grayscale image + Name to assign these images:Nuclei + Match metadata:[] + Image set matching method:Order + Set intensity range from:Image metadata + Assignments count:1 + Single images count:0 + Maximum intensity:255.0 + Process as 3D?:Yes + Relative pixel spacing in X:0.129 + Relative pixel spacing in Y:0.129 + Relative pixel spacing in Z:0.2 + Select the rule criteria:and (file does contain ""c00"") + Name to assign these images:c00 + Name to assign these objects:Cell + Select the image type:Grayscale image + Set intensity range from:Image metadata + Maximum intensity:255.0 + +Groups:[module_num:4|svn_version:'Unknown'|variable_revision_number:2|show_window:False|notes:['The Groups module optionally allows you to split your list of images into image subsets (groups) which will be processed independently of each other. Examples of groupings include screening batches, microtiter plates, time-lapse movies, etc.']|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Do you want to group your images?:No + grouping metadata count:1 + Metadata category:FileLocation + +GaussianFilter:[module_num:5|svn_version:'Unknown'|variable_revision_number:1|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:Nuclei + Name the output image:GaussianFilter + Sigma:1 + +ReduceNoise:[module_num:6|svn_version:'Unknown'|variable_revision_number:1|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:GaussianFilter + Name the output image:ReduceNoise + Size:5 + Distance:2 + Cut-off distance:0.2 + +Threshold:[module_num:7|svn_version:'Unknown'|variable_revision_number:12|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:ReduceNoise + Name the output image:Threshold + Threshold strategy:Global + Thresholding method:Otsu + Threshold smoothing scale:1.5 + Threshold correction factor:1 + Lower and upper bounds on threshold:0,1.0 + Manual threshold:0.0 + Select the measurement to threshold with:None + Two-class or three-class thresholding?:Three classes + Log transform before thresholding?:No + Assign pixels in the middle intensity class to the foreground or the background?:Background + Size of adaptive window:50 + Lower outlier fraction:0.05 + Upper outlier fraction:0.05 + Averaging method:Mean + Variance method:Standard deviation + # of deviations:2.0 + Thresholding method:Otsu + +Watershed:[module_num:8|svn_version:'Unknown'|variable_revision_number:3|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:Threshold + Name the output object:Watershed + Use advanced settings?:No + Generate from:Distance + Markers:None + Mask:Leave blank + Connectivity:1 + Compactness:0.0 + Footprint:10 + Downsample:3 + Separate watershed labels:No + Declump method:Intensity + Reference Image:GaussianFilter + Segmentation distance transform smoothing factor:0 + Minimum distance between seeds:1 + Minimum absolute internal distance:0.0 + Pixels from border to exclude:0 + Maximum number of seeds:-1 + Structuring element for seed dilation:ball,3 + +DilateObjects:[module_num:9|svn_version:'Unknown'|variable_revision_number:1|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input object:Watershed + Name the output object:RealsizeNuclei + Structuring element:octahedron,1 + +MeasureObjectIntensity:[module_num:10|svn_version:'Unknown'|variable_revision_number:4|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select images to measure:Nuclei + Select objects to measure:RealsizeNuclei + +MeasureObjectSizeShape:[module_num:11|svn_version:'Unknown'|variable_revision_number:3|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select object sets to measure:RealsizeNuclei + Calculate the Zernike features?:No + Calculate the advanced features?:Yes + +RescaleIntensity:[module_num:12|svn_version:'Unknown'|variable_revision_number:3|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input image:Nuclei + Name the output image:RescaleIntensityNuclei + Rescaling method:Stretch each image to use the full intensity range + Method to calculate the minimum intensity:Custom + Method to calculate the maximum intensity:Custom + Lower intensity limit for the input image:0.0 + Upper intensity limit for the input image:1.0 + Intensity range for the input image:0.0,1.0 + Intensity range for the output image:0.0,1.0 + Select image to match in maximum intensity:None + Divisor value:1.0 + Divisor measurement:None + +OverlayObjects:[module_num:13|svn_version:'Unknown'|variable_revision_number:1|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Input:RescaleIntensityNuclei + Name the output image:OverlayObjects + Objects:RealsizeNuclei + Opacity:0.3 + +SaveImages:[module_num:14|svn_version:'Unknown'|variable_revision_number:16|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:True] + Select the type of image to save:Image + Select the image to save:OverlayObjects + Select method for constructing file names:From image filename + Select image name for file prefix:Nuclei + Enter single file name:OrigBlue + Number of digits:4 + Append a suffix to the image file name?:Yes + Text to append to the image name:Overlay + Saved file format:tiff + Output file location:Default Output Folder| + Image bit depth:8-bit integer + Overwrite existing files without warning?:Yes + When to save:Every cycle + Record the file and path information to the saved image?:No + Create subfolders in the output folder?:No + Base image folder:Elsewhere...| + How to save the series:T (Time) + Save with lossless compression?:Yes + +ConvertObjectsToImage:[module_num:15|svn_version:'Unknown'|variable_revision_number:1|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the input objects:RealsizeNuclei + Name the output image:NucleiObjects3D + Select the color format:Grayscale + Select the colormap:Default + +SaveImages:[module_num:16|svn_version:'Unknown'|variable_revision_number:16|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the type of image to save:Image + Select the image to save:NucleiObjects3D + Select method for constructing file names:From image filename + Select image name for file prefix:Nuclei + Enter single file name:OrigBlue + Number of digits:4 + Append a suffix to the image file name?:Yes + Text to append to the image name:3D + Saved file format:tiff + Output file location:Default Output Folder| + Image bit depth:8-bit integer + Overwrite existing files without warning?:Yes + When to save:Every cycle + Record the file and path information to the saved image?:No + Create subfolders in the output folder?:No + Base image folder:Elsewhere...| + How to save the series:T (Time) + Save with lossless compression?:Yes + +ExportToSpreadsheet:[module_num:17|svn_version:'Unknown'|variable_revision_number:13|show_window:True|notes:[]|batch_state:array([], dtype=uint8)|enabled:True|wants_pause:False] + Select the column delimiter:Comma ("","") + Add image metadata columns to your object data file?:Yes + Add image file and folder names to your object data file?:Yes + Select the measurements to export:Yes + Calculate the per-image mean values for object measurements?:No + Calculate the per-image median values for object measurements?:No + Calculate the per-image standard deviation values for object measurements?:No + Output file location:Default Output Folder| + Create a GenePattern GCT file?:No + Select source of sample row name:Metadata + Select the image to use as the identifier:None + Select the metadata to use as the identifier:None + Export all measurement types?:Yes + Press button to select measurements:RealsizeNuclei|AreaShape_Center_Y,RealsizeNuclei|AreaShape_Center_Z,RealsizeNuclei|AreaShape_Center_X,RealsizeNuclei|AreaShape_Volume,RealsizeNuclei|AreaShape_BoundingBoxMinimum_X,RealsizeNuclei|AreaShape_BoundingBoxMinimum_Y,RealsizeNuclei|AreaShape_BoundingBoxMinimum_Z,RealsizeNuclei|AreaShape_BoundingBoxMaximum_X,RealsizeNuclei|AreaShape_BoundingBoxMaximum_Y,RealsizeNuclei|AreaShape_BoundingBoxMaximum_Z,RealsizeNuclei|Intensity_IntegratedIntensity_Nuclei + Representation of Nan/Inf:NaN + Add a prefix to file names?:Yes + Filename prefix:MyExpt_ + Overwrite existing files without warning?:Yes + Data to export:Do not use + Combine these object measurements with those of the previous object?:No + File name:DATA.csv + Use the object name for the file name?:Yes +" +Run_Timestamp,2026-02-04T19:02:17.133302 diff --git a/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/output/MyExpt_Image.csv b/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/output/MyExpt_Image.csv new file mode 100644 index 0000000..963066e --- /dev/null +++ b/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/output/MyExpt_Image.csv @@ -0,0 +1,5 @@ +ImageNumber +1 +2 +3 +4 diff --git a/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/output/MyExpt_RealsizeNuclei.csv b/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/output/MyExpt_RealsizeNuclei.csv new file mode 100644 index 0000000..1264d79 --- /dev/null +++ b/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/output/MyExpt_RealsizeNuclei.csv @@ -0,0 +1,21 @@ +ImageNumber,ObjectNumber,Metadata_ChannelNumber,Metadata_FileLocation,Metadata_Frame,Metadata_Plate,Metadata_Series,Metadata_Site,Metadata_Well,FileName_Nuclei,PathName_Nuclei,AreaShape_BoundingBoxMaximum_X,AreaShape_BoundingBoxMaximum_Y,AreaShape_BoundingBoxMaximum_Z,AreaShape_BoundingBoxMinimum_X,AreaShape_BoundingBoxMinimum_Y,AreaShape_BoundingBoxMinimum_Z,AreaShape_Center_X,AreaShape_Center_Y,AreaShape_Center_Z,AreaShape_Volume,Intensity_IntegratedIntensity_Nuclei +1,1,,file:/app/inputdata/nuclei1_out_c00_dr90_image.tif,0,,0,,,nuclei1_out_c00_dr90_image.tif,/app/inputdata,223,115,41,143,32,0,183.74574231544722,73.17164625156401,20.666231002797282,125479,798.4443875132129 +1,2,,file:/app/inputdata/nuclei1_out_c00_dr90_image.tif,0,,0,,,nuclei1_out_c00_dr90_image.tif,/app/inputdata,85,121,47,8,29,6,45.61296053466435,73.88746245928684,27.052105804886992,141846,914.466270907782 +1,3,,file:/app/inputdata/nuclei1_out_c00_dr90_image.tif,0,,0,,,nuclei1_out_c00_dr90_image.tif,/app/inputdata,153,238,68,71,140,20,111.28681921662483,190.63754596929542,43.85813437627692,171310,1204.1693947559688 +1,4,,file:/app/inputdata/nuclei1_out_c00_dr90_image.tif,0,,0,,,nuclei1_out_c00_dr90_image.tif,/app/inputdata,223,253,70,140,164,28,179.35643282619418,208.8848187072378,47.983331436304084,158142,1100.2907634475268 +1,5,,file:/app/inputdata/nuclei1_out_c00_dr90_image.tif,0,,0,,,nuclei1_out_c00_dr90_image.tif,/app/inputdata,244,109,85,161,26,45,201.5419567636306,66.4144015259895,65.46127801621364,125820,808.3078651551623 +2,1,,file:/app/inputdata/nuclei2_out_c90_dr90_image.tif,0,,0,,,nuclei2_out_c90_dr90_image.tif,/app/inputdata,180,175,46,95,86,4,137.25921727050633,132.58425279831383,23.674365688920116,125254,893.5798417944461 +2,2,,file:/app/inputdata/nuclei2_out_c90_dr90_image.tif,0,,0,,,nuclei2_out_c90_dr90_image.tif,/app/inputdata,118,157,57,23,63,12,67.54692705524003,107.57294475997081,33.20552400291906,189102,1514.705324228853 +2,3,,file:/app/inputdata/nuclei2_out_c90_dr90_image.tif,0,,0,,,nuclei2_out_c90_dr90_image.tif,/app/inputdata,109,67,55,14,5,17,61.72835638694158,36.72394912348416,34.183040562980786,111407,740.2653518663719 +2,4,,file:/app/inputdata/nuclei2_out_c90_dr90_image.tif,0,,0,,,nuclei2_out_c90_dr90_image.tif,/app/inputdata,226,205,70,140,119,20,184.5726133198278,164.28743985819196,43.83149531526969,157960,1202.407845951151 +2,5,,file:/app/inputdata/nuclei2_out_c90_dr90_image.tif,0,,0,,,nuclei2_out_c90_dr90_image.tif,/app/inputdata,175,154,75,77,65,36,125.0357961403366,105.76973568222195,55.253759984810884,147474,1081.8427727413364 +3,1,,file:/app/inputdata/nuclei3_out_c00_dr10_image.tif,0,,0,,,nuclei3_out_c00_dr10_image.tif,/app/inputdata,139,106,46,62,20,0,99.02323466617464,61.98034344605814,22.91241202983737,151756,168.13528644898906 +3,2,,file:/app/inputdata/nuclei3_out_c00_dr10_image.tif,0,,0,,,nuclei3_out_c00_dr10_image.tif,/app/inputdata,238,165,55,146,87,7,193.3114723050804,125.52018695508073,31.402792151133298,147843,162.69858850259334 +3,3,,file:/app/inputdata/nuclei3_out_c00_dr10_image.tif,0,,0,,,nuclei3_out_c00_dr10_image.tif,/app/inputdata,85,160,62,11,77,20,48.82950523964954,119.41868235698334,40.36871671534101,116420,123.59000531188212 +3,4,,file:/app/inputdata/nuclei3_out_c00_dr10_image.tif,0,,0,,,nuclei3_out_c00_dr10_image.tif,/app/inputdata,190,232,83,110,143,33,149.07346272342292,187.7962394562847,57.7870313744043,173326,197.09298843354918 +3,5,,file:/app/inputdata/nuclei3_out_c00_dr10_image.tif,0,,0,,,nuclei3_out_c00_dr10_image.tif,/app/inputdata,238,118,79,155,32,37,196.12903868530532,74.04231155458501,57.189481486204315,125474,139.06314180023037 +4,1,,file:/app/inputdata/nuclei4_out_c90_dr10_image.tif,0,,0,,,nuclei4_out_c90_dr10_image.tif,/app/inputdata,196,156,69,102,68,3,153.13067258579775,109.15593517570376,26.954600918528147,163087,183.41744101932272 +4,2,,file:/app/inputdata/nuclei4_out_c90_dr10_image.tif,0,,0,,,nuclei4_out_c90_dr10_image.tif,/app/inputdata,94,126,63,8,35,13,48.24259597693723,77.36944736728753,37.913933039203016,162513,186.4197298719082 +4,3,,file:/app/inputdata/nuclei4_out_c90_dr10_image.tif,0,,0,,,nuclei4_out_c90_dr10_image.tif,/app/inputdata,148,196,68,38,98,17,97.59464625237666,147.0324126888822,42.73247773441409,199860,239.40976571827196 +4,4,,file:/app/inputdata/nuclei4_out_c90_dr10_image.tif,0,,0,,,nuclei4_out_c90_dr10_image.tif,/app/inputdata,94,172,87,17,74,46,53.283356282420996,123.46558069851827,66.89478925809496,115473,124.29469746095128 +4,5,,file:/app/inputdata/nuclei4_out_c90_dr10_image.tif,0,,0,,,nuclei4_out_c90_dr10_image.tif,/app/inputdata,214,151,89,107,77,47,164.40447277814332,113.80257047404294,67.01713862290558,156547,178.2903181090951 diff --git a/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/output/MyExpt_Watershed.csv b/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/output/MyExpt_Watershed.csv new file mode 100644 index 0000000..de7e567 --- /dev/null +++ b/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/output/MyExpt_Watershed.csv @@ -0,0 +1,21 @@ +ImageNumber,ObjectNumber,Metadata_ChannelNumber,Metadata_FileLocation,Metadata_Frame,Metadata_Plate,Metadata_Series,Metadata_Site,Metadata_Well,FileName_Nuclei,PathName_Nuclei +1,1,,file:/app/inputdata/nuclei1_out_c00_dr90_image.tif,0,,0,,,nuclei1_out_c00_dr90_image.tif,/app/inputdata +1,2,,file:/app/inputdata/nuclei1_out_c00_dr90_image.tif,0,,0,,,nuclei1_out_c00_dr90_image.tif,/app/inputdata +1,3,,file:/app/inputdata/nuclei1_out_c00_dr90_image.tif,0,,0,,,nuclei1_out_c00_dr90_image.tif,/app/inputdata +1,4,,file:/app/inputdata/nuclei1_out_c00_dr90_image.tif,0,,0,,,nuclei1_out_c00_dr90_image.tif,/app/inputdata +1,5,,file:/app/inputdata/nuclei1_out_c00_dr90_image.tif,0,,0,,,nuclei1_out_c00_dr90_image.tif,/app/inputdata +2,1,,file:/app/inputdata/nuclei2_out_c90_dr90_image.tif,0,,0,,,nuclei2_out_c90_dr90_image.tif,/app/inputdata +2,2,,file:/app/inputdata/nuclei2_out_c90_dr90_image.tif,0,,0,,,nuclei2_out_c90_dr90_image.tif,/app/inputdata +2,3,,file:/app/inputdata/nuclei2_out_c90_dr90_image.tif,0,,0,,,nuclei2_out_c90_dr90_image.tif,/app/inputdata +2,4,,file:/app/inputdata/nuclei2_out_c90_dr90_image.tif,0,,0,,,nuclei2_out_c90_dr90_image.tif,/app/inputdata +2,5,,file:/app/inputdata/nuclei2_out_c90_dr90_image.tif,0,,0,,,nuclei2_out_c90_dr90_image.tif,/app/inputdata +3,1,,file:/app/inputdata/nuclei3_out_c00_dr10_image.tif,0,,0,,,nuclei3_out_c00_dr10_image.tif,/app/inputdata +3,2,,file:/app/inputdata/nuclei3_out_c00_dr10_image.tif,0,,0,,,nuclei3_out_c00_dr10_image.tif,/app/inputdata +3,3,,file:/app/inputdata/nuclei3_out_c00_dr10_image.tif,0,,0,,,nuclei3_out_c00_dr10_image.tif,/app/inputdata +3,4,,file:/app/inputdata/nuclei3_out_c00_dr10_image.tif,0,,0,,,nuclei3_out_c00_dr10_image.tif,/app/inputdata +3,5,,file:/app/inputdata/nuclei3_out_c00_dr10_image.tif,0,,0,,,nuclei3_out_c00_dr10_image.tif,/app/inputdata +4,1,,file:/app/inputdata/nuclei4_out_c90_dr10_image.tif,0,,0,,,nuclei4_out_c90_dr10_image.tif,/app/inputdata +4,2,,file:/app/inputdata/nuclei4_out_c90_dr10_image.tif,0,,0,,,nuclei4_out_c90_dr10_image.tif,/app/inputdata +4,3,,file:/app/inputdata/nuclei4_out_c90_dr10_image.tif,0,,0,,,nuclei4_out_c90_dr10_image.tif,/app/inputdata +4,4,,file:/app/inputdata/nuclei4_out_c90_dr10_image.tif,0,,0,,,nuclei4_out_c90_dr10_image.tif,/app/inputdata +4,5,,file:/app/inputdata/nuclei4_out_c90_dr10_image.tif,0,,0,,,nuclei4_out_c90_dr10_image.tif,/app/inputdata diff --git a/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/run.sh b/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/run.sh new file mode 100644 index 0000000..0818392 --- /dev/null +++ b/tests/data/CP_tutorial_3D_noise_nuclei_segmentation/run.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -euo pipefail + +# build and run cellprofiler from a docker container +CPDOCKER_RUNDIR=$PWD/tests/data/CP_tutorial_3D_noise_nuclei_segmentation +CPDOCKER_IMAGE_NAME=cp-3d-nuclei-profiling + +# build image +docker build --platform linux/amd64 -t "$CPDOCKER_IMAGE_NAME" -f "$CPDOCKER_RUNDIR/Dockerfile" . + +# show the CellProfiler version and use run as a quick test +echo "CellProfiler version:" +docker run --rm --platform linux/amd64 -w /app \ + -v "$CPDOCKER_RUNDIR:/app" \ + "$CPDOCKER_IMAGE_NAME" \ + --version + +# run cellprofiler with the examplehuman dataset from: +# https://cellprofiler.org/examples +docker run --rm --platform linux/amd64 -w /app \ + -v "$CPDOCKER_RUNDIR:/app" \ + "$CPDOCKER_IMAGE_NAME" \ + -c -r -p 3d-nuclei-profiling.cppipe -o output -i input diff --git a/tests/test_frame.py b/tests/test_frame.py index 7eec24d..2b9a2d8 100644 --- a/tests/test_frame.py +++ b/tests/test_frame.py @@ -2,17 +2,21 @@ Tests cosmicqc CytoDataFrame module """ +import logging import pathlib import sys import types +from collections import OrderedDict +from contextlib import nullcontext +from importlib.machinery import ModuleSpec import imageio.v2 as imageio -import nbformat +import ipywidgets as widgets import numpy as np import pandas as pd import pytest +import tifffile from _pytest.monkeypatch import MonkeyPatch -from nbconvert.preprocessors import CellExecutionError, ExecutePreprocessor from pyarrow import parquet from cytodataframe.frame import CytoDataFrame @@ -247,6 +251,39 @@ def test_prepare_layers_mask_binary(tmp_path: pathlib.Path) -> None: assert set(np.unique(mask_layer).tolist()).issubset({0, 255}) +def test_prepare_layers_3d_uses_loaded_volume_without_ome_arrow_fallback( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + volume = np.arange(4 * 5 * 6, dtype=np.uint8).reshape(4, 5, 6) + image_path = tmp_path / "vol3d.tiff" + tifffile.imwrite(image_path, volume) + + cdf = CytoDataFrame( + data=pd.DataFrame({"Image_FileName_DNA": [image_path.name]}), + data_context_dir=str(tmp_path), + ) + + def fail_ome_arrow_path(**_kwargs: object) -> str: + raise AssertionError("OME-Arrow fallback should not be used for 3D TIFF") + + monkeypatch.setattr( + "cytodataframe.frame.build_3d_html_from_path", + fail_ome_arrow_path, + ) + layers = cdf._prepare_cropped_image_layers( + data_value=image_path.name, + bounding_box=(0, 0, 6, 5), + include_composite=False, + include_original=False, + include_mask_outline=False, + ) + + html_value = layers.get(CytoDataFrame._HTML_3D_STUB_KEY) + assert isinstance(html_value, str) + assert "data-volume=" in html_value + + def test_cytodataframe_input( tmp_path: pathlib.Path, basic_outlier_dataframe: pd.DataFrame, @@ -654,21 +691,915 @@ def mock_render_output() -> None: assert render_called.get("called", False) -def test_example_notebook_execution(): - """ - Executes the example notebook to ensure it runs. - """ +def test_get_3d_volume_from_cell_loads_3d_tiff(tmp_path: pathlib.Path) -> None: + volume = np.arange(4 * 5 * 6, dtype=np.uint8).reshape(4, 5, 6) + image_path = tmp_path / "volume.tiff" + tifffile.imwrite(image_path, volume) - with open( - (notebook_path := "docs/src/examples/cytodataframe_at_a_glance.ipynb") - ) as f: - nb = nbformat.read(f, as_version=4) + cdf = CytoDataFrame( + data=pd.DataFrame({"Image_FileName_DNA": [image_path.name]}), + data_context_dir=str(tmp_path), + ) - ep = ExecutePreprocessor(timeout=300, kernel_name="python3") + loaded_volume, dims = cdf._get_3d_volume_from_cell( + row=0, column="Image_FileName_DNA" + ) - try: - ep.preprocess( - nb, {"metadata": {"path": str(pathlib.Path(notebook_path).parent)}} + assert loaded_volume.shape == (4, 5, 6) + assert dims == (6, 5, 4) + + +def test_get_3d_volume_from_cell_normalizes_file_uri_with_context_dir( + tmp_path: pathlib.Path, +) -> None: + volume = np.arange(3 * 4 * 5, dtype=np.uint8).reshape(3, 4, 5) + image_path = tmp_path / "volume_uri.tiff" + tifffile.imwrite(image_path, volume) + + cdf = CytoDataFrame( + data=pd.DataFrame({"Image_FileName_DNA": [f"file:{image_path}"]}), + data_context_dir=str(tmp_path), + ) + + loaded_volume, dims = cdf._get_3d_volume_from_cell( + row=0, column="Image_FileName_DNA" + ) + + assert loaded_volume.shape == (3, 4, 5) + assert dims == (5, 4, 3) + + +def test_find_image_columns_accepts_pathlike_values(tmp_path: pathlib.Path) -> None: + cdf = CytoDataFrame( + pd.DataFrame( + { + "PathLikeCol": [tmp_path / "img.tiff"], + "NotImage": [tmp_path / "table.csv"], + } + ) + ) + assert "PathLikeCol" in cdf.find_image_columns() + assert "NotImage" not in cdf.find_image_columns() + + +def test_get_3d_volume_from_cell_uses_image_pathname_column( + tmp_path: pathlib.Path, +) -> None: + volume = np.arange(3 * 4 * 5, dtype=np.uint8).reshape(3, 4, 5) + image_path = tmp_path / "via_path_col.tiff" + tifffile.imwrite(image_path, volume) + + cdf = CytoDataFrame( + data=pd.DataFrame( + { + "Image_FileName_DNA": [image_path.name], + "Image_PathName_DNA": [str(tmp_path)], + } + ), + ) + + loaded_volume, dims = cdf._get_3d_volume_from_cell( + row=0, column="Image_FileName_DNA" + ) + assert loaded_volume.shape == (3, 4, 5) + assert dims == (5, 4, 3) + + +def test_get_3d_volume_from_cell_uses_data_image_paths_helper( + tmp_path: pathlib.Path, +) -> None: + volume = np.arange(2 * 4 * 6, dtype=np.uint8).reshape(2, 4, 6) + image_path = tmp_path / "helper_path_col.tiff" + tifffile.imwrite(image_path, volume) + + cdf = CytoDataFrame( + data=pd.DataFrame({"Image_FileName_DNA": [image_path.name]}), + data_image_paths=pd.DataFrame({"Image_PathName_DNA": [str(tmp_path)]}), + ) + + loaded_volume, dims = cdf._get_3d_volume_from_cell( + row=0, column="Image_FileName_DNA" + ) + assert loaded_volume.shape == (2, 4, 6) + assert dims == (6, 4, 2) + + +def test_get_3d_volume_from_cell_rglob_in_context_dir(tmp_path: pathlib.Path) -> None: + nested_dir = tmp_path / "nested" / "images" + nested_dir.mkdir(parents=True) + volume = np.arange(2 * 3 * 4, dtype=np.uint8).reshape(2, 3, 4) + image_path = nested_dir / "rglob_volume.tiff" + tifffile.imwrite(image_path, volume) + + cdf = CytoDataFrame( + data=pd.DataFrame({"Image_FileName_DNA": [image_path.name]}), + data_context_dir=str(tmp_path), + ) + + loaded_volume, dims = cdf._get_3d_volume_from_cell( + row=0, column="Image_FileName_DNA" + ) + assert loaded_volume.shape == (2, 3, 4) + assert dims == (4, 3, 2) + + +def test_get_3d_volume_from_cell_uses_bounded_lru_cache() -> None: + cdf = CytoDataFrame( + pd.DataFrame( + { + "A": [ + np.zeros((2, 2, 2), dtype=np.uint8), + np.ones((2, 2, 2), dtype=np.uint8), + np.full((2, 2, 2), 2, dtype=np.uint8), + ] + } + ), + display_options={"volume_cache_max_entries": 2}, + ) + + cdf._get_3d_volume_from_cell(row=0, column="A") + cdf._get_3d_volume_from_cell(row=1, column="A") + cache = cdf._custom_attrs["_volume_cache"] + assert isinstance(cache, OrderedDict) + assert list(cache.keys()) == ["0::A", "1::A"] + + # Access row 0 again, making it the most-recent entry. + cdf._get_3d_volume_from_cell(row=0, column="A") + assert list(cache.keys()) == ["1::A", "0::A"] + + # Inserting a third entry evicts the least-recently used one (row 1). + cdf._get_3d_volume_from_cell(row=2, column="A") + assert list(cache.keys()) == ["0::A", "2::A"] + assert len(cache) == 2 + + +def test_get_3d_volume_from_cell_skips_cache_when_disabled() -> None: + cdf = CytoDataFrame( + pd.DataFrame({"A": [np.ones((2, 2, 2), dtype=np.uint8)]}), + display_options={"volume_disable_cache": True}, + ) + sentinel_cache = {"0::A": (np.zeros((1, 1, 1), dtype=np.uint8), (1, 1, 1))} + cdf._custom_attrs["_volume_cache"] = sentinel_cache + + volume, dims = cdf._get_3d_volume_from_cell(row=0, column="A") + + assert volume.shape == (2, 2, 2) + assert dims == (2, 2, 2) + assert cdf._custom_attrs["_volume_cache"] is sentinel_cache + assert cdf._custom_attrs["_volume_cache"]["0::A"][0].shape == (1, 1, 1) + + +def test_repr_html_auto_trame_for_3d_inputs( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + volume = np.arange(3 * 4 * 5, dtype=np.uint8).reshape(3, 4, 5) + image_path = tmp_path / "auto_trame_volume.tiff" + tifffile.imwrite(image_path, volume) + + cdf = CytoDataFrame( + data=pd.DataFrame({"Image_FileName_DNA": [image_path.name]}), + data_context_dir=str(tmp_path), + ) + + captured: dict = {} + + def fake_show_widget_table(column: str, **kwargs: object) -> str: + captured["column"] = column + captured["columns_3d"] = kwargs.get("columns_3d") + captured["backend"] = kwargs.get("backend") + return "widget_table" + + displayed: list = [] + + def fake_snapshot_html() -> str: + return "" + + def capture_display(value: object) -> None: + displayed.append(value) + + monkeypatch.setattr(cdf, "show_widget_table", fake_show_widget_table) + monkeypatch.setattr(cdf, "_generate_trame_snapshot_html", fake_snapshot_html) + monkeypatch.setattr("cytodataframe.frame.display", capture_display) + + assert cdf._repr_html_() is None + assert captured["column"] == "Image_FileName_DNA" + assert captured["columns_3d"] == ["Image_FileName_DNA"] + assert captured["backend"] is None + assert displayed + + +def test_find_3d_columns_for_display_skips_ellipsis( + monkeypatch: pytest.MonkeyPatch, +) -> None: + cdf = CytoDataFrame(pd.DataFrame({"Image_FileName_DNA": ["volume.tiff"]})) + attempted_rows: list = [] + + monkeypatch.setattr(cdf, "find_image_columns", lambda: ["Image_FileName_DNA"]) + monkeypatch.setattr(CytoDataFrame, "find_ome_arrow_columns", lambda _self, _df: []) + monkeypatch.setattr(cdf, "get_displayed_rows", lambda: [0, "\u2026", 1]) + + def fake_get_3d_volume(row: int, column: str): # noqa: ANN202 + attempted_rows.append(row) + if row == 1: + return np.zeros((2, 2, 2), dtype=np.uint8), (2, 2, 2) + raise ValueError("not 3d") + + monkeypatch.setattr(cdf, "_get_3d_volume_from_cell", fake_get_3d_volume) + + assert cdf._find_3d_columns_for_display() == ["Image_FileName_DNA"] + assert attempted_rows == [0, 1] + + +def test_find_3d_columns_for_display_falls_back_to_index( + monkeypatch: pytest.MonkeyPatch, +) -> None: + cdf = CytoDataFrame( + pd.DataFrame({"Image_FileName_DNA": ["volume.tiff"]}, index=[7]) + ) + attempted_rows: list = [] + + monkeypatch.setattr(cdf, "find_image_columns", lambda: ["Image_FileName_DNA"]) + monkeypatch.setattr(CytoDataFrame, "find_ome_arrow_columns", lambda _self, _df: []) + monkeypatch.setattr(cdf, "get_displayed_rows", lambda: ["\u2026"]) + + def fake_get_3d_volume(row: int, column: str): # noqa: ANN202 + attempted_rows.append(row) + return np.zeros((2, 2, 2), dtype=np.uint8), (2, 2, 2) + + monkeypatch.setattr(cdf, "_get_3d_volume_from_cell", fake_get_3d_volume) + + assert cdf._find_3d_columns_for_display() == ["Image_FileName_DNA"] + assert attempted_rows == [7] + + +def test_repr_html_force_trame_falls_back_to_candidate_columns( + monkeypatch: pytest.MonkeyPatch, +) -> None: + cdf = CytoDataFrame( + pd.DataFrame({"Image_FileName_DNA": ["volume.tiff"], "OMEArrowCol": [None]}), + display_options={"view": "trame", "auto_trame_for_3d": False}, + ) + captured: dict = {} + displayed: list = [] + + def fake_image_columns() -> list: + return ["Image_FileName_DNA"] + + def fake_ome_columns(_self: CytoDataFrame, _df: pd.DataFrame) -> list: + return ["OMEArrowCol"] + + def fake_snapshot_html() -> str: + return "
" + + def capture_display(value: object) -> None: + displayed.append(value) + + monkeypatch.setattr(cdf, "find_image_columns", fake_image_columns) + monkeypatch.setattr(CytoDataFrame, "find_ome_arrow_columns", fake_ome_columns) + monkeypatch.setattr(cdf, "_generate_trame_snapshot_html", fake_snapshot_html) + monkeypatch.setattr("cytodataframe.frame.display", capture_display) + + def fake_show_widget_table(column: str, **kwargs: object) -> str: + captured["column"] = column + captured["columns_3d"] = kwargs.get("columns_3d") + return "widget_table" + + monkeypatch.setattr(cdf, "show_widget_table", fake_show_widget_table) + + assert cdf._repr_html_() is None + assert captured["column"] == "Image_FileName_DNA" + assert captured["columns_3d"] == ["Image_FileName_DNA", "OMEArrowCol"] + assert displayed + + +def test_is_notebook_or_lab_detects_zmq_shell(monkeypatch: pytest.MonkeyPatch) -> None: + zmq_shell = type("ZMQInteractiveShell", (), {})() + monkeypatch.setattr("cytodataframe.frame.get_ipython", lambda: zmq_shell) + assert CytoDataFrame.is_notebook_or_lab() is True + + +def test_is_notebook_or_lab_detects_terminal_shell( + monkeypatch: pytest.MonkeyPatch, +) -> None: + term_shell = type("TerminalInteractiveShell", (), {})() + monkeypatch.setattr("cytodataframe.frame.get_ipython", lambda: term_shell) + assert CytoDataFrame.is_notebook_or_lab() is False + + +def test_is_notebook_or_lab_handles_unknown_shell( + monkeypatch: pytest.MonkeyPatch, +) -> None: + unknown_shell = type("CustomShell", (), {})() + monkeypatch.setattr("cytodataframe.frame.get_ipython", lambda: unknown_shell) + assert CytoDataFrame.is_notebook_or_lab() is False + + +def test_is_notebook_or_lab_handles_name_error(monkeypatch: pytest.MonkeyPatch) -> None: + def raise_name_error(): # noqa: ANN202 + raise NameError("missing") + + monkeypatch.setattr("cytodataframe.frame.get_ipython", raise_name_error) + assert CytoDataFrame.is_notebook_or_lab() is False + + +def test_show_widget_table_rejects_empty_columns_3d(): + cdf = CytoDataFrame(pd.DataFrame({"A": [1]})) + with pytest.raises(ValueError, match="columns_3d must include at least one column"): + cdf.show_widget_table(column="A", rows=[0], columns_3d=[]) + + +def test_show_widget_table_renders_fallback_when_3d_fails(): + cdf = CytoDataFrame(pd.DataFrame({"A": [1], "B": [2]})) + grid = cdf.show_widget_table( + column="A", + rows=[0, "\u2026"], + columns=["A", "B"], + columns_3d=["A"], + ) + # Header + 2 rows, index + 2 columns + assert grid.n_rows == 3 + assert grid.n_columns == 3 + assert "3D render failed" in grid[1, 1].value + assert "\u2026" in grid[2, 1].value + + +def test_show_widget_table_raises_in_debug_mode_when_3d_fails(): + cdf = CytoDataFrame(pd.DataFrame({"A": [1]})) + with pytest.raises(ValueError, match="does not contain a 3D volume"): + cdf.show_widget_table( + column="A", + rows=[0], + columns=["A"], + columns_3d=["A"], + debug=True, ) - except CellExecutionError as e: - pytest.fail(f"Notebook execution failed: {e}") + + +def test_show_widget_table_renders_3d_viewer_cells_successfully( + monkeypatch: pytest.MonkeyPatch, +) -> None: + cdf = CytoDataFrame( + pd.DataFrame( + { + "A": [1, 2, 3, 4, 5], + "B": [10, 20, 30, 40, 50], + "C": [100, 200, 300, 400, 500], + "D": [1000, 2000, 3000, 4000, 5000], + } + ), + display_options={"view": "trame", "height": 220, "width": 150}, + ) + monkeypatch.setattr(cdf, "get_displayed_rows", lambda: [np.int64(0), np.int64(1)]) + monkeypatch.setattr("cytodataframe.frame.pd.get_option", lambda _name: 3) + monkeypatch.setattr( + cdf, + "_get_3d_volume_from_cell", + lambda row, column: (np.ones((2, 2, 2), dtype=np.uint8), (2, 2, 2)), + ) + + captured: dict[str, object] = {} + + def fake_build_pyvista_viewer(**kwargs: object): # noqa: ANN202 + captured.update(kwargs) + + return widgets.HTML(value="viewer") + + monkeypatch.setattr(cdf, "_build_pyvista_viewer", fake_build_pyvista_viewer) + + grid = cdf.show_widget_table( + column="A", + backend=None, + columns=["A", "B", "C", "D"], + max_columns=3, + max_rows=2, + columns_3d=["A"], + widget_height=np.int64(140), + index_width=np.int64(90), + ) + + assert grid.n_rows == 3 + assert grid.n_columns == 4 + assert "…" in grid[2, 1].value + assert captured["backend"] == "trame" + assert captured["widget_height"] == "140px" + + +def test_get_displayed_rows_when_under_limit(monkeypatch: pytest.MonkeyPatch): + cdf = CytoDataFrame(pd.DataFrame({"A": [1, 2, 3]}, index=[10, 20, 30])) + + monkeypatch.setattr("cytodataframe.frame.pd.get_option", lambda name: 10) + assert cdf.get_displayed_rows() == [10, 20, 30] + + +def test_get_displayed_rows_when_over_limit(monkeypatch: pytest.MonkeyPatch): + cdf = CytoDataFrame(pd.DataFrame({"A": list(range(8))}, index=list(range(8)))) + + def fake_get_option(name: str) -> int: + return 6 if name == "display.max_rows" else 4 + + monkeypatch.setattr("cytodataframe.frame.pd.get_option", fake_get_option) + assert cdf.get_displayed_rows() == [0, 1, 6, 7] + + +def test_normalize_labels_returns_string_index_and_backmap(): + labels = pd.Index([1, "x", 2.5]) + labels_as_str, backmap = CytoDataFrame._normalize_labels(labels) + assert list(labels_as_str) == ["1", "x", "2.5"] + assert backmap["1"] == 1 + assert backmap["x"] == "x" + assert backmap["2.5"] == 2.5 + + +def test_is_3d_image_array_detects_rgb_like_images_as_not_3d() -> None: + rgb = np.zeros((64, 64, 3), dtype=np.uint8) + rgba = np.zeros((64, 64, 4), dtype=np.uint8) + assert CytoDataFrame._is_3d_image_array(rgb) is False + assert CytoDataFrame._is_3d_image_array(rgba) is False + + +def test_is_3d_image_array_accepts_thin_small_volume_shapes() -> None: + thin_x = np.zeros((5, 20, 3), dtype=np.uint8) + singleton_x = np.zeros((5, 20, 1), dtype=np.uint8) + assert CytoDataFrame._is_3d_image_array(thin_x) is True + assert CytoDataFrame._is_3d_image_array(singleton_x) is True + + +def _install_fake_pyvista( # noqa: C901 + monkeypatch: pytest.MonkeyPatch, + screenshot_image: np.ndarray | None = None, +) -> None: + class FakePointData: + def __init__(self) -> None: + self.data = {} + self.active_scalars_name = None + + def clear(self) -> None: + self.data = {} + + def __setitem__(self, key: str, value: object) -> None: + self.data[key] = value + + def set_active_scalars(self, _name: str) -> None: + raise AttributeError + + class FakeImageData: + def __init__(self) -> None: + self.dimensions = None + self.spacing = None + self.origin = None + self.point_data = FakePointData() + + def set_active_scalars(self, _name: str) -> None: + return None + + class FakeProp: + def SetInterpolationTypeToNearest(self) -> None: + return None + + def SetInterpolationTypeToLinear(self) -> None: + return None + + def SetInterpolateScalarsBeforeMapping(self, _value: bool) -> None: + return None + + def SetScalarOpacityUnitDistance(self, _value: float) -> None: + return None + + class FakeMapper: + def SetAutoAdjustSampleDistances(self, _value: bool) -> None: + return None + + def SetUseJittering(self, _value: bool) -> None: + return None + + def SetSampleDistance(self, _value: float) -> None: + return None + + class FakeActor: + def __init__(self) -> None: + self.prop = FakeProp() + self.mapper = FakeMapper() + + def GetProperty(self) -> FakeProp: + return self.prop + + def GetMapper(self) -> FakeMapper: + return self.mapper + + class FakeViewer: + def __init__(self) -> None: + self.layout = None + self.value = ( + 'class="pyvista" style="border: 1px solid; width: 200px; ' + 'height: 200px;"' + ) + + class FakePlotter: + def __init__(self, notebook: bool = False, off_screen: bool = False) -> None: + self.notebook = notebook + self.off_screen = off_screen + self.background = None + + def set_background(self, value: str) -> None: + self.background = value + + def add_volume(self, *args: object, **kwargs: object) -> FakeActor: + return FakeActor() + + def add_axes(self) -> None: + return None + + def show(self, **_kwargs: object) -> FakeViewer: + return FakeViewer() + + def screenshot(self, return_img: bool = True) -> np.ndarray | None: + if not return_img: + return None + return screenshot_image + + fake_module = types.SimpleNamespace( + ImageData=FakeImageData, + Plotter=FakePlotter, + set_jupyter_backend=lambda _backend: None, + __spec__=ModuleSpec("pyvista", loader=None), + ) + monkeypatch.setitem(sys.modules, "pyvista", fake_module) + + +def test_build_pyvista_viewer_with_fake_module(monkeypatch: pytest.MonkeyPatch) -> None: + _install_fake_pyvista( + monkeypatch, + screenshot_image=np.zeros((2, 2, 3), dtype=np.uint8), + ) + cdf = CytoDataFrame(pd.DataFrame({"A": [1]})) + + viewer = cdf._build_pyvista_viewer( + volume=np.ones((2, 2, 2), dtype=np.uint8), + backend="trame", + widget_height="120px", + ) + assert hasattr(viewer, "_cdf_plotter") + assert "width: 100%;" in viewer.value + assert "height: 100%;" in viewer.value + + +def test_show_trame_falls_back_to_ipywidgets(monkeypatch: pytest.MonkeyPatch) -> None: + _install_fake_pyvista( + monkeypatch, + screenshot_image=np.zeros((2, 2, 3), dtype=np.uint8), + ) + cdf = CytoDataFrame(pd.DataFrame({"A": [1]}), display_options={"view": "trame"}) + + monkeypatch.setattr( + cdf, + "_get_3d_volume_from_cell", + lambda row, column: (np.ones((2, 2, 2), dtype=np.uint8), (2, 2, 2)), + ) + + def fake_html_table() -> str: + return "
t
" + + monkeypatch.setattr(cdf, "_generate_jupyter_dataframe_html", fake_html_table) + monkeypatch.setattr( + cdf, + "_build_pyvista_viewer", + lambda **_kwargs: __import__("ipywidgets").HTML("viewer"), + ) + original_import = __import__ + + def fake_import(name: str, *args: object, **kwargs: object): # noqa: ANN202 + if name.startswith("trame"): + raise ImportError("no trame") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr("builtins.__import__", fake_import) + + container = cdf.show_trame(row=0, column="A", backend=None) + assert hasattr(container, "children") + assert len(container.children) == 2 + + +def test_show_trame_raises_when_ipywidgets_missing_in_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _install_fake_pyvista( + monkeypatch, + screenshot_image=np.zeros((2, 2, 3), dtype=np.uint8), + ) + cdf = CytoDataFrame(pd.DataFrame({"A": [1]}), display_options={"view": "trame"}) + monkeypatch.setattr( + cdf, + "_get_3d_volume_from_cell", + lambda row, column: (np.ones((2, 2, 2), dtype=np.uint8), (2, 2, 2)), + ) + monkeypatch.setattr( + cdf, + "_generate_jupyter_dataframe_html", + lambda: "t
", + ) + monkeypatch.setattr( + cdf, + "_build_pyvista_viewer", + lambda **_kwargs: types.SimpleNamespace(server=None, _server=None), + ) + original_import = __import__ + + def fake_import(name: str, *args: object, **kwargs: object): # noqa: ANN202 + if name.startswith("trame"): + raise ImportError("no trame") + if name == "ipywidgets": + raise ImportError("no ipywidgets") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr("builtins.__import__", fake_import) + + with pytest.raises( + RuntimeError, + match="ipywidgets is required for notebook layout", + ): + cdf.show_trame(row=0, column="A", backend=None) + + +def test_generate_jupyter_dataframe_html_info_repr_branch( + monkeypatch: pytest.MonkeyPatch, +): + cdf = CytoDataFrame(pd.DataFrame({"A": [1]})) + monkeypatch.setattr(cdf, "_info_repr", lambda: True) + html = cdf._generate_jupyter_dataframe_html() + assert html.startswith("
")
+    assert "<class" in html
+
+
+def test_generate_jupyter_dataframe_html_with_joined_components(
+    monkeypatch: pytest.MonkeyPatch,
+    tmp_path: pathlib.Path,
+):
+    base = pd.DataFrame(
+        {"Image_FileName_DNA": ["dna.tiff"], "OMECol": [{"type": "ome.arrow"}]},
+        index=[0],
+    )
+    cdf = CytoDataFrame(base, data_context_dir=str(tmp_path))
+    cdf._custom_attrs["data_bounding_box"] = pd.DataFrame(
+        {
+            "Cells_AreaShape_BoundingBoxMinimum_X": [0],
+            "Cells_AreaShape_BoundingBoxMinimum_Y": [0],
+            "Cells_AreaShape_BoundingBoxMaximum_X": [1],
+            "Cells_AreaShape_BoundingBoxMaximum_Y": [1],
+        },
+        index=[0],
+    )
+    cdf._custom_attrs["compartment_center_xy"] = pd.DataFrame(
+        {"Cells_Location_Center_X": [0], "Cells_Location_Center_Y": [0]},
+        index=[0],
+    )
+    cdf._custom_attrs["data_image_paths"] = pd.DataFrame(
+        {"Image_PathName_DNA": [str(tmp_path)]},
+        index=[0],
+    )
+
+    options = {
+        "display.notebook_repr_html": True,
+        "display.max_rows": 10,
+        "display.min_rows": 10,
+        "display.max_columns": 10,
+        "display.show_dimensions": False,
+    }
+
+    monkeypatch.setattr("cytodataframe.frame.get_option", lambda name: options[name])
+    monkeypatch.setattr(
+        "cytodataframe.frame.CytoDataFrame.find_image_columns",
+        lambda self: ["Image_FileName_DNA"],
+    )
+    monkeypatch.setattr(
+        "cytodataframe.frame.CytoDataFrame.find_image_path_columns",
+        lambda self, image_cols, all_cols: {"Image_FileName_DNA": "Image_PathName_DNA"},
+    )
+    monkeypatch.setattr(
+        "cytodataframe.frame.CytoDataFrame.get_displayed_rows",
+        lambda self: [0],
+    )
+    monkeypatch.setattr(
+        cdf,
+        "process_image_data_as_html_display",
+        lambda **_kwargs: "",
+    )
+    monkeypatch.setattr(cdf, "find_ome_arrow_columns", lambda data: ["OMECol"])
+    monkeypatch.setattr(
+        cdf,
+        "process_ome_arrow_data_as_html_display",
+        lambda _value: "
OME
", + ) + + html = cdf._generate_jupyter_dataframe_html() + assert "" in html + assert "
OME
" in html + + +def test_render_output_displays_js_and_print_html(monkeypatch: pytest.MonkeyPatch): + cdf = CytoDataFrame(pd.DataFrame({"A": [1]})) + cdf._custom_attrs["_output"] = nullcontext() + monkeypatch.setattr( + cdf, + "_generate_jupyter_dataframe_html", + lambda: '
', + ) + monkeypatch.setattr("cytodataframe.frame.get_option", lambda _name: False) + + displayed: list = [] + + def capture_display(value: object) -> None: + displayed.append(value) + + monkeypatch.setattr( + "cytodataframe.frame.display", + capture_display, + ) + result = cdf._render_output() + assert result is None + assert len(displayed) == 3 + + +def test_generate_trame_snapshot_html_paths(monkeypatch: pytest.MonkeyPatch): + cdf = CytoDataFrame(pd.DataFrame({"Image_FileName_DNA": ["dna.tiff"]}, index=[0])) + monkeypatch.setattr(cdf, "_generate_jupyter_dataframe_html", lambda: "") + + # Early return when no bounding box. + cdf._custom_attrs["data_bounding_box"] = None + assert cdf._generate_trame_snapshot_html() == "
" + + # Snapshot render path. + cdf._custom_attrs["data_bounding_box"] = pd.DataFrame( + { + "Cells_AreaShape_BoundingBoxMinimum_X": [0], + "Cells_AreaShape_BoundingBoxMinimum_Y": [0], + "Cells_AreaShape_BoundingBoxMaximum_X": [1], + "Cells_AreaShape_BoundingBoxMaximum_Y": [1], + }, + index=[0], + ) + cdf._custom_attrs["_snapshot_cache"] = {} + cdf._custom_attrs["_snapshot_cache_lock"] = None + monkeypatch.setattr(cdf, "find_image_columns", lambda: ["Image_FileName_DNA"]) + monkeypatch.setattr(cdf, "get_displayed_rows", lambda: [0]) + monkeypatch.setattr( + cdf, + "_get_3d_volume_from_cell", + lambda row, column: (np.ones((2, 2, 2), dtype=np.uint8), (2, 2, 2)), + ) + monkeypatch.setattr( + cdf, + "_pyvista_volume_snapshot_html", + lambda volume, dims: "", + ) + out = cdf._generate_trame_snapshot_html() + assert "" in out or "Snapshot unavailable" in out + + +def test_pyvista_volume_snapshot_html_success(monkeypatch: pytest.MonkeyPatch) -> None: + _install_fake_pyvista( + monkeypatch, + screenshot_image=np.zeros((2, 2, 3), dtype=np.uint8), + ) + cdf = CytoDataFrame( + pd.DataFrame({"A": [1]}), + display_options={"width": "10px", "height": "10px"}, + ) + html = cdf._pyvista_volume_snapshot_html( + volume=np.ones((2, 2, 2), dtype=np.uint8), + dims=(2, 2, 2), + ) + assert html is not None + assert "data:image/png;base64" in html + + +def test_pyvista_volume_snapshot_html_returns_none_when_no_image( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _install_fake_pyvista(monkeypatch, screenshot_image=None) + cdf = CytoDataFrame(pd.DataFrame({"A": [1]})) + html = cdf._pyvista_volume_snapshot_html( + volume=np.ones((2, 2, 2), dtype=np.uint8), + dims=(2, 2, 2), + ) + assert html is None + + +def test_show_trame_trame_layout_success(monkeypatch: pytest.MonkeyPatch) -> None: + _install_fake_pyvista( + monkeypatch, + screenshot_image=np.zeros((2, 2, 3), dtype=np.uint8), + ) + cdf = CytoDataFrame(pd.DataFrame({"A": [1]}), display_options={"view": "trame"}) + + monkeypatch.setattr( + cdf, + "_get_3d_volume_from_cell", + lambda row, column: (np.ones((2, 2, 2), dtype=np.uint8), (2, 2, 2)), + ) + monkeypatch.setattr( + cdf, + "_generate_jupyter_dataframe_html", + lambda: "
t
", + ) + monkeypatch.setattr( + cdf, + "_build_pyvista_viewer", + lambda **_kwargs: types.SimpleNamespace(server=None, _server=None), + ) + + class DummyCtx: + def __enter__(self): # noqa: ANN204 + return self + + def __exit__(self, *_exc: object) -> bool: + return False + + class DummyLayout: + def __init__(self, _server: object) -> None: + self.content = DummyCtx() + self.content.children = [] + + def __enter__(self): # noqa: ANN204 + return self + + def __exit__(self, *_exc: object) -> bool: + return False + + class DummyServer: + client_type = "vue2" + + def url(self) -> str: + return "http://example" + + def fake_get_server() -> DummyServer: + return DummyServer() + + html_mod = types.SimpleNamespace(Div=lambda *args, **kwargs: None) + vuetify_mod = types.SimpleNamespace( + VContainer=lambda *args, **kwargs: DummyCtx(), + VRow=lambda *args, **kwargs: DummyCtx(), + VCol=lambda *args, **kwargs: DummyCtx(), + ) + monkeypatch.setitem( + sys.modules, + "trame.app", + types.SimpleNamespace( + get_server=fake_get_server, + __spec__=ModuleSpec("trame.app", loader=None), + ), + ) + monkeypatch.setitem( + sys.modules, + "trame.widgets", + types.SimpleNamespace( + html=html_mod, + vuetify=vuetify_mod, + __spec__=ModuleSpec("trame.widgets", loader=None), + ), + ) + monkeypatch.setitem( + sys.modules, + "trame.ui.vuetify", + types.SimpleNamespace( + SinglePageLayout=DummyLayout, + __spec__=ModuleSpec("trame.ui.vuetify", loader=None), + ), + ) + + shown: list = [] + + def capture_shown(value: object) -> None: + shown.append(value) + + monkeypatch.setattr( + "cytodataframe.frame.display", + capture_shown, + ) + layout = cdf.show_trame(row=0, column="A", backend=None) + assert layout is not None + assert shown + + +def test_repr_returns_expected_values(monkeypatch: pytest.MonkeyPatch) -> None: + cdf = CytoDataFrame(pd.DataFrame({"A": [1]})) + monkeypatch.setattr("cytodataframe.frame.get_option", lambda _name: True) + assert cdf.__repr__() == "" + debug_repr = cdf.__repr__(debug=True) + assert isinstance(debug_repr, str) + assert "A" in debug_repr + + +def test_enable_debug_mode_adds_handler_once() -> None: + cdf = CytoDataFrame(pd.DataFrame({"A": [1]})) + frame_logger = logging.getLogger("cytodataframe.frame") + original_handlers = list(frame_logger.handlers) + frame_logger.handlers = [] + frame_logger.setLevel(logging.INFO) + try: + cdf._enbable_debug_mode() + assert frame_logger.level == logging.DEBUG + assert len(frame_logger.handlers) == 1 + cdf._enbable_debug_mode() + assert len(frame_logger.handlers) == 1 + finally: + frame_logger.handlers = original_handlers diff --git a/tests/test_image.py b/tests/test_image.py index 0ee482d..4f72088 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -369,12 +369,20 @@ def test_colorconv_control_may_emit_warning(): """ bad = np.array([[0.0, np.inf], [np.nan, 1.0]], dtype=float) - funcs = [ - lambda a: skimage.color.gray2rgb(a), - lambda a: skimage.color.rgb2lab(np.dstack([a, a, a])), - lambda a: skimage.color.lab2rgb(np.dstack([a * 100.0, a * 255.0, a * 255.0])), - lambda a: skimage.color.rgb2hsv(np.dstack([a, a, a])), - ] + def _gray2rgb(a: np.ndarray) -> np.ndarray: + return skimage.color.gray2rgb(a) + + def _rgb2lab(a: np.ndarray) -> np.ndarray: + return skimage.color.rgb2lab(np.dstack([a, a, a])) + + def _lab2rgb(a: np.ndarray) -> np.ndarray: + stacked = np.dstack([a * 100.0, a * 255.0, a * 255.0]) + return skimage.color.lab2rgb(stacked) + + def _rgb2hsv(a: np.ndarray) -> np.ndarray: + return skimage.color.rgb2hsv(np.dstack([a, a, a])) + + funcs = [_gray2rgb, _rgb2lab, _lab2rgb, _rgb2hsv] saw_warning = False with warnings.catch_warnings(record=True) as caught: @@ -429,5 +437,110 @@ def test_add_image_scale_bar_avoids_colorconv_warning_with_bad_values(): ) for w in caught ), "Should not trigger the skimage colorconv matmul warning." - assert out.ndim == 3 and out.shape == (H, W, 3) and out.dtype == np.uint8 + + +def test_draw_outline_on_image_from_outline_raises_for_non_rgb(tmp_path: pathlib.Path): + outline_image_path = tmp_path / "outline.png" + imageio.imwrite(outline_image_path, np.zeros((5, 5), dtype=np.uint8)) + + with pytest.raises(ValueError, match="3 channels"): + draw_outline_on_image_from_outline( + np.zeros((5, 5, 4), dtype=np.uint8), + str(outline_image_path), + ) + + +def test_draw_outline_on_image_from_mask_raises_for_non_rgb(tmp_path: pathlib.Path): + mask_image_path = tmp_path / "mask.png" + imageio.imwrite(mask_image_path, np.zeros((5, 5), dtype=np.uint8)) + + with pytest.raises(ValueError, match="3 channels"): + draw_outline_on_image_from_mask( + np.zeros((5, 5, 4), dtype=np.uint8), + str(mask_image_path), + ) + + +def test_draw_outline_on_image_from_outline_converts_non_uint8(tmp_path: pathlib.Path): + outline_image_path = tmp_path / "outline.png" + imageio.imwrite(outline_image_path, np.full((5, 5), 255, dtype=np.uint8)) + orig = np.zeros((5, 5, 3), dtype=np.float32) + out = draw_outline_on_image_from_outline(orig, str(outline_image_path)) + assert out.dtype == np.uint8 + + +def test_draw_outline_on_image_from_mask_handles_multichannel_mask( + tmp_path: pathlib.Path, +): + mask = np.zeros((6, 6, 3), dtype=np.uint8) + mask[2:4, 2:4, :] = 255 + mask_image_path = tmp_path / "mask_rgb.png" + imageio.imwrite(mask_image_path, mask) + out = draw_outline_on_image_from_mask( + np.zeros((6, 6, 3), dtype=np.uint8), str(mask_image_path) + ) + assert out.shape == (6, 6, 3) + + +def test_get_pixel_bbox_from_offsets_handles_inverted_axes(): + bbox = get_pixel_bbox_from_offsets( + center_x=10, center_y=20, rel_bbox=(5, 8, -5, -8) + ) + assert bbox == (5, 12, 15, 28) + + +def test_add_image_scale_bar_returns_original_when_um_per_pixel_nonpositive(): + img = np.zeros((8, 8), dtype=np.uint8) + out = add_image_scale_bar(img, um_per_pixel=0.0) + assert out is img + + +def test_add_image_scale_bar_scales_float_unit_range(): + img = np.linspace(0.0, 1.0, 16, dtype=np.float32).reshape(4, 4) + out = add_image_scale_bar( + img, + um_per_pixel=0.5, + length_um=2.0, + thickness_px=1, + margin_px=0, + ) + assert out.dtype == np.uint8 + assert out.shape == (4, 4, 3) + assert int(out.max()) <= 255 + + +def test_add_image_scale_bar_raises_on_unsupported_shape(): + with pytest.raises(ValueError, match="Unsupported image shape"): + add_image_scale_bar( + np.zeros((2, 2, 2), dtype=np.uint8), + um_per_pixel=0.5, + ) + + +def test_add_image_scale_bar_clips_extent_for_small_images(): + img = np.zeros((5, 5), dtype=np.uint8) + out = add_image_scale_bar( + img, + um_per_pixel=0.1, + length_um=50.0, + thickness_px=4, + location="upper left", + margin_px=4, + ) + # The clipped bar still exists and remains within bounds. + assert out.shape == (5, 5, 3) + assert np.any(np.all(out == (255, 255, 255), axis=-1)) + + +def test_add_image_scale_bar_accepts_rgba_input(): + img = np.zeros((6, 6, 4), dtype=np.uint8) + img[..., 3] = 200 + out = add_image_scale_bar( + img, + um_per_pixel=0.5, + length_um=2.0, + thickness_px=1, + margin_px=0, + ) + assert out.shape == (6, 6, 3) diff --git a/tests/test_project_integration.py b/tests/test_project_integration.py index a209cd1..dc5af82 100644 --- a/tests/test_project_integration.py +++ b/tests/test_project_integration.py @@ -4,8 +4,13 @@ import subprocess -import cosmicqc import pandas as pd +import pytest + +cosmicqc = pytest.importorskip( + "cosmicqc", + reason="project integration tests require cosmicqc to be installed", +) def test_cosmicqc_find_outliers_cfret(cytotable_CFReT_data_df: pd.DataFrame): diff --git a/tests/test_volume.py b/tests/test_volume.py new file mode 100644 index 0000000..421ada1 --- /dev/null +++ b/tests/test_volume.py @@ -0,0 +1,394 @@ +import logging +import pathlib +import types +from importlib.machinery import ModuleSpec + +import numpy as np +import pandas as pd +import pytest + +from cytodataframe.frame import CytoDataFrame +from cytodataframe.volume import ( + build_3d_html_from_path, + build_3d_image_html_stub, + build_3d_image_html_view, + build_3d_vtk_js_initializer, + build_3d_vtk_js_script, + extract_volume_from_ome_arrow, +) + + +def _fake_ome_arrow_volume() -> dict: + return { + "type": "ome.arrow", + "pixels_meta": { + "size_x": 2, + "size_y": 2, + "size_z": 2, + "size_c": 1, + "size_t": 1, + }, + "planes": [ + {"z": 0, "c": 0, "t": 0, "pixels": [0, 1, 2, 3]}, + {"z": 1, "c": 0, "t": 0, "pixels": [4, 5, 6, 7]}, + ], + } + + +def test_extract_volume_from_ome_arrow_builds_volume(): + logger = logging.getLogger(__name__) + cdf = CytoDataFrame(pd.DataFrame({"A": [1]})) + volume_data = extract_volume_from_ome_arrow( + _fake_ome_arrow_volume(), + cdf._ensure_uint8, + cdf._is_ome_arrow_value, + logger, + ) + + assert volume_data is not None + volume, dims = volume_data + assert dims == (2, 2, 2) + assert volume.shape == (2, 2, 2) + assert volume[0, 0, 0] == 0 + assert volume[1, 1, 1] == 7 + + +def test_build_3d_image_html_view_contains_vtk_script(): + volume = np.zeros((2, 2, 2), dtype=np.uint8) + html = build_3d_image_html_view( + volume=volume, + dims=(2, 2, 2), + data_value="volume.tiff", + candidate_path=pathlib.Path("volume.tiff"), + display_options={"width": "120px", "height": "120px"}, + ) + + assert 'class="cyto-3d-image"' in html + assert "data-volume=" in html + assert "vtk.js" in html + + +def test_build_3d_image_html_view_uses_stub_when_inline_volume_too_large(): + volume = np.zeros((8, 8, 8), dtype=np.uint8) + html = build_3d_image_html_view( + volume=volume, + dims=(8, 8, 8), + data_value="volume.tiff", + candidate_path=pathlib.Path("volume.tiff"), + display_options={"max_inline_volume_bytes": 16}, + ) + + assert 'class="cyto-3d-image"' in html + assert "data-volume=" not in html + assert "too large for inline rendering" in html + + +def test_build_3d_image_html_view_defaults_when_limit_value_invalid(): + volume = np.zeros((2, 2, 2), dtype=np.uint8) + html = build_3d_image_html_view( + volume=volume, + dims=(2, 2, 2), + data_value="volume.tiff", + candidate_path=pathlib.Path("volume.tiff"), + display_options={"max_inline_volume_bytes": "not-an-int"}, + ) + + assert "data-volume=" in html + + +def test_process_ome_arrow_volume_returns_vtk_html(): + cdf = CytoDataFrame(pd.DataFrame({"A": [1]})) + html = cdf.process_ome_arrow_data_as_html_display(_fake_ome_arrow_volume()) + + assert 'class="cyto-3d-image"' in html + assert "vtk.js" in html + + +def test_build_3d_image_html_stub_respects_none_height(): + html = build_3d_image_html_stub( + data_value="vol.tiff", + candidate_path=pathlib.Path("vol.tiff"), + display_options={"width": "111px", "height": None}, + ) + assert "width:111px" in html + assert "height:" not in html + assert "3D image" in html + + +def test_build_3d_image_html_stub_includes_default_height(): + html = build_3d_image_html_stub( + data_value="vol.tiff", + candidate_path=pathlib.Path("vol.tiff"), + display_options={"width": "90px"}, + ) + assert "height:300px" in html + + +def test_vtk_js_helpers_include_expected_hooks(): + script = build_3d_vtk_js_script("abc") + initializer = build_3d_vtk_js_initializer() + assert "https://unpkg.com/@kitware/vtk.js@34.9.1/dist/vtk.js" in script + assert "document.getElementById('abc')" in script + assert "querySelectorAll('.cyto-3d-image[data-volume][data-dims]')" in initializer + for token in ( + "ctfun.addRGBPoint(1,1,1,1);", + "ctfun.addRGBPoint(255,1,1,1);", + "ofun.addPoint(1,0.15);", + "ofun.addPoint(255,0.2);", + ): + assert token in script + assert token in initializer + + +def test_vtk_js_helpers_allow_custom_url_override(): + custom_url = "https://example.com/vtk-local.js" + script = build_3d_vtk_js_script("abc", vtk_js_url=custom_url) + initializer = build_3d_vtk_js_initializer( + display_options={"vtk_js_url": custom_url} + ) + assert custom_url in script + assert custom_url in initializer + + +def test_build_3d_image_html_view_uses_env_vtk_js_url( + monkeypatch: pytest.MonkeyPatch, +): + custom_url = "https://example.com/vtk-env.js" + monkeypatch.setenv("CYTODATAFRAME_VTK_JS_URL", custom_url) + volume = np.zeros((2, 2, 2), dtype=np.uint8) + html = build_3d_image_html_view( + volume=volume, + dims=(2, 2, 2), + data_value="volume.tiff", + candidate_path=pathlib.Path("volume.tiff"), + display_options={}, + ) + assert custom_url in html + + +def test_extract_volume_from_ome_arrow_returns_none_for_invalid_inputs(): + cdf = CytoDataFrame(pd.DataFrame({"A": [1]})) + logger = logging.getLogger(__name__) + assert ( + extract_volume_from_ome_arrow( + {"not": "ome"}, + cdf._ensure_uint8, + cdf._is_ome_arrow_value, + logger, + ) + is None + ) + assert ( + extract_volume_from_ome_arrow( + { + "type": "ome.arrow", + "pixels_meta": {"size_x": 2, "size_y": 2, "size_z": 1}, + "planes": [], + }, + cdf._ensure_uint8, + cdf._is_ome_arrow_value, + logger, + ) + is None + ) + + +def test_extract_volume_from_ome_arrow_filters_invalid_planes(): + cdf = CytoDataFrame(pd.DataFrame({"A": [1]})) + logger = logging.getLogger(__name__) + data_value = { + "type": "ome.arrow", + "pixels_meta": {"size_x": 2, "size_y": 2, "size_z": 3}, + "planes": np.array( + [ + {"z": 0, "c": 1, "t": 0, "pixels": [0, 1, 2, 3]}, + {"z": 1, "c": 0, "t": 0, "pixels": [4, 5, 6, 7]}, + {"z": 2, "c": 0, "t": 0, "pixels": [8, 9]}, + ], + dtype=object, + ), + } + volume_data = extract_volume_from_ome_arrow( + data_value, + cdf._ensure_uint8, + cdf._is_ome_arrow_value, + logger, + ) + assert volume_data is not None + volume, dims = volume_data + assert dims == (2, 2, 3) + assert int(volume[1, 1, 1]) == 7 + + +def test_extract_volume_from_ome_arrow_logs_debug_on_exception(): + cdf = CytoDataFrame(pd.DataFrame({"A": [1]})) + messages = [] + + class FakeLogger: + def debug(self, msg: str, *args: object) -> None: + messages.append(msg % args if args else msg) + + volume_data = extract_volume_from_ome_arrow( + { + "type": "ome.arrow", + "pixels_meta": {"size_x": "bad", "size_y": 2, "size_z": 2}, + "planes": [], + }, + cdf._ensure_uint8, + cdf._is_ome_arrow_value, + FakeLogger(), + ) + assert volume_data is None + assert any("Unable to decode 3D OME-Arrow struct" in msg for msg in messages) + + +def test_build_3d_html_from_path_without_ome_arrow_returns_none(): + cdf = CytoDataFrame(pd.DataFrame({"A": [1]})) + messages = [] + + class FakeLogger: + def debug(self, msg: str, *args: object) -> None: + messages.append(msg % args if args else msg) + + original_import = __import__ + + def fake_import(name: str, *args: object, **kwargs: object): # noqa: ANN202 + if name == "ome_arrow": + raise ImportError("missing ome_arrow") + return original_import(name, *args, **kwargs) + + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr("builtins.__import__", fake_import) + html = build_3d_html_from_path( + data_value="x", + candidate_path=pathlib.Path("nope"), + display_options={}, + ensure_uint8=cdf._ensure_uint8, + is_ome_arrow_value=cdf._is_ome_arrow_value, + logger=FakeLogger(), + ) + assert html is None + assert any("ome-arrow not available" in msg for msg in messages) + + +def test_extract_volume_from_ome_arrow_returns_none_when_no_valid_planes(): + cdf = CytoDataFrame(pd.DataFrame({"A": [1]})) + logger = logging.getLogger(__name__) + data_value = { + "type": "ome.arrow", + "pixels_meta": {"size_x": 2, "size_y": 2, "size_z": 2}, + "planes": [None, {"z": 3, "c": 0, "t": 0, "pixels": [1, 2, 3, 4]}, {"z": 1}], + } + assert ( + extract_volume_from_ome_arrow( + data_value, + cdf._ensure_uint8, + cdf._is_ome_arrow_value, + logger, + ) + is None + ) + + +def test_build_3d_image_html_view_fallback_handles_percentile_and_write_errors( + monkeypatch: pytest.MonkeyPatch, +): + volume = np.ones((2, 2, 2), dtype=np.uint8) + + def raise_percentile_error(*_args: object) -> tuple[float, float]: + raise ValueError("bad") + + def raise_write_error(*_args: object, **_kwargs: object) -> None: + raise ValueError("write fail") + + monkeypatch.setattr("cytodataframe.volume.np.percentile", raise_percentile_error) + monkeypatch.setattr("cytodataframe.volume.imageio.imwrite", raise_write_error) + html = build_3d_image_html_view( + volume=volume, + dims=(2, 2, 2), + data_value="volume.tiff", + candidate_path=pathlib.Path("volume.tiff"), + display_options={"width": "120px", "height": "120px"}, + ) + assert 'class="cyto-3d-image"' in html + assert ' None: + messages.append(msg % args if args else msg) + + class FailingOMEArrow: + def __init__(self, data: str) -> None: + raise RuntimeError("boom") + + fake_module = types.SimpleNamespace( + OMEArrow=FailingOMEArrow, + __spec__=ModuleSpec("ome_arrow", loader=None), + ) + monkeypatch.setitem(__import__("sys").modules, "ome_arrow", fake_module) + html = build_3d_html_from_path( + data_value="x", + candidate_path=pathlib.Path("x"), + display_options={}, + ensure_uint8=cdf._ensure_uint8, + is_ome_arrow_value=cdf._is_ome_arrow_value, + logger=FakeLogger(), + ) + assert html is None + assert any("Failed to load OME-Arrow for 3D rendering" in msg for msg in messages) + + +def test_build_3d_html_from_path_with_fake_ome_arrow(monkeypatch: pytest.MonkeyPatch): + cdf = CytoDataFrame(pd.DataFrame({"A": [1]})) + + class FakeOMEArrow: + def __init__(self, data: str) -> None: + self.data = _fake_ome_arrow_volume() + + fake_module = types.SimpleNamespace( + OMEArrow=FakeOMEArrow, + __spec__=ModuleSpec("ome_arrow", loader=None), + ) + monkeypatch.setitem(__import__("sys").modules, "ome_arrow", fake_module) + html = build_3d_html_from_path( + data_value="volume.tiff", + candidate_path=pathlib.Path("volume.tiff"), + display_options={"width": "120px", "height": "120px"}, + ensure_uint8=cdf._ensure_uint8, + is_ome_arrow_value=cdf._is_ome_arrow_value, + logger=logging.getLogger(__name__), + ) + assert html is not None + assert 'class="cyto-3d-image"' in html + + +def test_build_3d_html_from_path_returns_none_when_volume_decode_fails( + monkeypatch: pytest.MonkeyPatch, +): + cdf = CytoDataFrame(pd.DataFrame({"A": [1]})) + + class BadOMEArrow: + def __init__(self, data: str) -> None: + self.data = {"type": "ome.arrow", "pixels_meta": {"size_x": 2}} + + fake_module = types.SimpleNamespace( + OMEArrow=BadOMEArrow, + __spec__=ModuleSpec("ome_arrow", loader=None), + ) + monkeypatch.setitem(__import__("sys").modules, "ome_arrow", fake_module) + html = build_3d_html_from_path( + data_value="x", + candidate_path=pathlib.Path("x"), + display_options={}, + ensure_uint8=cdf._ensure_uint8, + is_ome_arrow_value=cdf._is_ome_arrow_value, + logger=logging.getLogger(__name__), + ) + assert html is None diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..bda0207 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3 @@ +version = 1 +revision = 3 +requires-python = ">=3.13"