diff --git a/.coveragerc_omit b/.coveragerc_omit index 79315301..b2036261 100644 --- a/.coveragerc_omit +++ b/.coveragerc_omit @@ -2,6 +2,7 @@ omit = vitessce/config.py vitessce/export.py + vitessce/file_def_utils.py vitessce/routes.py vitessce/widget.py vitessce/wrappers.py @@ -10,4 +11,6 @@ omit = vitessce/data_utils/anndata.py vitessce/data_utils/ome.py vitessce/data_utils/entities.py - vitessce/data_utils/multivec.py \ No newline at end of file + vitessce/data_utils/multivec.py + vitessce/widget_plugins/demo_plugin.py + vitessce/widget_plugins/spatial_query.py \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d29bbec8..2dddba77 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - version: ['3.8', '3.12'] + version: ['3.9', '3.12'] steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 diff --git a/demos/fill_template.py b/demos/fill_template.py index 2c00a487..c2296e76 100644 --- a/demos/fill_template.py +++ b/demos/fill_template.py @@ -12,7 +12,7 @@ def render_json(dir_name, version_str, url_type, port): BASE_URL = { 'local': f'http://localhost:{port}/{dir_name}/data/processed', - 'remote': f'https://s3.amazonaws.com/vitessce-data/{version_str}/main/{dir_name}' + 'remote': f'https://data-1.vitessce.io/{version_str}/main/{dir_name}' } BASE_URL_GCP = { 'local': f'http://localhost:{port}/{dir_name}/data/processed', diff --git a/docs/data_examples.rst b/docs/data_examples.rst index 673cb77f..4553453c 100644 --- a/docs/data_examples.rst +++ b/docs/data_examples.rst @@ -7,4 +7,5 @@ Data preparation examples notebooks/data_export_s3 notebooks/data_export_files - notebooks/widget_brain_with_base_dir \ No newline at end of file + notebooks/widget_brain_with_base_dir + notebooks/widget_brain_h5ad \ No newline at end of file diff --git a/docs/data_options.rst b/docs/data_options.rst index d600efde..a6c15663 100644 --- a/docs/data_options.rst +++ b/docs/data_options.rst @@ -106,5 +106,44 @@ Jupyter process: remote service like Colab/Binder; Files: remote & accessed via Unfortunately, this will not work because the remote server cannot access the files that are on another machine behind SSH. +======================================================================== +Jupyter process: anywhere; Files: anywhere that can be accessed via Zarr +======================================================================== +If the data is readable via Zarr (i.e., `zarr.storage.*Store`) and the Jupyter process can access the store contents, then the Vitessce widget can access the data by specifying the Zarr store as the data source for Vitessce data wrapper class instances. +This is currently supported for the ``AnnDataWrapper`` class using the ``adata_store`` parameter (as opposed to ``adata_path`` or ``adata_url``). + +.. code-block:: python + + from vitessce import VitessceConfig, AnnDataWrapper + + # ... + adata.write_zarr("my_store.adata.zarr") + + vc = VitessceConfig(name="My Vitessce Configuration") + vc.add_dataset(name="My Dataset").add_object(AnnDataWrapper( + adata_store="my_store.adata.zarr", + # ... + )) + # ... + vc.widget() + + +Or, with a Zarr store instance (instead of a local string path to a DirectoryStore): + +.. code-block:: python + + import zarr + from vitessce import VitessceConfig, AnnDataWrapper + + # ... + store = zarr.storage.FSStore("s3://my_bucket/path/to/my_store.adata.zarr") + + vc = VitessceConfig(name="My Vitessce Configuration") + vc.add_dataset(name="My Dataset").add_object(AnnDataWrapper( + adata_store=store, + # ... + )) + # ... + vc.widget() diff --git a/docs/index.rst b/docs/index.rst index c47bf0b3..a92caae6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -48,6 +48,7 @@ The Vitessce widget is compatible with the following interactive Python platform api_config api_data data_options + widget_plugins screenshots diff --git a/docs/notebooks/spatial_data.ipynb b/docs/notebooks/spatial_data.ipynb new file mode 100644 index 00000000..062f5655 --- /dev/null +++ b/docs/notebooks/spatial_data.ipynb @@ -0,0 +1,203 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "nbsphinx": "hidden" + }, + "source": [ + "# Vitessce Widget Tutorial" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Visualization of SpatialData Object" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Import dependencies\n", + "\n", + "We need to import the classes and functions that we will be using from the corresponding packages." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2\n", + "\n", + "from pathlib import Path\n", + "from urllib.request import urlretrieve\n", + "import dask\n", + "\n", + "dask.config.set({'dataframe.query-planning-warning': False})\n", + "\n", + "from spatialdata import read_zarr\n", + "import scanpy as sc\n", + "\n", + "from vitessce import (\n", + " VitessceConfig,\n", + " Component as cm,\n", + " CoordinationType as ct,\n", + " CoordinationLevel as CL,\n", + " AbstractWrapper,\n", + " SpatialDataWrapper,\n", + " get_initial_coordination_scope_prefix\n", + ")\n", + "from vitessce.data_utils import (\n", + " optimize_adata,\n", + " VAR_CHUNK_SIZE,\n", + ")\n", + "import zipfile\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "zip_filepath = Path(\"data/visium.spatialdata.zarr.zip\")\n", + "spatialdata_filepath = zip_filepath.with_suffix('')\n", + "if not zip_filepath.exists():\n", + " spatialdata_filepath.parent.mkdir(exist_ok=True)\n", + " urlretrieve('https://s3.embl.de/spatialdata/spatialdata-sandbox/visium_associated_xenium_io.zip', zip_filepath)\n", + "if not spatialdata_filepath.exists():\n", + " with zipfile.ZipFile(zip_filepath,\"r\") as zip_ref:\n", + " zip_ref.extractall(spatialdata_filepath.parent)\n", + " (spatialdata_filepath.parent / \"data.zarr\").rename(spatialdata_filepath)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Load the data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "spatialdata = read_zarr(spatialdata_filepath)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "spatialdata" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Create the Vitessce widget configuration\n", + "\n", + "Vitessce needs to know which pieces of data we are interested in visualizing, the visualization types we would like to use, and how we want to coordinate (or link) the views." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vc = VitessceConfig(\n", + " schema_version=\"1.0.16\",\n", + " name='Visium SpatialData Demo (visium_associated_xenium_io)',\n", + " description='From https://spatialdata.scverse.org/en/latest/tutorials/notebooks/datasets/README.html'\n", + ")\n", + "# Add data to the configuration:\n", + "wrapper = SpatialDataWrapper(\n", + " sdata_path=spatialdata_filepath,\n", + " # The following paths are relative to the root of the SpatialData zarr store on-disk.\n", + " image_path=\"images/CytAssist_FFPE_Human_Breast_Cancer_full_image\",\n", + " table_path=\"table/table\",\n", + " obs_feature_matrix_path=\"table/table/X\",\n", + " obs_spots_path=\"shapes/CytAssist_FFPE_Human_Breast_Cancer\",\n", + " region=\"CytAssist_FFPE_Human_Breast_Cancer\",\n", + " coordinate_system=\"global\",\n", + " coordination_values={\n", + " # The following tells Vitessce to consider each observation as a \"spot\"\n", + " \"obsType\": \"spot\",\n", + " }\n", + ")\n", + "dataset = vc.add_dataset(name='Breast Cancer Visium').add_object(wrapper)\n", + "\n", + "# Add views (visualizations) to the configuration:\n", + "spatial = vc.add_view(\"spatialBeta\", dataset=dataset)\n", + "feature_list = vc.add_view(cm.FEATURE_LIST, dataset=dataset)\n", + "layer_controller = vc.add_view(\"layerControllerBeta\", dataset=dataset)\n", + "vc.link_views_by_dict([spatial, layer_controller], {\n", + " 'imageLayer': CL([{\n", + " 'photometricInterpretation': 'RGB',\n", + " }]),\n", + "}, scope_prefix=get_initial_coordination_scope_prefix(\"A\", \"image\"))\n", + "obs_sets = vc.add_view(cm.OBS_SETS, dataset=dataset)\n", + "vc.link_views([spatial, layer_controller, feature_list, obs_sets], ['obsType'], [wrapper.obs_type_label])\n", + "\n", + "# Layout the views\n", + "vc.layout(spatial | (feature_list / layer_controller / obs_sets));" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 4. Render the widget" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vw = vc.widget()\n", + "vw" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/docs/notebooks/widget_brain_h5ad.ipynb b/docs/notebooks/widget_brain_h5ad.ipynb new file mode 100644 index 00000000..d3a37359 --- /dev/null +++ b/docs/notebooks/widget_brain_h5ad.ipynb @@ -0,0 +1,177 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "nbsphinx": "hidden" + }, + "source": [ + "# Vitessce Widget Tutorial" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Visualization of single-cell RNA seq data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "from os.path import join, isfile, isdir\n", + "from urllib.request import urlretrieve\n", + "from anndata import read_h5ad\n", + "import scanpy as sc\n", + "import json\n", + "\n", + "from vitessce import (\n", + " VitessceConfig,\n", + " Component as cm,\n", + " CoordinationType as ct,\n", + " AnnDataWrapper,\n", + ")\n", + "from vitessce.data_utils import (\n", + " generate_h5ad_ref_spec\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 0. Download data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "h5_url = \"https://datasets.cellxgene.cziscience.com/84df8fa1-ab53-43c9-a439-95dcb9148265.h5ad\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "adata_filepath = join(\"data\", \"84df8fa1-ab53-43c9-a439-95dcb9148265.h5ad\")\n", + "if not isfile(adata_filepath):\n", + " os.makedirs(\"data\", exist_ok=True)\n", + " urlretrieve(h5_url, adata_filepath)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Create a Reference Spec JSON file for the H5AD file\n", + "\n", + "In order for Vitessce to load H5AD files, we also need to provide a corresponding [Reference Spec](https://fsspec.github.io/kerchunk/spec.html) JSON file which contains mappings between AnnData object keys and the byte offsets at which those AnnData object values begin within the H5AD file binary contents." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "json_filepath = join(\"data\", \"84df8fa1-ab53-43c9-a439-95dcb9148265.h5ad.reference.json\")\n", + "if not isfile(json_filepath):\n", + " ref_dict = generate_h5ad_ref_spec(h5_url)\n", + " with open(json_filepath, \"w\") as f:\n", + " json.dump(ref_dict, f)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "tags": [] + }, + "source": [ + "## 2. Create the Vitessce widget configuration\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vc = VitessceConfig(schema_version=\"1.0.17\", name='Nakshatri et al', description='snRNA-seq analyses of breast tissues of healthy women of diverse genetic ancestry')\n", + "\n", + "dataset = vc.add_dataset(name='84df8fa1').add_object(AnnDataWrapper(\n", + " adata_path=adata_filepath,\n", + " ref_path=json_filepath, # We specify paths to both the H5AD and JSON files\n", + " obs_embedding_paths=[\"obsm/X_wnn.umap\"],\n", + " obs_embedding_names=[\"UMAP\"],\n", + " obs_set_paths=[\"obs/cell_type\"],\n", + " obs_set_names=[\"Cell Type\"],\n", + " obs_feature_matrix_path=\"X\",\n", + " )\n", + ")\n", + "\n", + "scatterplot = vc.add_view(cm.SCATTERPLOT, dataset=dataset, mapping=\"UMAP\")\n", + "cell_sets = vc.add_view(cm.OBS_SETS, dataset=dataset)\n", + "cell_set_sizes = vc.add_view(cm.OBS_SET_SIZES, dataset=dataset)\n", + "genes = vc.add_view(cm.FEATURE_LIST, dataset=dataset)\n", + "\n", + "vc.layout((scatterplot | cell_sets) / (cell_set_sizes | genes));" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "tags": [] + }, + "source": [ + "## 3. Create the widget" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vw = vc.widget()\n", + "vw" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/docs/notebooks/widget_pbmc.ipynb b/docs/notebooks/widget_pbmc.ipynb index f972949c..284546aa 100644 --- a/docs/notebooks/widget_pbmc.ipynb +++ b/docs/notebooks/widget_pbmc.ipynb @@ -118,7 +118,9 @@ "source": [ "## 4. Create a Vitessce view config\n", "\n", - "Define the data and views you would like to include in the widget." + "Define the data and views you would like to include in the widget.\n", + "\n", + "For more details about how to configure data depending on where the files are located relative to the notebook execution, see https://python-docs.vitessce.io/data_options.html." ] }, { @@ -129,7 +131,7 @@ "source": [ "vc = VitessceConfig(schema_version=\"1.0.15\", name='PBMC Reference')\n", "dataset = vc.add_dataset(name='PBMC 3k').add_object(AnnDataWrapper(\n", - " adata_path=zarr_filepath,\n", + " adata_store=zarr_filepath,\n", " obs_set_paths=[\"obs/leiden\"],\n", " obs_set_names=[\"Leiden\"],\n", " obs_embedding_paths=[\"obsm/X_umap\", \"obsm/X_pca\"],\n", @@ -153,13 +155,6 @@ "## 5. Create the Vitessce widget" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "A widget can be created with the `.widget()` method on the config instance. Here, the `proxy=True` parameter allows this widget to be used in a cloud notebook environment, such as Binder." - ] - }, { "cell_type": "code", "execution_count": null, @@ -201,7 +196,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.0" + "version": "3.9.0" } }, "nbformat": 4, diff --git a/docs/notebooks/widget_plugin_custom.ipynb b/docs/notebooks/widget_plugin_custom.ipynb new file mode 100644 index 00000000..440b77d5 --- /dev/null +++ b/docs/notebooks/widget_plugin_custom.ipynb @@ -0,0 +1,207 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "nbsphinx": "hidden" + }, + "source": [ + "# Vitessce Widget Tutorial" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [], + "source": [ + "from vitessce import (\n", + " VitessceConfig,\n", + " Component as cm,\n", + " CoordinationType as ct,\n", + " OmeTiffWrapper,\n", + " MultiImageWrapper,\n", + " VitesscePlugin\n", + ")\n", + "from esbuild_py import transform" + ] + }, + { + "cell_type": "code", + "execution_count": 40, + "metadata": {}, + "outputs": [], + "source": [ + "PLUGIN_ESM = transform(\"\"\"\n", + "function createPlugins(utilsForPlugins) {\n", + " const {\n", + " React,\n", + " PluginFileType,\n", + " PluginViewType,\n", + " PluginCoordinationType,\n", + " PluginJointFileType,\n", + " z,\n", + " useCoordination,\n", + " invokeCommand,\n", + " } = utilsForPlugins;\n", + " \n", + " const CSS = `\n", + " .chat {\n", + " overflow-y: scroll;\n", + " }\n", + " `;\n", + " \n", + " function ChatView(props) {\n", + " \n", + " const [nextMessage, setNextMessage] = React.useState('');\n", + " const [isLoading, setIsLoading] = React.useState(false);\n", + " const [chatHistory, setChatHistory] = React.useState([]); // chatHistory is an array of message objects like [{ user, text }, ...]\n", + " \n", + " async function handleClick() { \n", + " setChatHistory(prev => ([\n", + " ...prev,\n", + " { user: 'You', text: nextMessage },\n", + " ]));\n", + " setIsLoading(true);\n", + " const [chatReceiveValue, chatReceiveBuffers] = await invokeCommand(\"chat_send\", nextMessage, []);\n", + " setChatHistory(prev => ([\n", + " ...prev,\n", + " { user: 'AI', text: chatReceiveValue.text },\n", + " ]));\n", + " setIsLoading(false);\n", + " }\n", + " \n", + " return (\n", + " <>\n", + " \n", + "
\n", + "

Chat view

\n", + "
\n", + " {chatHistory.map(message => (\n", + "

\n", + " {message.user}:\n", + " {message.text}\n", + "

\n", + " ))}\n", + "
\n", + " setNextMessage(e.target.value)} disabled={isLoading} />\n", + " \n", + "
\n", + " \n", + " );\n", + " }\n", + "\n", + " const pluginViewTypes = [\n", + " new PluginViewType('chat', ChatView, []),\n", + " ];\n", + " return { pluginViewTypes };\n", + "}\n", + "export default { createPlugins };\n", + "\"\"\")\n", + "\n", + "\n", + "def handle_chat_message(message, buffers):\n", + " return { \"text\": message.upper() }, []\n", + "\n", + "\n", + "class ChatPlugin(VitesscePlugin):\n", + " plugin_esm = PLUGIN_ESM\n", + " commands = {\n", + " \"chat_send\": handle_chat_message,\n", + " }" + ] + }, + { + "cell_type": "code", + "execution_count": 41, + "metadata": {}, + "outputs": [], + "source": [ + "vc = VitessceConfig(schema_version=\"1.0.15\", name='Spraggins Multi-Modal', description='PAS + IMS + AF From https://portal.hubmapconsortium.org/browse/collection/6a6efd0c1a2681dc7d2faab8e4ab0bca')\n", + "dataset = vc.add_dataset(name='Spraggins').add_object(\n", + " MultiImageWrapper(\n", + " image_wrappers=[\n", + " OmeTiffWrapper(img_url='https://assets.hubmapconsortium.org/f4188a148e4c759092d19369d310883b/ometiff-pyramids/processedMicroscopy/VAN0006-LK-2-85-PAS_images/VAN0006-LK-2-85-PAS_registered.ome.tif?token=', name='PAS'),\n", + " OmeTiffWrapper(img_url='https://assets.hubmapconsortium.org/2130d5f91ce61d7157a42c0497b06de8/ometiff-pyramids/processedMicroscopy/VAN0006-LK-2-85-AF_preIMS_images/VAN0006-LK-2-85-AF_preIMS_registered.ome.tif?token=', name='AF'),\n", + " OmeTiffWrapper(img_url='https://assets.hubmapconsortium.org/be503a021ed910c0918842e318e6efa2/ometiff-pyramids/ometiffs/VAN0006-LK-2-85-IMS_PosMode_multilayer.ome.tif?token=', name='IMS Pos Mode'),\n", + " OmeTiffWrapper(img_url='https://assets.hubmapconsortium.org/ca886a630b2038997a4cfbbf4abfd283/ometiff-pyramids/ometiffs/VAN0006-LK-2-85-IMS_NegMode_multilayer.ome.tif?token=', name='IMS Neg Mode')\n", + " ],\n", + " use_physical_size_scaling=True,\n", + " )\n", + ")\n", + "spatial = vc.add_view(cm.SPATIAL, dataset=dataset)\n", + "status = vc.add_view(\"chat\", dataset=dataset)\n", + "lc = vc.add_view(cm.LAYER_CONTROLLER, dataset=dataset).set_props(disableChannelsIfRgbDetected=True)\n", + "vc.layout(spatial | (lc / status));" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Create the Vitessce widget" + ] + }, + { + "cell_type": "code", + "execution_count": 42, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "52bbdb1e3f91423b8dd934e3a4ff796e", + "version_major": 2, + "version_minor": 1 + }, + "text/plain": [ + "VitessceWidget(config={'version': '1.0.15', 'name': 'Spraggins Multi-Modal', 'description': 'PAS + IMS + AF Fr…" + ] + }, + "execution_count": 42, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "vw = vc.widget(plugins=[ChatPlugin()])\n", + "vw" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/docs/notebooks/widget_plugin_demo.ipynb b/docs/notebooks/widget_plugin_demo.ipynb new file mode 100644 index 00000000..9fd0a419 --- /dev/null +++ b/docs/notebooks/widget_plugin_demo.ipynb @@ -0,0 +1,139 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "nbsphinx": "hidden" + }, + "source": [ + "# Vitessce Widget Tutorial" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Visualization of Multi-Modal Imaging Data\n", + "We visualize IMS, PAS, and AF imaging data overlaid from the Spraggins Lab of the Biomolecular Multimodal Imaging Center (BIOMC) at Vanderbilt University, uploaded to the HuBMAP data portal." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from vitessce import (\n", + " VitessceConfig,\n", + " Component as cm,\n", + " CoordinationType as ct,\n", + " OmeTiffWrapper,\n", + " MultiImageWrapper,\n", + ")\n", + "from os.path import join" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "from vitessce.widget_plugins import DemoPlugin" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Configure Vitessce\n", + "Set up the images from the three different assays, with the `use_physical_size_scaling` set to `True` so that the IMS image scales to the other images based on their physical sizes." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "vc = VitessceConfig(schema_version=\"1.0.15\", name='Spraggins Multi-Modal', description='PAS + IMS + AF From https://portal.hubmapconsortium.org/browse/collection/6a6efd0c1a2681dc7d2faab8e4ab0bca')\n", + "dataset = vc.add_dataset(name='Spraggins').add_object(\n", + " MultiImageWrapper(\n", + " image_wrappers=[\n", + " OmeTiffWrapper(img_url='https://assets.hubmapconsortium.org/f4188a148e4c759092d19369d310883b/ometiff-pyramids/processedMicroscopy/VAN0006-LK-2-85-PAS_images/VAN0006-LK-2-85-PAS_registered.ome.tif?token=', name='PAS'),\n", + " OmeTiffWrapper(img_url='https://assets.hubmapconsortium.org/2130d5f91ce61d7157a42c0497b06de8/ometiff-pyramids/processedMicroscopy/VAN0006-LK-2-85-AF_preIMS_images/VAN0006-LK-2-85-AF_preIMS_registered.ome.tif?token=', name='AF'),\n", + " OmeTiffWrapper(img_url='https://assets.hubmapconsortium.org/be503a021ed910c0918842e318e6efa2/ometiff-pyramids/ometiffs/VAN0006-LK-2-85-IMS_PosMode_multilayer.ome.tif?token=', name='IMS Pos Mode'),\n", + " OmeTiffWrapper(img_url='https://assets.hubmapconsortium.org/ca886a630b2038997a4cfbbf4abfd283/ometiff-pyramids/ometiffs/VAN0006-LK-2-85-IMS_NegMode_multilayer.ome.tif?token=', name='IMS Neg Mode')\n", + " ],\n", + " use_physical_size_scaling=True,\n", + " )\n", + ")\n", + "spatial = vc.add_view(cm.SPATIAL, dataset=dataset)\n", + "status = vc.add_view(\"demo\", dataset=dataset)\n", + "lc = vc.add_view(cm.LAYER_CONTROLLER, dataset=dataset).set_props(disableChannelsIfRgbDetected=True)\n", + "vc.layout(spatial | (lc / status));" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Create the Vitessce widget" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "837fec6d047c4f83be8530996e324fb9", + "version_major": 2, + "version_minor": 1 + }, + "text/plain": [ + "VitessceWidget(config={'version': '1.0.15', 'name': 'Spraggins Multi-Modal', 'description': 'PAS + IMS + AF Fr…" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "vw = vc.widget(plugins=[DemoPlugin()])\n", + "vw" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/docs/notebooks/widget_plugin_spatial-query.ipynb b/docs/notebooks/widget_plugin_spatial-query.ipynb new file mode 100644 index 00000000..88d28bf0 --- /dev/null +++ b/docs/notebooks/widget_plugin_spatial-query.ipynb @@ -0,0 +1,169 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "#!pip install \"vitessce[all]==3.3.0\" esbuild_py anndata\n", + "!pip install \"mlxtend~=0.23.0\"\n", + "#!pip install -i \"https://test.pypi.org/simple/\" SpatialQuery\n", + "!pip install \"SpatialQuery @ git+https://github.com/ShaokunAn/Spatial-Query@main\"" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from os.path import join\n", + "from anndata import read_h5ad\n", + "from vitessce import (\n", + " VitessceConfig,\n", + " AnnDataWrapper,\n", + " ViewType as vt,\n", + " CoordinationType as ct,\n", + " CoordinationLevel as CL,\n", + ")\n", + "from vitessce.widget_plugins import SpatialQueryPlugin" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "adata = read_h5ad(join(\"data\", \"HBM987_KWLK_254\", \"secondary_analysis.h5ad\"))\n", + "zarr_path = join(\"data\", \"HBM987_KWLK_254\", \"secondary_analysis.h5ad.zarr\")\n", + "adata.write_zarr(zarr_path)" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "plugin = SpatialQueryPlugin(adata)" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "vc = VitessceConfig(schema_version=\"1.0.16\", name=\"Spatial-Query\")\n", + "dataset = vc.add_dataset(\"Query results\").add_object(AnnDataWrapper(\n", + " adata_path=zarr_path,\n", + " obs_feature_matrix_path=\"X\",\n", + " obs_set_paths=[\"obs/predicted.ASCT.celltype\"],\n", + " obs_set_names=[\"Cell Type\"],\n", + " obs_spots_path=\"obsm/X_spatial\",\n", + " feature_labels_path=\"var/hugo_symbol\",\n", + " coordination_values={\n", + " \"featureLabelsType\": \"Gene symbol\",\n", + " }\n", + "))\n", + "\n", + "spatial_view = vc.add_view(\"spatialBeta\", dataset=dataset)\n", + "lc_view = vc.add_view(\"layerControllerBeta\", dataset=dataset)\n", + "sets_view = vc.add_view(\"obsSets\", dataset=dataset)\n", + "features_view = vc.add_view(\"featureList\", dataset=dataset)\n", + "sq_view = vc.add_view(\"spatialQuery\", dataset=dataset)\n", + "\n", + "obs_set_selection_scope, = vc.add_coordination(\"obsSetSelection\",)\n", + "obs_set_selection_scope.set_value(None)\n", + "\n", + "sets_view.use_coordination(obs_set_selection_scope)\n", + "sq_view.use_coordination(obs_set_selection_scope)\n", + "spatial_view.use_coordination(obs_set_selection_scope)\n", + "features_view.use_coordination(obs_set_selection_scope)\n", + "\n", + "vc.link_views([spatial_view, lc_view, sets_view, features_view],\n", + " [\"additionalObsSets\", \"obsSetColor\"],\n", + " [plugin.additional_obs_sets, plugin.obs_set_color]\n", + ")\n", + "vc.link_views_by_dict([spatial_view, lc_view], {\n", + " \"spotLayer\": CL([\n", + " {\n", + " \"obsType\": \"cell\",\n", + " \"spatialSpotRadius\": 15,\n", + " },\n", + " ])\n", + "})\n", + "\n", + "vc.layout((spatial_view | (lc_view / features_view)) / (sets_view | sq_view));" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/mkeller/software/miniconda3/envs/vitessce-python-notebooks/lib/python3.9/site-packages/traitlets/traitlets.py:869: DeprecationWarning: Deprecated in traitlets 4.1, use the instance .metadata dictionary directly, like x.metadata[key] or x.metadata.get(key, default)\n", + " warn(\"Deprecated in traitlets 4.1, \" + msg, DeprecationWarning, stacklevel=2)\n", + "/Users/mkeller/software/miniconda3/envs/vitessce-python-notebooks/lib/python3.9/site-packages/traitlets/traitlets.py:869: DeprecationWarning: Deprecated in traitlets 4.1, use the instance .metadata dictionary directly, like x.metadata[key] or x.metadata.get(key, default)\n", + " warn(\"Deprecated in traitlets 4.1, \" + msg, DeprecationWarning, stacklevel=2)\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "6f30fb3eac1644478df256bf79a986e9", + "version_major": 2, + "version_minor": 1 + }, + "text/plain": [ + "VitessceWidget(config={'version': '1.0.16', 'name': 'Spatial-Query', 'description': '', 'datasets': [{'uid': '…" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "vw = vc.widget(height=900, plugins=[plugin], remount_on_uid_change=False)\n", + "vw" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/docs/notebooks/widget_segmentations_beta.ipynb b/docs/notebooks/widget_segmentations_beta.ipynb new file mode 100644 index 00000000..6c82c420 --- /dev/null +++ b/docs/notebooks/widget_segmentations_beta.ipynb @@ -0,0 +1,104 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "nbsphinx": "hidden" + }, + "source": [ + "# Vitessce Widget Tutorial" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from vitessce import (\n", + " VitessceConfig,\n", + " Component as cm,\n", + " CoordinationType as ct,\n", + " OmeTiffWrapper,\n", + " MultiImageWrapper,\n", + " CoordinationLevel as CL,\n", + " ObsSegmentationsOmeTiffWrapper,\n", + " ImageOmeTiffWrapper,\n", + " get_initial_coordination_scope_prefix,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vc = VitessceConfig(schema_version=\"1.0.16\")\n", + "dataset = vc.add_dataset(name='Spraggins').add_object(\n", + " ImageOmeTiffWrapper(\n", + " img_url=\"https://storage.googleapis.com/vitessce-demo-data/kpmp-f2f-march-2023/S-1905-017737/S-1905-017737_PAS_2of2_bf.ome.tif\",\n", + " offsets_url=\"https://storage.googleapis.com/vitessce-demo-data/kpmp-f2f-march-2023/S-1905-017737/S-1905-017737_PAS_2of2_bf.offsets.json\"\n", + " )\n", + ").add_object(\n", + " ObsSegmentationsOmeTiffWrapper(\n", + " img_url=\"https://storage.googleapis.com/vitessce-demo-data/kpmp-f2f-march-2023/S-1905-017737/S-1905-017737_PAS_2of2.ome.tif\",\n", + " offsets_url=\"https://storage.googleapis.com/vitessce-demo-data/kpmp-f2f-march-2023/S-1905-017737/S-1905-017737_PAS_2of2.offsets.json\",\n", + " obs_types_from_channel_names=True\n", + " )\n", + ")\n", + "\n", + "spatial = vc.add_view(\"spatialBeta\", dataset=dataset)\n", + "lc = vc.add_view(\"layerControllerBeta\", dataset=dataset)\n", + "\n", + "vc.link_views_by_dict([spatial, lc], {\n", + " \"imageLayer\": CL([\n", + " {\n", + " \"photometricInterpretation\": \"RGB\" \n", + " }\n", + " ]),\n", + "}, meta=True, scope_prefix=get_initial_coordination_scope_prefix(\"A\", \"image\"))\n", + "\n", + "vc.layout(spatial | lc);" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vw = vc.widget()\n", + "vw" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/docs/widget_plugins.rst b/docs/widget_plugins.rst new file mode 100644 index 00000000..a1c7f927 --- /dev/null +++ b/docs/widget_plugins.rst @@ -0,0 +1,152 @@ +Widget plugins +############## + +Vitessce supports multiple types of `plugins `_ defined using JavaScript code. + +Leveraging concepts from `anywidget `_, we can define such plugins directly from Python: plugin developers can supply custom JavaScript code via a Python string. + +The most minimal example of such plugin JavaScript code is the following: + +.. code-block:: python + + PLUGIN_ESM = """ + function createPlugins(utilsForPlugins) { + const { + React, + PluginFileType, + PluginViewType, + PluginCoordinationType, + PluginJointFileType, + z, + useCoordination, + } = utilsForPlugins; + return { + pluginViewTypes: undefined, + pluginFileTypes: undefined, + pluginCoordinationTypes: undefined, + pluginJointFileTypes: undefined, + }; + } + export default { createPlugins }; + """ + + +The plugin string must be defined as an EcmaScript Module (ESM) that exports a function named ``createPlugins``. +The ``createPlugins`` function is called (on the initial render of the Jupyter widget) with the ``utilsForPlugins`` argument (to facilitate dependency injection) and returns an object with the following properties: + +- ``pluginViewTypes``: an array of objects that define the view types of the plugin. +- ``pluginFileTypes```: an array of objects that define the file types of the plugin. +- ``pluginCoordinationTypes``: an array of objects that define the coordination types of the plugin. +- ``pluginJointFileTypes``: an array of objects that define the joint file types of the plugin. + +If defined, these plugin arrays are passed to the Vitessce component as `props `_ with the same names. + +**Note**: For maximum stability of plugins, we recommend that plugin developers document which version(s) of the vitessce Python package that plugins have been developed under. + +-------------------------------- +Passing plugin ESM to the widget +-------------------------------- + +The plugin string can be passed to the widget using the ``plugins`` parameter and passing a subclass of ``VitesscePlugin``: + + +.. code-block:: python + + from vitessce import VitessceConfig, VitesscePlugin + + class MyPlugin(VitesscePlugin): + plugin_esm = PLUGIN_ESM + + vc = VitessceConfig(description="A Vitessce widget with a custom plugin") + # Some more configuration here... + + plugin = MyPlugin() + vc.widget(plugins=[plugin]) + + +------------------------------- +Defining plugin views using JSX +------------------------------- + +Vitessce plugin view types are defined as React components. +During typical React component development, JSX syntax is used. +However, JSX is not valid JavaScript and therefore must be transformed to valid JavaScript before it can be passed to the widget where it will be interpreted as ESM. +Vitessce plugin developers then have two options for defining React components for plugin view types: + +* Use ``React.createElement`` directly (without JSX). +* Use the ``transform`` function from `esbuild_py `_ to perform JSX to JS transformation. + +.. code-block:: python + + from esbuild_py import transform + + PLUGIN_ESM = transform(""" + function createPlugins(utilsForPlugins) { + const { + React, + PluginFileType, + PluginViewType, + PluginCoordinationType, + PluginJointFileType, + z, + useCoordination, + } = utilsForPlugins; + + function MyPluginView(props) { + return ( +

Hello world from JSX!

+ ); + } + + const pluginViewTypes = [ + new PluginViewType('myPlugin', MyPluginView, []), + ]; + return { pluginViewTypes }; + } + export default { createPlugins }; + """) + + +To import additional dependencies, JavaScript (more specifically, ESM) can be dynamically imported from CDN (such as ``unpkg`` or ``esm.sh``) within the ``createPlugins`` function: + +.. code-block:: python + + PLUGIN_ESM = """ + async function createPlugins(utilsForPlugins) { + const { + React, + PluginFileType, + PluginViewType, + PluginCoordinationType, + PluginJointFileType, + z, + useCoordination, + } = utilsForPlugins; + + const d3 = await import('https://cdn.jsdelivr.net/npm/d3@7/+esm'); + + // Do something with d3 here... + + return { + pluginViewTypes: undefined, + pluginFileTypes: undefined, + pluginCoordinationTypes: undefined, + pluginJointFileTypes: undefined, + }; + } + export default { createPlugins }; + """ + + +To support more complex import scenarios, see `dynamic-importmap `_. + + + +vitessce.widget_plugins +*********************** + +.. automodule:: vitessce.widget_plugins.demo_plugin + :members: + +.. automodule:: vitessce.widget_plugins.spatial_query + :members: \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 5bac98af..f5b5f17e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "vitessce" -version = "3.2.6" +version = "3.4.1" authors = [ { name="Mark Keller", email="mark_keller@hms.harvard.edu" }, ] description = "Jupyter widget facilitating interactive visualization of spatial single-cell data with Vitessce" readme = "README.md" license = {file = "LICENSE"} -requires-python = ">=3.7" +requires-python = ">=3.9" keywords = ["ipython", "jupyter", "widgets"] classifiers = [ 'Development Status :: 4 - Beta', @@ -19,10 +19,10 @@ classifiers = [ 'Intended Audience :: Developers', 'Intended Audience :: Science/Research', 'Topic :: Multimedia :: Graphics', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', ] dependencies = [ 'zarr>=2.5.0', @@ -31,10 +31,11 @@ dependencies = [ 'negspy>=0.2.24', 'pandas>=1.1.2', 'black>=21.11b1', - 'numpy>=1.21.2', + 'numpy>=1.21.2,<2.0', 'anndata>=0.7.8,<0.11', + 'spatialdata>=0.2.2', 'scanpy>=1.9.3', - 'ome-zarr==0.8.3', + 'ome-zarr>=0.8.3', 'tifffile>=2020.10.1', 'jsonschema>=3.2', 'tqdm>=4.1.0' @@ -73,11 +74,14 @@ docs = [ ] all = [ 'jupyter-server-proxy>=1.5.2', + 'esbuild_py>=0.1.3', 'anywidget>=0.9.10', 'uvicorn>=0.17.0', 'ujson>=4.0.1', 'starlette==0.14.0', 'generate-tiff-offsets>=0.1.7', + 'kerchunk>=0.2.6', + 'fsspec', # aiofiles is not explicitly referenced in our code, # but it is an implicit dependency of starlette==0.14.0. @@ -94,4 +98,4 @@ notebook = [] repository = "https://github.com/vitessce/vitessce-python" [tool.setuptools] -packages = ["vitessce", "vitessce.data_utils"] +packages = ["vitessce", "vitessce.data_utils", "vitessce.widget_plugins"] diff --git a/setup.cfg b/setup.cfg index 7ddeb2c0..09602d86 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,6 +6,7 @@ per-file-ignores = # Special case: names are reimported from __init__.py, so unused imports are expected. vitessce/__init__.py: F401 vitessce/data_utils/__init__.py: F401 + vitessce/widget_plugins/__init__.py: F401 ignore = # Ignore line too long E501, diff --git a/tests/test_config.py b/tests/test_config.py index 86c0ed2b..fe572629 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -12,6 +12,7 @@ AbstractWrapper, make_repr, CoordinationLevel as CL, + AnnDataWrapper, # Neither of these is in the source code, but they do appear in code which is eval'd. VitessceChainableConfig, @@ -19,6 +20,20 @@ ) +class MockArtifactPath: + def __init__(self, url): + self.url = url + + def to_url(self): + return self.url + + +class MockArtifact: + def __init__(self, name, url): + self.name = name + self.path = MockArtifactPath(url) + + def test_config_creation(): vc = VitessceConfig(schema_version="1.0.15") vc_dict = vc.to_dict() @@ -61,6 +76,100 @@ def test_config_add_dataset(): } +def test_config_add_anndata_url(): + vc = VitessceConfig(schema_version="1.0.15") + vc.add_dataset(name='My Dataset').add_object( + AnnDataWrapper( + adata_url="http://example.com/adata.h5ad.zarr", + obs_set_paths=["obs/louvain"], + ) + ) + + vc_dict = vc.to_dict() + + assert vc_dict == { + "version": "1.0.15", + "name": "", + "description": "", + "datasets": [ + { + 'uid': 'A', + 'name': 'My Dataset', + 'files': [ + { + "fileType": "anndata.zarr", + "url": "http://example.com/adata.h5ad.zarr", + "options": { + "obsSets": [ + { + "name": "louvain", + "path": "obs/louvain", + } + ] + } + } + ] + } + ], + 'coordinationSpace': { + 'dataset': { + 'A': 'A' + }, + }, + "layout": [], + "initStrategy": "auto" + } + + +def test_config_add_anndata_artifact(): + vc = VitessceConfig(schema_version="1.0.15") + vc.add_dataset(name='My Dataset').add_object( + AnnDataWrapper( + adata_artifact=MockArtifact("My anndata artifact", "http://example.com/adata.h5ad.zarr"), + obs_set_paths=["obs/louvain"], + ) + ) + + vc_dict = vc.to_dict() + + assert vc_dict == { + "version": "1.0.15", + "name": "", + "description": "", + "datasets": [ + { + 'uid': 'A', + 'name': 'My Dataset', + 'files': [ + { + "fileType": "anndata.zarr", + "url": "http://example.com/adata.h5ad.zarr", + "options": { + "obsSets": [ + { + "name": "louvain", + "path": "obs/louvain", + } + ] + } + } + ] + } + ], + 'coordinationSpace': { + 'dataset': { + 'A': 'A' + }, + }, + "layout": [], + "initStrategy": "auto" + } + + vc_artifacts = vc.get_artifacts() + assert list(vc_artifacts.keys()) == ["http://example.com/adata.h5ad.zarr"] + assert vc_artifacts["http://example.com/adata.h5ad.zarr"].name == "My anndata artifact" + + def test_config_add_dataset_add_files(): vc = VitessceConfig(schema_version="1.0.15") vc.add_dataset(name='My Chained Dataset').add_file( @@ -522,7 +631,12 @@ def test_config_from_dict(): 'files': [ { 'url': 'http://cells.json', - 'fileType': 'cells.json' + 'fileType': 'cells.json', + 'requestInit': { + 'headers': { + 'Authorization': 'Bearer token' + } + } } ] } @@ -568,7 +682,12 @@ def test_config_from_dict(): 'files': [ { 'url': 'http://cells.json', - 'fileType': 'cells.json' + 'fileType': 'cells.json', + 'requestInit': { + 'headers': { + 'Authorization': 'Bearer token' + } + } } ] }, diff --git a/tests/test_config_updates.py b/tests/test_config_updates.py new file mode 100644 index 00000000..b413ae12 --- /dev/null +++ b/tests/test_config_updates.py @@ -0,0 +1,87 @@ +import pytest + +from vitessce import ( + VitessceConfig, + ViewType as cm, +) + + +@pytest.fixture +def vitessce_config(): + vc = VitessceConfig(schema_version="1.0.15") + my_dataset = vc.add_dataset(name='My Dataset') + vc.add_view(cm.SPATIAL, dataset=my_dataset) + vc.add_view(cm.SCATTERPLOT, dataset=my_dataset) + vc.add_view(cm.SCATTERPLOT, dataset=my_dataset) + return vc + + +def test_get_views(vitessce_config): + views = vitessce_config.get_views() + assert len(views) == 3 + assert views[0].view["component"].lower() == "spatial" + assert views[1].view["component"].lower() == "scatterplot" + + +def test_get_view_by_index(vitessce_config): + view = vitessce_config.get_view_by_index(0) + vc_dict = view.to_dict() + assert vc_dict["component"] == "spatial" + + view = vitessce_config.get_view_by_index(1) + vc_dict = view.to_dict() + assert vc_dict["component"] == "scatterplot" + + with pytest.raises(IndexError): + vitessce_config.get_view_by_index(5) + + +def test_get_view_by_component(vitessce_config): + view = vitessce_config.get_first_view_by_type("spatial") + vc_dict = view.to_dict() + assert vc_dict["component"] == "spatial" + + view = vitessce_config.get_first_view_by_type("SCATTERPLOT") + vc_dict = view.to_dict() + assert vc_dict["component"] == "scatterplot" + + with pytest.raises(ValueError): + vitessce_config.get_first_view_by_type("TEST") + + +def test_get_view_by_invalid_type(vitessce_config): + with pytest.raises(TypeError): + vitessce_config.get_first_view_by_type(3.5) + + +def test_remove_view_by_index(vitessce_config): + removed_view = vitessce_config.remove_view_by_index(0) + rv_dict = removed_view.to_dict() + assert rv_dict["component"] == "spatial" + assert len(vitessce_config.get_views()) == 2 + + removed_view = vitessce_config.remove_view_by_index(1) + rv_dict = removed_view.to_dict() + assert rv_dict["component"] == "scatterplot" + assert len(vitessce_config.get_views()) == 1 + + with pytest.raises(IndexError): + vitessce_config.remove_view_by_index(5) + + +def test_remove_view_by_component(vitessce_config): + removed_view = vitessce_config.remove_first_view_by_type("spatial") + rv_dict = removed_view.to_dict() + assert rv_dict["component"] == "spatial" + assert len(vitessce_config.get_views()) == 2 + + with pytest.raises(ValueError): + vitessce_config.remove_first_view_by_type("spatial") + + with pytest.raises(ValueError): + vitessce_config.remove_first_view_by_type("TEST") + + +def test_remove_view_by_invalid_index(vitessce_config): + with pytest.raises(TypeError): + vitessce_config.remove_view_by_index(3.5) diff --git a/tests/test_wrappers.py b/tests/test_wrappers.py index d351cb1c..5ced8d6d 100644 --- a/tests/test_wrappers.py +++ b/tests/test_wrappers.py @@ -22,7 +22,7 @@ ObsSegmentationsOmeZarrWrapper, ) -from vitessce.wrappers import file_path_to_url_path +from vitessce.wrappers import SpatialDataWrapper, file_path_to_url_path data_path = Path('tests/data') @@ -235,8 +235,64 @@ def test_anndata_with_base_dir(self): file_def = file_def_creator('http://localhost:8000') self.assertEqual(file_def, {'fileType': 'anndata.zarr', 'url': 'http://localhost:8000/test.h5ad.zarr', 'options': { + 'obsEmbedding': [{'path': 'obsm/X_umap', 'dims': [0, 1], 'embeddingType': 'UMAP'}], + 'obsSets': [{'name': 'Cell Type', 'path': 'obs/CellType'}] + }}) + + def test_anndata_with_base_dir_no_names(self): + adata_path = 'test.h5ad.zarr' + w = AnnDataWrapper(adata_path, obs_set_paths=['obs/CellType'], obs_embedding_paths=[ + 'obsm/X_umap']) + w.base_dir = data_path + w.local_dir_uid = 'anndata.zarr' + + file_def_creator = w.make_file_def_creator('A', 0) + file_def = file_def_creator('http://localhost:8000') + self.assertEqual(file_def, {'fileType': 'anndata.zarr', 'url': 'http://localhost:8000/test.h5ad.zarr', + 'options': { + 'obsEmbedding': [{'path': 'obsm/X_umap', 'dims': [0, 1], 'embeddingType': 'X_umap'}], + 'obsSets': [{'name': 'CellType', 'path': 'obs/CellType'}] + }}) + + def test_anndata_with_h5ad_and_ref_json(self): + adata_path = data_path / 'test.h5ad' + ref_json_path = data_path / 'test.h5ad.ref.json' + w = AnnDataWrapper(adata_path, ref_path=ref_json_path, + obs_set_paths=['obs/CellType'], obs_set_names=['Cell Type'], + obs_labels_names=['Cell Label'], obs_labels_paths=['obs/CellLabel'], + obs_embedding_paths=['obsm/X_umap'], obs_embedding_names=['UMAP']) + w.local_file_uid = 'anndata.h5ad' + w.local_ref_uid = 'anndata.reference.json' + + file_def_creator = w.make_file_def_creator('A', 0) + file_def = file_def_creator('http://localhost:8000') + self.assertEqual(file_def, {'fileType': 'anndata.h5ad', 'url': 'http://localhost:8000/A/0/anndata.h5ad', + 'options': { + 'refSpecUrl': 'http://localhost:8000/A/0/anndata.reference.json', 'obsEmbedding': [{'path': 'obsm/X_umap', 'embeddingType': 'UMAP', 'dims': [0, 1]}], - 'obsSets': [{'path': 'obs/CellType', 'name': 'Cell Type'}] + 'obsSets': [{'path': 'obs/CellType', 'name': 'Cell Type'}], + 'obsLabels': [{'path': 'obs/CellLabel', 'obsLabelsType': 'Cell Label'}] + }}) + + def test_anndata_with_h5ad_and_ref_json_with_base_dir(self): + adata_path = 'test.h5ad' + ref_json_path = 'test.h5ad.ref.json' + w = AnnDataWrapper(adata_path, ref_path=ref_json_path, + obs_set_paths=['obs/CellType'], obs_set_names=['Cell Type'], + obs_labels_names=['Cell Label'], obs_labels_paths=['obs/CellLabel'], + obs_embedding_paths=['obsm/X_umap'], obs_embedding_names=['UMAP']) + w.base_dir = data_path + w.local_file_uid = 'anndata.h5ad' + w.local_ref_uid = 'anndata.reference.json' + + file_def_creator = w.make_file_def_creator('A', 0) + file_def = file_def_creator('http://localhost:8000') + self.assertEqual(file_def, {'fileType': 'anndata.h5ad', 'url': 'http://localhost:8000/test.h5ad', + 'options': { + 'refSpecUrl': 'http://localhost:8000/test.h5ad.ref.json', + 'obsEmbedding': [{'path': 'obsm/X_umap', 'embeddingType': 'UMAP', 'dims': [0, 1]}], + 'obsSets': [{'path': 'obs/CellType', 'name': 'Cell Type'}], + 'obsLabels': [{'path': 'obs/CellLabel', 'obsLabelsType': 'Cell Label'}] }}) def test_csv(self): @@ -334,3 +390,73 @@ def test_multivec_zarr_with_base_dir(self): 'fileType': 'genomic-profiles.zarr', 'url': 'http://localhost:8000/test_out.snap.multivec.zarr', }) + + def test_spatial_data_with_base_dir(self): + + spatial_data_path = 'test.spatialdata.zarr' + w = SpatialDataWrapper( + sdata_path=spatial_data_path, + image_path="images/picture", + obs_set_paths=['obs/CellType'], + obs_set_names=['Cell Type'], + obs_embedding_paths=['obsm/X_umap'], + obs_embedding_names=['UMAP'] + ) + w.base_dir = data_path + w.local_dir_uid = 'spatialdata.zarr' + + file_def_creator = w.make_file_def_creator('A', 0) + file_def = file_def_creator('http://localhost:8000') + self.assertEqual(file_def, { + 'fileType': 'spatialdata.zarr', + 'url': 'http://localhost:8000/test.spatialdata.zarr', + 'options': { + 'obsSets': { + 'obsSets': [{'name': 'Cell Type', 'path': 'obs/CellType'}], + 'tablePath': 'tables/table' + }, + 'image': {'path': 'images/picture'} + }}) + + def test_spatial_data_with_base_dir_2(self): + spatial_data_path = 'test.spatialdata.zarr' + w = SpatialDataWrapper( + sdata_path=spatial_data_path, + image_path='images/CytAssist_FFPE_Human_Breast_Cancer_full_image', + coordinate_system='aligned', + region='CytAssist_FFPE_Human_Breast_Cancer', + obs_feature_matrix_path='tables/table/X', + obs_spots_path='shapes/CytAssist_FFPE_Human_Breast_Cancer', + table_path='tables/table', + coordination_values={ + "obsType": "spot" + } + ) + w.base_dir = data_path + w.local_dir_uid = 'spatialdata.zarr' + + file_def_creator = w.make_file_def_creator('A', 0) + file_def = file_def_creator('http://localhost:8000') + self.assertDictEqual(file_def, { + 'fileType': 'spatialdata.zarr', + 'url': 'http://localhost:8000/test.spatialdata.zarr', + 'options': { + 'image': { + 'path': 'images/CytAssist_FFPE_Human_Breast_Cancer_full_image', + 'coordinateSystem': 'aligned', + }, + 'obsFeatureMatrix': { + 'path': 'tables/table/X', + 'region': 'CytAssist_FFPE_Human_Breast_Cancer' + }, + 'obsSpots': { + 'path': 'shapes/CytAssist_FFPE_Human_Breast_Cancer', + 'tablePath': 'tables/table', + 'region': 'CytAssist_FFPE_Human_Breast_Cancer', + 'coordinateSystem': 'aligned', + } + }, + 'coordinationValues': { + "obsType": "spot" + } + }) diff --git a/vitessce/__init__.py b/vitessce/__init__.py index bc8ae8db..00af54d6 100644 --- a/vitessce/__init__.py +++ b/vitessce/__init__.py @@ -37,7 +37,7 @@ # We allow installation without all of the dependencies that the widget requires. # The imports below will fail in that case, and corresponding globals will be undefined. try: - from .widget import VitessceWidget, data_server + from .widget import VitessceWidget, VitesscePlugin, data_server except ModuleNotFoundError as e: # pragma: no cover warn(f'Extra installs are necessary to use widgets: {e}') @@ -53,6 +53,7 @@ ObsSegmentationsOmeTiffWrapper, ImageOmeZarrWrapper, ObsSegmentationsOmeZarrWrapper, + SpatialDataWrapper, ) except ModuleNotFoundError as e: # pragma: no cover warn(f'Extra installs are necessary to use wrappers: {e}') diff --git a/vitessce/config.py b/vitessce/config.py index 4663ef38..efb53174 100644 --- a/vitessce/config.py +++ b/vitessce/config.py @@ -50,7 +50,7 @@ class VitessceConfigDatasetFile: A class to represent a file (described by a URL, data type, and file type) in a Vitessce view config dataset. """ - def __init__(self, file_type, url=None, coordination_values=None, options=None, data_type=None): + def __init__(self, file_type, url=None, coordination_values=None, options=None, data_type=None, request_init=None): """ Not meant to be instantiated directly, but instead created and returned by the ``VitessceConfigDataset.add_file()`` method. @@ -62,6 +62,8 @@ def __init__(self, file_type, url=None, coordination_values=None, options=None, :param options: Extra options to pass to the file loader class. :type options: dict or list or None :param data_type: Deprecated / not used. Only included for backwards compatibility with the old API. + :param request_init: Optional request init object to pass to the fetch API. + :type request_init: dict or None """ self.file = { "fileType": file_type @@ -72,6 +74,8 @@ def __init__(self, file_type, url=None, coordination_values=None, options=None, self.file["options"] = options if coordination_values: self.file["coordinationValues"] = coordination_values + if request_init: + self.file["requestInit"] = request_init def __repr__(self): repr_dict = { @@ -83,6 +87,8 @@ def __repr__(self): repr_dict["coordination_values"] = self.file["coordinationValues"] if "options" in self.file: repr_dict["options"] = self.file["options"] + if "requestInit" in self.file: + repr_dict["request_init"] = self.file["requestInit"] return make_repr(repr_dict, class_def=self.__class__) @@ -135,7 +141,7 @@ def get_uid(self): """ return self.dataset["uid"] - def add_file(self, file_type, url=None, coordination_values=None, options=None, data_type=None): + def add_file(self, file_type, url=None, coordination_values=None, options=None, data_type=None, request_init=None): """ Add a new file definition to this dataset instance. @@ -148,6 +154,8 @@ def add_file(self, file_type, url=None, coordination_values=None, options=None, :param options: Extra options to pass to the file loader class. Optional. :type options: dict or list or None :param data_type: Deprecated / not used. Only included for backwards compatibility with the old API. + :param request_init: Optional request init object to pass to the fetch API. + :type request_init: dict or None :returns: Self, to allow function chaining. :rtype: VitessceConfigDataset @@ -171,7 +179,7 @@ def add_file(self, file_type, url=None, coordination_values=None, options=None, file_type_str = norm_enum(file_type, ft) self._add_file(VitessceConfigDatasetFile( - url=url, file_type=file_type_str, coordination_values=coordination_values, options=options)) + url=url, file_type=file_type_str, coordination_values=coordination_values, options=options, request_init=request_init)) return self def _add_file(self, obj): @@ -227,6 +235,13 @@ def get_routes(self): return routes + def get_artifacts(self): + artifacts = {} + for obj in self.objs: + artifacts.update(obj.get_artifacts()) + + return artifacts + def get_stores(self, base_url=None): stores = {} for obj in self.objs: @@ -1033,7 +1048,7 @@ def add_view(self, view_type, dataset=None, dataset_uid=None, x=0, y=0, w=1, h=1 dataset_uid, str) assert dataset is None or dataset_uid is None component = view_type - assert type(component) in [str, cm] + # assert type(component) in [str, cm] if dataset is None: dataset = self.get_dataset_by_uid(dataset_uid) @@ -1487,6 +1502,89 @@ def to_dict(self, base_url=None): "layout": [c.to_dict() for c in self.config["layout"]] } + def get_views(self): + """ + Provides all the views in the config.layout object list + + :returns: A list of VitessceConfigView objects. + + """ + return self.config["layout"] + + def get_view_by_index(self, index): + """ + Get a view from the layout by the index specified by the 'index' parameter. + + :param index: Index (int) of the view in the Layout array. + :type index: int + + :returns: The view corresponding to the provided index + :rtype: VitessceConfigView or None if not found + """ + if isinstance(index, int): + if 0 <= index < len(self.config["layout"]): + return self.config["layout"][index] + else: + raise IndexError("index out of range") + else: + raise TypeError("index must be an integer") + + def get_first_view_by_type(self, view_type): + """ + Get a view from the layout by view type (component) specified by the 'view_type' parameter. + + :param view_type: The view type (str) of the view in the Layout array. + :type view_type: str + + :returns: The view corresponding to the provided view_type. + :rtype: VitessceConfigView or None if not found + """ + if isinstance(view_type, str): + for view in self.config["layout"]: + if view.view["component"].lower() == view_type.lower(): + return view + raise ValueError(f"No view found with component view_type: {view_type}") + else: + raise TypeError("view_type must be a string representing the view type") + + def remove_view_by_index(self, index): + """ + Removes a view from the layout by the index specified by the 'index' parameter. + + :param index: the index (int) of the view + :type index: int + + :returns: The layout component of the config corresponding to the specified index + :rtype: VitessceConfigView or None if not found + + """ + if isinstance(index, int): + if 0 <= index < len(self.config["layout"]): + return self.config["layout"].pop(index) + else: + raise IndexError("Index out of range") + else: + raise TypeError("index must be an integer") + + def remove_first_view_by_type(self, view_type): + """ + Removes a view from the layout by the view type (component) specified by the 'view_type' parameter. + + :param view_by: A component view_type (str). + :type view_by: str + + :returns: The layout component of the config corresponding to the specified view_type + :rtype: VitessceConfigView or None if not found + + """ + if isinstance(view_type, str): + for i, view in enumerate(self.config["layout"]): + if view.view["component"].lower() == view_type.lower(): + return self.config["layout"].pop(i) + raise ValueError(f"No view found with component type: {view_type}") + else: + raise TypeError("view_by must a string representing component type") + def get_routes(self): """ Convert the routes for this view config from the datasets. @@ -1499,6 +1597,18 @@ def get_routes(self): routes += d.get_routes() return routes + def get_artifacts(self): + """ + Get all artifacts for this view config from the datasets. + + :returns: A dict mapping artifact URLs to corresponding artifact objects. + :rtype: dict[str, lamindb.Artifact] + """ + artifacts = {} + for d in self.config["datasets"]: + artifacts.update(d.get_artifacts()) + return artifacts + def get_stores(self, base_url=None): """ Convert the routes for this view config from the datasets. @@ -1606,7 +1716,8 @@ def from_dict(config): file_type=f["fileType"], url=f.get("url"), coordination_values=f.get("coordinationValues"), - options=f.get("options") + options=f.get("options"), + request_init=f.get("requestInit") ) if 'coordinationSpace' in config: for c_type in config['coordinationSpace'].keys(): diff --git a/vitessce/constants.py b/vitessce/constants.py index 5ca75390..2accca3e 100644 --- a/vitessce/constants.py +++ b/vitessce/constants.py @@ -14,7 +14,7 @@ def __new__(cls, value, doc): def norm_enum(enum_val, expected_enum_class=None): - assert isinstance(enum_val, str) or isinstance(enum_val, expected_enum_class) + # assert isinstance(enum_val, str) or isinstance(enum_val, expected_enum_class), f"enum_val was {type(enum_val)} and not a string or expected value {type(expected_enum_class)}" # We don't actually use the expected_enum_class, # since it would not account for things like plugin coordination types, etc. # But we can pass it around anyway and in the future could use @@ -162,6 +162,8 @@ class FileType(DocEnum): An enum type representing the file format or schema to which a file conforms. """ ANNDATA_ZARR = "anndata.zarr", "Joint file type for AnnData objects" + SPATIALDATA_ZARR = "spatialdata.zarr", "Joint file type for SpatialData objects" + ANNDATA_H5AD = "anndata.h5ad", "Joint file type for AnnData objects" OBS_EMBEDDING_CSV = 'obsEmbedding.csv', "File type for obsEmbedding values stored in a CSV file" OBS_LOCATIONS_CSV = 'obsLocations.csv', "File type for obsLocations values stored in a CSV file" OBS_LABELS_CSV = 'obsLabels.csv', "File type for obsLabels values stored in a CSV file" diff --git a/vitessce/data_utils/__init__.py b/vitessce/data_utils/__init__.py index 88077ca1..0b1d4cce 100644 --- a/vitessce/data_utils/__init__.py +++ b/vitessce/data_utils/__init__.py @@ -6,6 +6,7 @@ sort_var_axis, to_diamond, VAR_CHUNK_SIZE, + generate_h5ad_ref_spec, ) from .ome import ( rgb_img_to_ome_zarr, diff --git a/vitessce/data_utils/anndata.py b/vitessce/data_utils/anndata.py index a9743670..648fc85d 100644 --- a/vitessce/data_utils/anndata.py +++ b/vitessce/data_utils/anndata.py @@ -6,6 +6,17 @@ VAR_CHUNK_SIZE = 10 +def generate_h5ad_ref_spec(h5_url, omit_url=True): + from kerchunk.hdf import SingleHdf5ToZarr + h5chunks = SingleHdf5ToZarr(h5_url, inline_threshold=300) + h5dict = h5chunks.translate() + if omit_url: + for key, val in h5dict['refs'].items(): + if isinstance(val, list): + h5dict['refs'][key] = [None, *val[1:]] + return h5dict + + def cast_arr(arr): """ Try to cast an array to a dtype that takes up less space. diff --git a/vitessce/data_utils/ome.py b/vitessce/data_utils/ome.py index 633d48b3..75a0272b 100644 --- a/vitessce/data_utils/ome.py +++ b/vitessce/data_utils/ome.py @@ -5,6 +5,21 @@ from .anndata import cast_arr +def needs_bigtiff(img_arr_shape): + """ + Helper function to determine if an image array is too large for standard TIFF format. + + :param img_arr_shape: The shape of the image array. + :type img_arr_shape: tuple[int] + :return: True if the image array is too large for standard TIFF format, False otherwise. + :rtype: bool + """ + num_pixels = 1 + for n in img_arr_shape.shape: + num_pixels *= n + return (num_pixels > 2**32) + + def rgb_img_to_ome_tiff(img_arr, output_path, img_name="Image", axes="CYX"): """ Convert an RGB image to OME-TIFF. @@ -16,8 +31,9 @@ def rgb_img_to_ome_tiff(img_arr, output_path, img_name="Image", axes="CYX"): :param str axes: The array axis ordering. By default, "CYX" """ img_arr = img_arr.astype(np.dtype('uint8')) + bigtiff = needs_bigtiff(img_arr.shape) - tiff_writer = TiffWriter(output_path, ome=True) + tiff_writer = TiffWriter(output_path, ome=True, bigtiff=bigtiff) tiff_writer.write( img_arr, metadata={ @@ -38,7 +54,9 @@ def multiplex_img_to_ome_tiff(img_arr, channel_names, output_path, axes="CYX"): :param str output_path: The path to save the Zarr store. :param str axes: The array axis ordering. By default, "CYX" """ - tiff_writer = TiffWriter(output_path, ome=True) + bigtiff = needs_bigtiff(img_arr.shape) + + tiff_writer = TiffWriter(output_path, ome=True, bigtiff=bigtiff) tiff_writer.write( img_arr, metadata={ diff --git a/vitessce/file_def_utils.py b/vitessce/file_def_utils.py new file mode 100644 index 00000000..4723bc63 --- /dev/null +++ b/vitessce/file_def_utils.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from functools import partial +from typing import Optional + +import numpy as np + + +def gen_obs_embedding_schema(options: dict, paths: Optional[list[str]] = None, names: Optional[list[str]] = None, dims: Optional[list[list[int]]] = None): + if paths is not None: + if "obsEmbedding" not in options: + options["obsEmbedding"] = [] + if names is not None: + for key, mapping in zip(paths, names): + options["obsEmbedding"].append({ + "path": key, + "dims": [0, 1], + "embeddingType": mapping + }) + else: + for mapping in paths: + mapping_key = mapping.split('/')[-1] + options["obsEmbedding"].append({ + "path": mapping, + "dims": [0, 1], + "embeddingType": mapping_key + }) + if dims is not None: + if "obsEmbedding" not in options: + options["obsEmbedding"] = [] + for dim_i, dim in enumerate(dims): + options["obsEmbedding"][dim_i]['dims'] = dim + return options + + +def gen_obs_sets_schema(options: dict, paths: Optional[list[str]] = None, names: Optional[list[str]] = None): + if paths is not None: + options["obsSets"] = [] + if names is not None: + names = names + else: + names = [] + for obs in paths: + obs_end_path = obs.split('/')[-1] + names += [obs_end_path] + for obs, name in zip(paths, names): + options["obsSets"].append({ + "name": name, + "path": obs + }) + return options + + +def gen_sdata_obs_sets_schema(options: dict, paths: Optional[list[str]] = None, names: Optional[list[str]] = None, table_path: Optional[str] = None, region: Optional[str] = None): + if paths is not None: + options["obsSets"] = {"obsSets": []} + if names is not None: + names = names + else: + names = [] + for obs in paths: + obs_end_path = obs.split('/')[-1] + names += [obs_end_path] + for obs, name in zip(paths, names): + options["obsSets"]["obsSets"].append({ + "name": name, + "path": obs + }) + if table_path is not None: + options["obsSets"]["tablePath"] = table_path + if region is not None: + options["obsSets"]["region"] = region + return options + + +def gen_obs_feature_matrix_schema(options: dict, matrix_path: Optional[str] = None, var_filter_path: Optional[str] = None, init_var_filter_path: Optional[str] = None): + if matrix_path is not None: + options["obsFeatureMatrix"] = { + "path": matrix_path + } + if var_filter_path is not None: + options["obsFeatureMatrix"]["featureFilterPath"] = var_filter_path + if init_var_filter_path is not None: + options["obsFeatureMatrix"]["initialFeatureFilterPath"] = init_var_filter_path + return options + + +def gen_obs_labels_schema(options: dict, paths: Optional[list[str]] = None, names: Optional[list[str]] = None): + if paths is not None: + if names is not None and len(paths) == len(names): + # A name was provided for each path element, so use those values. + names = names + else: + # Names were not provided for each path element, + # so fall back to using the final part of each path for the names. + names = [labels_path.split('/')[-1] for labels_path in paths] + obs_labels = [] + for path, name in zip(paths, names): + obs_labels.append({"path": path, "obsLabelsType": name}) + options["obsLabels"] = obs_labels + return options + + +def gen_path_schema(key: str, path: Optional[str], options: dict): + if path is not None: + options[key] = { + "path": path + } + return options + + +gen_obs_locations_schema = partial(gen_path_schema, "obsLocations") +gen_obs_segmentations_schema = partial(gen_path_schema, "obsSegmentations") +gen_obs_spots_schema = partial(gen_path_schema, "obsSpots") +gen_obs_points_schema = partial(gen_path_schema, "obsPoints") +gen_feature_labels_schema = partial(gen_path_schema, "featureLabels") + + +def gen_sdata_image_schema(options, path: str, coordinate_system: Optional[str] = None, affine_transformation: Optional[np.ndarray] = None) -> dict: + if path is not None: + options["image"] = { + "path": path + } + if affine_transformation is not None: + options["image"]['coordinateTransformations'] = affine_transformation + if coordinate_system is not None: + options["image"]['coordinateSystem'] = coordinate_system + return options + + +def gen_sdata_labels_schema(options, path: str, table_path: str = "tables/table", coordinate_system: Optional[str] = None, affine_transformation: Optional[np.ndarray] = None) -> dict: + if path is not None: + options["labels"] = { + "path": path + } + if table_path is not None: + options["labels"]['tablePath'] = table_path + if affine_transformation is not None: + options["labels"]['coordinateTransformations'] = affine_transformation + if coordinate_system is not None: + options["labels"]['coordinateSystem'] = coordinate_system + return options + + +def gen_sdata_obs_spots_schema(options: dict, shapes_path: str, table_path: str = "tables/table", region: Optional[str] = None, coordinate_system: Optional[str] = None) -> dict: + if shapes_path is not None: + options['obsSpots'] = { + "path": shapes_path, + "tablePath": table_path + } + if region is not None: + options['obsSpots']['region'] = region + if coordinate_system is not None: + options['obsSpots']['coordinateSystem'] = coordinate_system + return options + + +def gen_sdata_obs_feature_matrix_schema(options: dict, matrix_path: Optional[str] = None, var_filter_path: Optional[str] = None, init_var_filter_path: Optional[str] = None, region: Optional[str] = None): + if matrix_path is not None: + options["obsFeatureMatrix"] = { + "path": matrix_path + } + if region is not None: + options['obsFeatureMatrix']['region'] = region + if var_filter_path is not None: + options["obsFeatureMatrix"]["featureFilterPath"] = var_filter_path + if init_var_filter_path is not None: + options["obsFeatureMatrix"]["initialFeatureFilterPath"] = init_var_filter_path + return options diff --git a/vitessce/widget.py b/vitessce/widget.py index 473dc3b9..4b344739 100644 --- a/vitessce/widget.py +++ b/vitessce/widget.py @@ -199,9 +199,10 @@ def get_uid_str(uid): const jsDevMode = view.model.get('js_dev_mode'); const jsPackageVersion = view.model.get('js_package_version'); const customJsUrl = view.model.get('custom_js_url'); - const pluginEsm = view.model.get('plugin_esm'); + const pluginEsmArr = view.model.get('plugin_esm'); const remountOnUidChange = view.model.get('remount_on_uid_change'); const storeUrls = view.model.get('store_urls'); + const invokeTimeout = view.model.get('invoke_timeout'); const pkgName = (jsDevMode ? "@vitessce/dev" : "vitessce"); @@ -218,19 +219,30 @@ def get_uid_str(uid): PluginJointFileType, z, useCoordination, + useGridItemSize, + // TODO: names and function signatures are subject to change for the following functions + // Reference: https://github.com/keller-mark/use-coordination/issues/37#issuecomment-1946226827 + useComplexCoordination, + useMultiCoordinationScopesNonNull, + useMultiCoordinationScopesSecondaryNonNull, + useComplexCoordinationSecondary, + useCoordinationScopes, + useCoordinationScopesBy, } = await importWithMap("vitessce", importMap); - let pluginViewTypes; - let pluginCoordinationTypes; - let pluginFileTypes; - let pluginJointFileTypes; + let pluginViewTypes = []; + let pluginCoordinationTypes = []; + let pluginFileTypes = []; + let pluginJointFileTypes = []; const stores = Object.fromEntries( storeUrls.map(storeUrl => ([ storeUrl, { async get(key) { - const [data, buffers] = await view.experimental.invoke("_zarr_get", [storeUrl, key]); + const [data, buffers] = await view.experimental.invoke("_zarr_get", [storeUrl, key], { + signal: AbortSignal.timeout(invokeTimeout), + }); if (!data.success) return undefined; return buffers[0].buffer; }, @@ -238,26 +250,51 @@ def get_uid_str(uid): ])), ); - try { - const pluginEsmUrl = URL.createObjectURL(new Blob([pluginEsm], { type: "text/javascript" })); - const pluginModule = (await import(pluginEsmUrl)).default; - URL.revokeObjectURL(pluginEsmUrl); - - const pluginsObj = await pluginModule.createPlugins({ - React, - PluginFileType, - PluginViewType, - PluginCoordinationType, - PluginJointFileType, - z, - useCoordination, + function invokePluginCommand(commandName, commandParams, commandBuffers) { + return view.experimental.invoke("_plugin_command", [commandName, commandParams], { + signal: AbortSignal.timeout(invokeTimeout), + ...(commandBuffers ? { buffers: commandBuffers } : {}), }); - pluginViewTypes = pluginsObj.pluginViewTypes; - pluginCoordinationTypes = pluginsObj.pluginCoordinationTypes; - pluginFileTypes = pluginsObj.pluginFileTypes; - pluginJointFileTypes = pluginsObj.pluginJointFileTypes; - } catch(e) { - console.error(e); + } + + for (const pluginEsm of pluginEsmArr) { + try { + const pluginEsmUrl = URL.createObjectURL(new Blob([pluginEsm], { type: "text/javascript" })); + const pluginModule = (await import(pluginEsmUrl)).default; + URL.revokeObjectURL(pluginEsmUrl); + + const pluginsObj = await pluginModule.createPlugins({ + React, + PluginFileType, + PluginViewType, + PluginCoordinationType, + PluginJointFileType, + z, + invokeCommand: invokePluginCommand, + useCoordination, + useGridItemSize, + useComplexCoordination, + useMultiCoordinationScopesNonNull, + useMultiCoordinationScopesSecondaryNonNull, + useComplexCoordinationSecondary, + useCoordinationScopes, + useCoordinationScopesBy, + }); + if(Array.isArray(pluginsObj.pluginViewTypes)) { + pluginViewTypes = [...pluginViewTypes, ...pluginsObj.pluginViewTypes]; + } + if(Array.isArray(pluginsObj.pluginCoordinationTypes)) { + pluginCoordinationTypes = [...pluginCoordinationTypes, ...pluginsObj.pluginCoordinationTypes]; + } + if(Array.isArray(pluginsObj.pluginFileTypes)) { + pluginFileTypes = [...pluginFileTypes, ...pluginsObj.pluginFileTypes]; + } + if(Array.isArray(pluginsObj.pluginJointFileTypes)) { + pluginJointFileTypes = [...pluginJointFileTypes, ...pluginsObj.pluginJointFileTypes]; + } + } catch(e) { + console.error(e); + } } function VitessceWidget(props) { @@ -366,6 +403,7 @@ def get_uid_str(uid): PluginJointFileType, z, useCoordination, + invokeCommand, } = utilsForPlugins; return { pluginViewTypes: undefined, @@ -378,6 +416,25 @@ def get_uid_str(uid): """ +class VitesscePlugin: + """ + A class that represents a Vitessce widget plugin. Custom plugins can be created by subclassing this class. + """ + plugin_esm = DEFAULT_PLUGIN_ESM + commands = {} + + def on_config_change(self, new_config): + """ + Config change handler. + + :param dict new_config: The new config object. + + :returns: config (likely with new "uid" property) or None + :rtype: dict or None + """ + raise NotImplementedError("on_config_change may optionally be implemented by subclasses.") + + class VitessceWidget(anywidget.AnyWidget): """ A class to represent a Jupyter widget for Vitessce. @@ -397,15 +454,16 @@ class VitessceWidget(anywidget.AnyWidget): next_port = DEFAULT_PORT - js_package_version = Unicode('3.3.12').tag(sync=True) + js_package_version = Unicode('3.4.14').tag(sync=True) js_dev_mode = Bool(False).tag(sync=True) custom_js_url = Unicode('').tag(sync=True) - plugin_esm = Unicode(DEFAULT_PLUGIN_ESM).tag(sync=True) + plugin_esm = List(trait=Unicode(''), default_value=[]).tag(sync=True) remount_on_uid_change = Bool(True).tag(sync=True) + invoke_timeout = Int(30000).tag(sync=True) store_urls = List(trait=Unicode(''), default_value=[]).tag(sync=True) - def __init__(self, config, height=600, theme='auto', uid=None, port=None, proxy=False, js_package_version='3.3.12', js_dev_mode=False, custom_js_url='', plugin_esm=DEFAULT_PLUGIN_ESM, remount_on_uid_change=True): + def __init__(self, config, height=600, theme='auto', uid=None, port=None, proxy=False, js_package_version='3.4.14', js_dev_mode=False, custom_js_url='', plugins=None, remount_on_uid_change=True, invoke_timeout=30000): """ Construct a new Vitessce widget. @@ -418,8 +476,9 @@ def __init__(self, config, height=600, theme='auto', uid=None, port=None, proxy= :param str js_package_version: The version of the NPM package ('vitessce' if not js_dev_mode else '@vitessce/dev'). :param bool js_dev_mode: Should @vitessce/dev be used (typically for debugging purposes)? By default, False. :param str custom_js_url: A URL to a JavaScript file to use (instead of 'vitessce' or '@vitessce/dev' NPM package). - :param str plugin_esm: JavaScript module that defines a createPlugins function. Optional. + :param list[WidgetPlugin] plugins: A list of subclasses of VitesscePlugin. Optional. :param bool remount_on_uid_change: Passed to the remountOnUidChange prop of the React component. By default, True. + :param int invoke_timeout: The timeout in milliseconds for invoking Python functions from JavaScript. By default, 30000. .. code-block:: python :emphasize-lines: 4 @@ -439,16 +498,36 @@ def __init__(self, config, height=600, theme='auto', uid=None, port=None, proxy= routes = config.get_routes() self._stores = config.get_stores(base_url=base_url) + self._plugins = plugins or [] + + plugin_esm = [p.plugin_esm for p in self._plugins] + self._plugin_commands = {} + for plugin in self._plugins: + self._plugin_commands.update(plugin.commands) uid_str = get_uid_str(uid) super(VitessceWidget, self).__init__( config=config_dict, height=height, theme=theme, proxy=proxy, js_package_version=js_package_version, js_dev_mode=js_dev_mode, custom_js_url=custom_js_url, - plugin_esm=plugin_esm, remount_on_uid_change=remount_on_uid_change, + plugin_esm=plugin_esm, remount_on_uid_change=remount_on_uid_change, invoke_timeout=invoke_timeout, uid=uid_str, store_urls=list(self._stores.keys()) ) + # Register chained plugin on_config_change functions with a change observer. + def handle_config_change(change): + new_config = change.new + for plugin in self._plugins: + try: + new_config = plugin.on_config_change(new_config) + except NotImplementedError: + # It is optional for plugins to implement on_config_change. + pass + if new_config is not None: + self.config = new_config + + self.observe(handle_config_change, names=['config']) + serve_routes(config, routes, use_port) def _get_coordination_value(self, coordination_type, coordination_scope): @@ -488,10 +567,16 @@ def _zarr_get(self, params, buffers): buffers = [] return {"success": len(buffers) == 1}, buffers + @anywidget.experimental.command + def _plugin_command(self, params, buffers): + [command_name, command_params] = params + command_func = self._plugin_commands[command_name] + return command_func(command_params, buffers) + # Launch Vitessce using plain HTML representation (no ipywidgets) -def ipython_display(config, height=600, theme='auto', base_url=None, host_name=None, uid=None, port=None, proxy=False, js_package_version='3.3.12', js_dev_mode=False, custom_js_url='', plugin_esm=DEFAULT_PLUGIN_ESM, remount_on_uid_change=True): +def ipython_display(config, height=600, theme='auto', base_url=None, host_name=None, uid=None, port=None, proxy=False, js_package_version='3.4.14', js_dev_mode=False, custom_js_url='', plugin_esm=DEFAULT_PLUGIN_ESM, remount_on_uid_change=True): from IPython.display import display, HTML uid_str = "vitessce" + get_uid_str(uid) @@ -508,6 +593,7 @@ def ipython_display(config, height=600, theme='auto', base_url=None, host_name=N "custom_js_url": custom_js_url, "plugin_esm": plugin_esm, "remount_on_uid_change": remount_on_uid_change, + "invoke_timeout": 30000, "proxy": proxy, "has_host_name": host_name is not None, "height": height, diff --git a/vitessce/widget_plugins/__init__.py b/vitessce/widget_plugins/__init__.py new file mode 100644 index 00000000..80c8d659 --- /dev/null +++ b/vitessce/widget_plugins/__init__.py @@ -0,0 +1,2 @@ +from .demo_plugin import DemoPlugin +from .spatial_query import SpatialQueryPlugin diff --git a/vitessce/widget_plugins/demo_plugin.py b/vitessce/widget_plugins/demo_plugin.py new file mode 100644 index 00000000..06737d93 --- /dev/null +++ b/vitessce/widget_plugins/demo_plugin.py @@ -0,0 +1,66 @@ +from esbuild_py import transform +from ..widget import VitesscePlugin + + +PLUGIN_ESM = transform(""" +function createPlugins(utilsForPlugins) { + const { + React, + PluginFileType, + PluginViewType, + PluginCoordinationType, + PluginJointFileType, + z, + useCoordination, + invokeCommand, + } = utilsForPlugins; + function DemoView(props) { + const { coordinationScopes } = props; + const [{ + obsType, + }, { + setObsType, + }] = useCoordination(['obsType'], coordinationScopes); + + function handleClick() { + console.log(invokeCommand('demo_command', "Hello from command", [])); + } + + return ( +
+

Demo plugin view

+

obsType: {obsType}

+ +
+ ); + } + + const pluginViewTypes = [ + new PluginViewType('demo', DemoView, ['obsType']), + ]; + return { pluginViewTypes }; +} +export default { createPlugins }; +""") + + +def handle_demo_command(message, buffers): + return message.upper(), [] + + +class DemoPlugin(VitesscePlugin): + """ + Example of a minimal plugin view that gets the obsType coordination value from the coordination space and renders a button. + This plugin view is not meant to be useful for end-users, but rather to demonstrate how to develop a plugin view that uses coordination (and uses eslint_py for JSX transformation). + + .. code-block:: python + + from vitessce.widget_plugins import DemoPlugin + + # ... + vc.widget(plugins=[DemoPlugin()]) + """ + plugin_esm = PLUGIN_ESM + commands = { + "demo_command": handle_demo_command, + } diff --git a/vitessce/widget_plugins/spatial_query.py b/vitessce/widget_plugins/spatial_query.py new file mode 100644 index 00000000..5ac589f4 --- /dev/null +++ b/vitessce/widget_plugins/spatial_query.py @@ -0,0 +1,324 @@ +from esbuild_py import transform +from ..widget import VitesscePlugin + + +PLUGIN_ESM = transform(""" +function createPlugins(utilsForPlugins) { + const { + React, + PluginFileType, + PluginViewType, + PluginCoordinationType, + PluginJointFileType, + z, + useCoordination, + } = utilsForPlugins; + function SpatialQueryView(props) { + const { coordinationScopes } = props; + const [{ + queryParams, + obsSetSelection, + }, { + setQueryParams, + }] = useCoordination(['queryParams', 'obsSetSelection', 'obsType'], coordinationScopes); + + const [uuid, setUuid] = React.useState(1); + const [queryType, setQueryType] = React.useState('grid'); + const [maxDist, setMaxDist] = React.useState(100); + const [minSize, setMinSize] = React.useState(4); + const [minCount, setMinCount] = React.useState(10); + const [minSupport, setMinSupport] = React.useState(0.5); + + const cellTypeOfInterest = obsSetSelection?.length === 1 && obsSetSelection[0][0] === "Cell Type" + ? obsSetSelection[0][1] + : null; + + const onQueryTypeChange = React.useCallback((e) => { + setQueryType(e.target.value); + }, []); + + return ( +
+

Spatial Query Manager

+ +
+ +
+ +
+ +
+ +
+ {/* TODO: disDuplicates: Distinguish duplicates in patterns. */} + +
+ ); + } + + const pluginCoordinationTypes = [ + new PluginCoordinationType('queryParams', null, z.object({ + cellTypeOfInterest: z.string().nullable(), + queryType: z.enum(['grid', 'rand', 'ct-center']), + maxDist: z.number(), + minSize: z.number(), + minCount: z.number(), + minSupport: z.number(), + disDuplicates: z.boolean(), + uuid: z.number(), + }).partial().nullable()), + ]; + + const pluginViewTypes = [ + new PluginViewType('spatialQuery', SpatialQueryView, ['queryParams', 'obsSetSelection', 'obsType']), + ]; + return { pluginViewTypes, pluginCoordinationTypes }; +} +export default { createPlugins }; +""") + + +class SpatialQueryPlugin(VitesscePlugin): + """ + Spatial-Query plugin view renders controls to change parameters passed to the Spatial-Query methods. + """ + plugin_esm = PLUGIN_ESM + commands = {} + + def __init__(self, adata, spatial_key="X_spatial", label_key="cell_type"): + """ + Construct a new Vitessce widget. + + :param adata: AnnData. + :type adata: anndata.AnnData + :param str spatial_key: The key in adata.obsm that contains the (x, y) coordinates of each cell. By default, "X_spatial". + :param str label_key: The column in adata.obs that contains the cell type labels. By default, "cell_type". + + .. code-block:: python + + from vitessce.widget_plugins import SpatialQueryPlugin + + plugin = SpatialQueryPlugin(adata, spatial_key="X_spatial", label_key="cell_type") + # ... + vc.widget(plugins=[plugin], remount_on_uid_change=False) + """ + from SpatialQuery.spatial_query import spatial_query + import matplotlib.pyplot as plt # Add as dependency / optional dependency? + + self.adata = adata + self.spatial_key = spatial_key + self.label_key = label_key + + self.tt = spatial_query(adata=adata, dataset='test', spatial_key=spatial_key, label_key=label_key, leaf_size=10) + + self.tab20_rgb = [[int(r * 255), int(g * 255), int(b * 255)] for (r, g, b, a) in [plt.cm.tab20(i) for i in range(20)]] + + self.additional_obs_sets = { + "version": "0.1.3", + "tree": [ + { + "name": "Spatial-Query Results", + "children": [ + + ] + } + ] + } + + self.obs_set_color = [ + { + "color": [255, 255, 255], + "path": ["Cell Type"], + }, + { + "color": [255, 255, 255], + "path": ["Spatial-Query Results"], + } + ] + + self.ct_to_color = dict() + + for ct_i, cell_type in enumerate(adata.obs[label_key].unique().tolist()): + color = self.tab20_rgb[ct_i % 20] + self.ct_to_color[cell_type] = color + path = ["Cell Type", cell_type] + self.obs_set_color.append({ + "color": color, + "path": path + }) + + self.cell_i_to_cell_id = dict(zip(range(adata.obs.shape[0]), adata.obs.index.tolist())) + self.cell_id_to_cell_type = dict(zip(adata.obs.index.tolist(), adata.obs[label_key].tolist())) + + def get_matching_cell_ids(self, cell_type, cell_i): + cell_ids = [self.cell_i_to_cell_id[i] for i in cell_i] + matches = [] + for cell_id in cell_ids: + cell_ct = self.cell_id_to_cell_type[cell_id] + if cell_ct == cell_type: + matches.append([cell_id, None]) + return matches + + def fp_tree_to_obs_sets_tree(self, fp_tree, sq_id): + additional_obs_sets = { + "version": "0.1.3", + "tree": [ + { + "name": f"Spatial-Query Results {sq_id}", + "children": [ + + ] + } + ] + } + + obs_set_color = [] + + for row_i, row in fp_tree.iterrows(): + try: + motif = row["itemsets"] + except KeyError: + motif = row["motifs"] + cell_i = row["cell_id"] + + motif_name = str(list(motif)) + + additional_obs_sets["tree"][0]["children"].append({ + "name": motif_name, + "children": [ + { + "name": cell_type, + "set": self.get_matching_cell_ids(cell_type, cell_i) + } + for cell_type in motif + ] + }) + + obs_set_color.append({ + "color": [255, 255, 255], + "path": [additional_obs_sets["tree"][0]["name"], motif_name] + }) + + for cell_type in motif: + color = self.ct_to_color[cell_type] + path = [additional_obs_sets["tree"][0]["name"], motif_name, cell_type] + obs_set_color.append({ + "color": color, + "path": path + }) + return (additional_obs_sets, obs_set_color) + + def run_sq(self, prev_config): + query_params = prev_config["coordinationSpace"]["queryParams"]["A"] + + max_dist = query_params.get("maxDist", 150) + min_size = query_params.get("minSize", 4) + # min_count = query_params.get("minCount", 10) + min_support = query_params.get("minSupport", 0.5) + # dis_duplicates = query_params.get("disDuplicates", False) # if distinguish duplicates of cell types in neighborhood + query_type = query_params.get("queryType", "grid") + cell_type_of_interest = query_params.get("cellTypeOfInterest", None) + + query_uuid = query_params["uuid"] + + params_dict = dict( + max_dist=max_dist, + min_size=min_size, + # min_count=min_count, + min_support=min_support, + # dis_duplicates=dis_duplicates, + if_display=True, + fig_size=(9, 6), + return_cellID=True, + ) + print(params_dict) + + # TODO: add unit tests for this functionality + + if query_type == "rand": + # TODO: implement param similar to return_grid for find_patterns_rand (to return the random points used) + fp_tree = self.tt.find_patterns_rand(**params_dict) + elif query_type == "grid": + params_dict["return_grid"] = True + fp_tree, grid_pos = self.tt.find_patterns_grid(**params_dict) + elif query_type == "ct-center": + fp_tree = self.tt.motif_enrichment_knn( + ct=cell_type_of_interest, + k=20, # TODO: make this a parameter in the UI. + min_support=min_support, + # dis_duplicates=dis_duplicates, + return_cellID=True, + ) + print(fp_tree) + + # TODO: implement query types that are dependent on motif selection. + + # Previous values + additional_obs_sets = prev_config["coordinationSpace"]["additionalObsSets"]["A"] + obs_set_color = prev_config["coordinationSpace"]["obsSetColor"]["A"] + + # Perform query + (new_additional_obs_sets, new_obs_set_color) = self.fp_tree_to_obs_sets_tree(fp_tree, query_uuid) + + additional_obs_sets["tree"][0] = new_additional_obs_sets["tree"][0] + prev_config["coordinationSpace"]["additionalObsSets"]["A"] = additional_obs_sets + + obs_set_color += new_obs_set_color + prev_config["coordinationSpace"]["obsSetColor"]["A"] = obs_set_color + + motif_to_select = new_additional_obs_sets["tree"][0]["children"][0]["name"] + new_obs_set_selection = [[new_additional_obs_sets["tree"][0]["name"], motif_to_select, node["name"]] for node in new_additional_obs_sets["tree"][0]["children"][0]["children"]] + prev_config["coordinationSpace"]["obsSetSelection"]["A"] = new_obs_set_selection + + # TODO: need to fix bug that prevents this from working + # Reference: https://github.com/vitessce/vitessce/blob/774328ab5c4436576dd2e8e4fff0758d6c6cce89/packages/view-types/obs-sets-manager/src/ObsSetsManagerSubscriber.js#L104 + prev_config["coordinationSpace"]["obsSetExpansion"]["A"] = [path[:-1] for path in new_obs_set_selection] + + return {**prev_config, "uid": f"with_query_{query_uuid}"} + + def on_config_change(self, new_config): + query_params = new_config["coordinationSpace"]["queryParams"]["A"] + if query_params and "uuid" in query_params: + print(query_params) + query_uuid = query_params.get("uuid", None) + if new_config["uid"] != f"with_query_{query_uuid}": + return self.run_sq(new_config) + return None diff --git a/vitessce/wrappers.py b/vitessce/wrappers.py index e579e36b..fda7345f 100644 --- a/vitessce/wrappers.py +++ b/vitessce/wrappers.py @@ -1,11 +1,40 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from collections import defaultdict import os from os.path import join import tempfile +from typing import Callable, Optional, Type, TypeVar, Union from uuid import uuid4 from pathlib import PurePath, PurePosixPath import warnings import zarr +import numpy as np +from spatialdata import SpatialData + +if TYPE_CHECKING: + import lamindb as ln + +from vitessce.file_def_utils import ( + gen_obs_locations_schema, + gen_obs_segmentations_schema, + gen_obs_spots_schema, + gen_obs_points_schema, + gen_obs_embedding_schema, + gen_feature_labels_schema, + gen_obs_feature_matrix_schema, + gen_obs_labels_schema, + gen_obs_sets_schema, + gen_sdata_image_schema, + gen_sdata_labels_schema, + gen_sdata_obs_spots_schema, + gen_sdata_obs_sets_schema, + gen_sdata_obs_feature_matrix_schema, +) + from .constants import ( norm_enum, ViewType as cm, @@ -48,6 +77,7 @@ def __init__(self, **kwargs): self.file_def_creators = [] self.base_dir = None self.stores = {} + self.artifacts = {} self._request_init = kwargs['request_init'] if 'request_init' in kwargs else None def __repr__(self): @@ -75,6 +105,28 @@ def get_routes(self): """ return self.routes + def register_artifact(self, artifact): + """ + Register an artifact. + + :param artifact: The artifact object to register. + :type artifact: lamindb.Artifact + :returns: The artifact URL. + :rtype: str + """ + artifact_url = artifact.path.to_url() + self.artifacts[artifact_url] = artifact + return artifact_url + + def get_artifacts(self): + """ + Obtain the dictionary that maps artifact URLs to artifact objects. + + :returns: A dictionary that maps artifact URLs to Artifact objects. + :rtype: dict[str, lamindb.Artifact] + """ + return self.artifacts + def get_stores(self, base_url): """ Obtain the stores that have been created for this wrapper class. @@ -124,10 +176,14 @@ def get_out_dir_route(self, dataset_uid, obj_i): app=StaticFiles(directory=out_dir, html=False))] return [] - def get_local_dir_url(self, base_url, dataset_uid, obj_i, local_dir_path, local_dir_uid): + def get_local_file_url(self, base_url, dataset_uid, obj_i, local_file_path, local_file_uid): if not self.is_remote and self.base_dir is not None: - return self._get_url_simple(base_url, file_path_to_url_path(local_dir_path, prepend_slash=False)) - return self._get_url(base_url, dataset_uid, obj_i, local_dir_uid) + return self._get_url_simple(base_url, file_path_to_url_path(local_file_path, prepend_slash=False)) + return self._get_url(base_url, dataset_uid, obj_i, local_file_uid) + + def get_local_dir_url(self, base_url, dataset_uid, obj_i, local_dir_path, local_dir_uid): + # Logic for files and directories is the same for this function. + return self.get_local_file_url(base_url, dataset_uid, obj_i, local_dir_path, local_dir_uid) def register_zarr_store(self, dataset_uid, obj_i, store_or_local_dir_path, local_dir_uid): if not self.is_remote and self.is_store: @@ -178,6 +234,21 @@ def get_local_dir_route(self, dataset_uid, obj_i, local_dir_path, local_dir_uid) app=StaticFiles(directory=local_dir_path, html=False))] return [] + def get_local_file_route(self, dataset_uid, obj_i, local_file_path, local_file_uid): + if not self.is_remote: + from .routes import range_repsonse, FileRoute + + if self.base_dir is None: + route_path = self._get_route_str(dataset_uid, obj_i, local_file_uid) + else: + route_path = file_path_to_url_path(local_file_path) + local_file_path = join(self.base_dir, local_file_path) + + return [ + FileRoute(route_path, lambda req: range_repsonse(req, local_file_path), local_file_path), + ] + return [] + def _get_url(self, base_url, dataset_uid, obj_i, *args): return base_url + self._get_route_str(dataset_uid, obj_i, *args) @@ -399,21 +470,36 @@ class ImageOmeTiffWrapper(AbstractWrapper): :param \\*\\*kwargs: Keyword arguments inherited from :class:`~vitessce.wrappers.AbstractWrapper` """ - def __init__(self, img_path=None, offsets_path=None, img_url=None, offsets_url=None, coordinate_transformations=None, coordination_values=None, **kwargs): + def __init__(self, img_path=None, img_url=None, img_artifact=None, offsets_path=None, offsets_url=None, offsets_artifact=None, coordinate_transformations=None, coordination_values=None, **kwargs): super().__init__(**kwargs) self._repr = make_repr(locals()) + num_inputs = sum([1 for x in [img_path, img_url, img_artifact] if x is not None]) + if num_inputs != 1: + raise ValueError( + "Expected one of img_path, img_url, or img_artifact to be provided") + + num_inputs = sum([1 for x in [offsets_path, offsets_url, offsets_artifact] if x is not None]) + if num_inputs > 1: + raise ValueError( + "Expected zero or one of offsets_path, offsets_url, or offsets_artifact to be provided") + self._img_path = img_path self._img_url = img_url + self._img_artifact = img_artifact self._offsets_path = offsets_path self._offsets_url = offsets_url + self._offsets_artifact = offsets_artifact self._coordinate_transformations = coordinate_transformations self._coordination_values = coordination_values - self.is_remote = img_url is not None + self.is_remote = img_url is not None or img_artifact is not None self.local_img_uid = make_unique_filename(".ome.tif") self.local_offsets_uid = make_unique_filename(".offsets.json") - if img_url is not None and (img_path is not None or offsets_path is not None): - raise ValueError( - "Did not expect img_path or offsets_path to be provided with img_url") + + if img_artifact is not None: + self._img_url = self.register_artifact(img_artifact) + + if offsets_artifact is not None: + self._offsets_url = self.register_artifact(offsets_artifact) def convert_and_save(self, dataset_uid, obj_i, base_dir=None): # Only create out-directory if needed @@ -503,31 +589,50 @@ class ObsSegmentationsOmeTiffWrapper(AbstractWrapper): Wrap an OME-TIFF File by creating an instance of the ``ObsSegmentationsOmeTiffWrapper`` class. Intended to be used with the spatialBeta and layerControllerBeta views. :param str img_path: A local filepath to an OME-TIFF file. - :param str offsets_path: A local filepath to an offsets.json file. :param str img_url: A remote URL of an OME-TIFF file. + :param img_artifact: A lamindb Artifact corresponding to the image. + :type img_artifact: lamindb.Artifact + :param str offsets_path: A local filepath to an offsets.json file. :param str offsets_url: A remote URL of an offsets.json file. + :param offsets_artifact: A lamindb Artifact corresponding to the offsets JSON. + :type offsets_artifact: lamindb.Artifact :param list coordinate_transformations: A column-major ordered matrix for transforming this image (see http://www.opengl-tutorial.org/beginners-tutorials/tutorial-3-matrices/#homogeneous-coordinates for more information). :param bool obs_types_from_channel_names: Whether to use the channel names to determine the obs types. Optional. :param dict coordination_values: Optional coordinationValues to be passed in the file definition. :param \\*\\*kwargs: Keyword arguments inherited from :class:`~vitessce.wrappers.AbstractWrapper` """ - def __init__(self, img_path=None, offsets_path=None, img_url=None, offsets_url=None, coordinate_transformations=None, obs_types_from_channel_names=None, coordination_values=None, **kwargs): + def __init__(self, img_path=None, img_url=None, img_artifact=None, offsets_path=None, offsets_url=None, offsets_artifact=None, coordinate_transformations=None, obs_types_from_channel_names=None, coordination_values=None, **kwargs): super().__init__(**kwargs) self._repr = make_repr(locals()) + num_inputs = sum([1 for x in [img_path, img_url, img_artifact] if x is not None]) + if num_inputs != 1: + raise ValueError( + "Expected one of img_path, img_url, or img_artifact to be provided") + + num_inputs = sum([1 for x in [offsets_path, offsets_url, offsets_artifact] if x is not None]) + if num_inputs > 1: + raise ValueError( + "Expected zero or one of offsets_path, offsets_url, or offsets_artifact to be provided") + self._img_path = img_path self._img_url = img_url + self._img_artifact = img_artifact self._offsets_path = offsets_path self._offsets_url = offsets_url + self._offsets_artifact = offsets_artifact self._coordinate_transformations = coordinate_transformations self._obs_types_from_channel_names = obs_types_from_channel_names self._coordination_values = coordination_values - self.is_remote = img_url is not None + self.is_remote = img_url is not None or img_artifact is not None self.local_img_uid = make_unique_filename(".ome.tif") self.local_offsets_uid = make_unique_filename(".offsets.json") - if img_url is not None and (img_path is not None or offsets_path is not None): - raise ValueError( - "Did not expect img_path or offsets_path to be provided with img_url") + + if img_artifact is not None: + self._img_url = self.register_artifact(img_artifact) + + if offsets_artifact is not None: + self._offsets_url = self.register_artifact(offsets_artifact) def convert_and_save(self, dataset_uid, obj_i, base_dir=None): # Only create out-directory if needed @@ -791,28 +896,36 @@ class ImageOmeZarrWrapper(AbstractWrapper): :param str img_path: A local filepath to an OME-NGFF Zarr store. :param str img_url: A remote URL of an OME-NGFF Zarr store. + :param img_artifact: A lamindb Artifact corresponding to the image. + :type img_artifact: lamindb.Artifact :param list coordinate_transformations: A column-major ordered matrix for transforming this image (see http://www.opengl-tutorial.org/beginners-tutorials/tutorial-3-matrices/#homogeneous-coordinates for more information). :param dict coordination_values: Optional coordinationValues to be passed in the file definition. :param \\*\\*kwargs: Keyword arguments inherited from :class:`~vitessce.wrappers.AbstractWrapper` """ - def __init__(self, img_path=None, img_url=None, coordinate_transformations=None, coordination_values=None, **kwargs): + def __init__(self, img_path=None, img_url=None, img_artifact=None, coordinate_transformations=None, coordination_values=None, **kwargs): super().__init__(**kwargs) self._repr = make_repr(locals()) - if img_url is not None and img_path is not None: - raise ValueError( - "Did not expect img_path to be provided with img_url") - if img_url is None and img_path is None: + + num_inputs = sum([1 for x in [img_path, img_url, img_artifact] if x is not None]) + if num_inputs != 1: raise ValueError( - "Expected either img_url or img_path to be provided") + "Expected one of img_path, img_url, or img_artifact to be provided") + self._img_path = img_path self._img_url = img_url + self._img_artifact = img_artifact self._coordinate_transformations = coordinate_transformations self._coordination_values = coordination_values if self._img_path is not None: self.is_remote = False else: self.is_remote = True + + if self._img_artifact is not None: + # To serve as a placeholder in the config JSON URL field + self._img_url = self.register_artifact(img_artifact) + self.local_dir_uid = make_unique_filename(".ome.zarr") def convert_and_save(self, dataset_uid, obj_i, base_dir=None): @@ -865,23 +978,25 @@ class ObsSegmentationsOmeZarrWrapper(AbstractWrapper): :param str img_path: A local filepath to an OME-NGFF Zarr store. :param str img_url: A remote URL of an OME-NGFF Zarr store. + :param img_artifact: A lamindb Artifact corresponding to the image. + :type img_artifact: lamindb.Artifact :param list coordinate_transformations: A column-major ordered matrix for transforming this image (see http://www.opengl-tutorial.org/beginners-tutorials/tutorial-3-matrices/#homogeneous-coordinates for more information). :param dict coordination_values: Optional coordinationValues to be passed in the file definition. :param bool obs_types_from_channel_names: Whether to use the channel names to determine the obs types. Optional. :param \\*\\*kwargs: Keyword arguments inherited from :class:`~vitessce.wrappers.AbstractWrapper` """ - def __init__(self, img_path=None, img_url=None, coordinate_transformations=None, coordination_values=None, obs_types_from_channel_names=None, **kwargs): + def __init__(self, img_path=None, img_url=None, img_artifact=None, coordinate_transformations=None, coordination_values=None, obs_types_from_channel_names=None, **kwargs): super().__init__(**kwargs) self._repr = make_repr(locals()) - if img_url is not None and img_path is not None: - raise ValueError( - "Did not expect img_path to be provided with img_url") - if img_url is None and img_path is None: + + num_inputs = sum([1 for x in [img_path, img_url, img_artifact] if x is not None]) + if num_inputs != 1: raise ValueError( - "Expected either img_url or img_path to be provided") + "Expected one of img_path, img_url, or img_artifact to be provided") self._img_path = img_path self._img_url = img_url + self._img_artifact = img_artifact self._coordinate_transformations = coordinate_transformations self._obs_types_from_channel_names = obs_types_from_channel_names self._coordination_values = coordination_values @@ -889,6 +1004,11 @@ def __init__(self, img_path=None, img_url=None, coordinate_transformations=None, self.is_remote = False else: self.is_remote = True + + if self._img_artifact is not None: + # To serve as a placeholder in the config JSON URL field + self._img_url = self.register_artifact(img_artifact) + self.local_dir_uid = make_unique_filename(".ome.zarr") def convert_and_save(self, dataset_uid, obj_i, base_dir=None): @@ -937,15 +1057,48 @@ def image_file_def_creator(base_url): return image_file_def_creator +def raise_error_if_zero_or_more_than_one(inputs): + num_inputs = sum([1 for x in inputs if x is not None]) + if num_inputs > 1: + raise ValueError( + "Expected only one type of data input parameter to be provided (_url, _path, _store, etc.), but received more than one." + ) + if num_inputs == 0: + raise ValueError( + "Expected one type of data input parameter to be provided (_url, _path, _store, etc.), but received none." + ) + return True + + +def raise_error_if_any(inputs): + num_inputs = sum([1 for x in inputs if x is not None]) + if num_inputs > 0: + raise ValueError( + "Did not expect any of these parameters to be provided, but received one or more: " + str(inputs) + ) + return True + + +def raise_error_if_more_than_one(inputs): + num_inputs = sum([1 for x in inputs if x is not None]) + if num_inputs > 1: + raise ValueError( + "Expected only one of these parameters to be provided, but received more than one: " + str(inputs) + ) + return True + + class AnnDataWrapper(AbstractWrapper): - def __init__(self, adata_path=None, adata_url=None, adata_store=None, obs_feature_matrix_path=None, feature_filter_path=None, initial_feature_filter_path=None, obs_set_paths=None, obs_set_names=None, obs_locations_path=None, obs_segmentations_path=None, obs_embedding_paths=None, obs_embedding_names=None, obs_embedding_dims=None, obs_spots_path=None, obs_points_path=None, feature_labels_path=None, obs_labels_path=None, convert_to_dense=True, coordination_values=None, obs_labels_paths=None, obs_labels_names=None, **kwargs): + def __init__(self, adata_path=None, adata_url=None, adata_store=None, adata_artifact=None, ref_path=None, ref_url=None, ref_artifact=None, obs_feature_matrix_path=None, feature_filter_path=None, initial_feature_filter_path=None, obs_set_paths=None, obs_set_names=None, obs_locations_path=None, obs_segmentations_path=None, obs_embedding_paths=None, obs_embedding_names=None, obs_embedding_dims=None, obs_spots_path=None, obs_points_path=None, feature_labels_path=None, obs_labels_path=None, convert_to_dense=True, coordination_values=None, obs_labels_paths=None, obs_labels_names=None, **kwargs): """ Wrap an AnnData object by creating an instance of the ``AnnDataWrapper`` class. :param str adata_path: A path to an AnnData object written to a Zarr store containing single-cell experiment data. :param str adata_url: A remote url pointing to a zarr-backed AnnData store. - :param adata_store: A path to pass to zarr.FSStore, or an existing store instance. + :param adata_store: A path to pass to zarr.DirectoryStore, or an existing store instance. :type adata_store: str or zarr.Storage + :param adata_artifact: A lamindb Artifact corresponding to the AnnData object. + :type adata_artifact: lamindb.Artifact :param str obs_feature_matrix_path: Location of the expression (cell x gene) matrix, like `X` or `obsm/highly_variable_genes_subset` :param str feature_filter_path: A string like `var/highly_variable` used in conjunction with `obs_feature_matrix_path` if obs_feature_matrix_path points to a subset of `X` of the full `var` list. :param str initial_feature_filter_path: A string like `var/highly_variable` used in conjunction with `obs_feature_matrix_path` if obs_feature_matrix_path points to a subset of `X` of the full `var` list. @@ -969,26 +1122,42 @@ def __init__(self, adata_path=None, adata_url=None, adata_store=None, obs_featur """ super().__init__(**kwargs) self._repr = make_repr(locals()) - self._path = adata_path - self._url = adata_url - self._store = adata_store + self._adata_path = adata_path + self._adata_url = adata_url + self._adata_store = adata_store + self._adata_artifact = adata_artifact + + # For reference spec JSON with .h5ad files + self._ref_path = ref_path + self._ref_url = ref_url + self._ref_artifact = ref_artifact + + if ref_path is not None or ref_url is not None or ref_artifact is not None: + self.is_h5ad = True + else: + self.is_h5ad = False - num_inputs = sum([1 for x in [adata_path, adata_url, adata_store] if x is not None]) - if num_inputs > 1: + if adata_store is not None and (ref_path is not None or ref_url is not None or ref_artifact is not None): raise ValueError( - "Expected only one of adata_path, adata_url, or adata_store to be provided") - if num_inputs == 0: - raise ValueError( - "Expected one of adata_path, adata_url, or adata_store to be provided") + "Did not expect reference JSON to be provided with adata_store") + + raise_error_if_zero_or_more_than_one([adata_path, adata_url, adata_store, adata_artifact]) if adata_path is not None: self.is_remote = False self.is_store = False self.zarr_folder = 'anndata.zarr' - elif adata_url is not None: + elif adata_url is not None or adata_artifact is not None: self.is_remote = True self.is_store = False self.zarr_folder = None + + # Store artifacts on AbstractWrapper.artifacts for downstream access, + # e.g. in lamindb.save_vitessce_config + if adata_artifact is not None: + self._adata_url = self.register_artifact(adata_artifact) + if ref_artifact is not None: + self._ref_url = self.register_artifact(ref_artifact) else: # Store case self.is_remote = False @@ -996,6 +1165,9 @@ def __init__(self, adata_path=None, adata_url=None, adata_store=None, obs_featur self.zarr_folder = None self.local_dir_uid = make_unique_filename(".adata.zarr") + self.local_file_uid = make_unique_filename(".h5ad") + self.local_ref_uid = make_unique_filename(".ref.json") + self._expression_matrix = obs_feature_matrix_path self._obs_set_names = obs_set_names self._mappings_obsm_names = obs_embedding_names @@ -1036,96 +1208,55 @@ def make_routes(self, dataset_uid, obj_i): if self.is_remote: return [] elif self.is_store: - self.register_zarr_store(dataset_uid, obj_i, self._store, self.local_dir_uid) + self.register_zarr_store(dataset_uid, obj_i, self._adata_store, self.local_dir_uid) return [] else: - return self.get_local_dir_route(dataset_uid, obj_i, self._path, self.local_dir_uid) + if self.is_h5ad: + return [ + *self.get_local_file_route(dataset_uid, obj_i, self._adata_path, self.local_file_uid), + *self.get_local_file_route(dataset_uid, obj_i, self._ref_path, self.local_ref_uid) + ] + else: + return self.get_local_dir_route(dataset_uid, obj_i, self._adata_path, self.local_dir_uid) def get_zarr_url(self, base_url="", dataset_uid="", obj_i=""): if self.is_remote: - return self._url + return self._adata_url + else: + return self.get_local_dir_url(base_url, dataset_uid, obj_i, self._adata_path, self.local_dir_uid) + + def get_h5ad_url(self, base_url="", dataset_uid="", obj_i=""): + if self.is_remote: + return self._adata_url else: - return self.get_local_dir_url(base_url, dataset_uid, obj_i, self._path, self.local_dir_uid) + return self.get_local_file_url(base_url, dataset_uid, obj_i, self._adata_path, self.local_file_uid) + + def get_ref_url(self, base_url="", dataset_uid="", obj_i=""): + if self.is_remote: + return self._ref_url + else: + return self.get_local_file_url(base_url, dataset_uid, obj_i, self._ref_path, self.local_ref_uid) def make_file_def_creator(self, dataset_uid, obj_i): def get_anndata_zarr(base_url): options = {} - if self._spatial_centroid_obsm is not None: - options["obsLocations"] = { - "path": self._spatial_centroid_obsm - } - if self._spatial_polygon_obsm is not None: - options["obsSegmentations"] = { - "path": self._spatial_polygon_obsm - } - if self._spatial_spots_obsm is not None: - options["obsSpots"] = { - "path": self._spatial_spots_obsm - } - if self._spatial_points_obsm is not None: - options["obsPoints"] = { - "path": self._spatial_points_obsm - } - if self._mappings_obsm is not None: - options["obsEmbedding"] = [] - if self._mappings_obsm_names is not None: - for key, mapping in zip(self._mappings_obsm_names, self._mappings_obsm): - options["obsEmbedding"].append({ - "path": mapping, - "dims": [0, 1], - "embeddingType": key - }) - else: - for mapping in self._mappings_obsm: - mapping_key = mapping.split('/')[-1] - self._mappings_obsm_names = mapping_key - options["obsEmbedding"].append({ - "path": mapping, - "dims": [0, 1], - "embeddingType": mapping_key - }) - if self._mappings_obsm_dims is not None: - for dim_i, dim in enumerate(self._mappings_obsm_dims): - options["obsEmbedding"][dim_i]['dims'] = dim - if self._obs_set_elems is not None: - options["obsSets"] = [] - if self._obs_set_names is not None: - names = self._obs_set_names - else: - names = [obs.split('/')[-1] for obs in self._obs_set_elems] - for obs, name in zip(self._obs_set_elems, names): - options["obsSets"].append({ - "name": name, - "path": obs - }) - if self._expression_matrix is not None: - options["obsFeatureMatrix"] = { - "path": self._expression_matrix - } - if self._gene_var_filter is not None: - options["obsFeatureMatrix"]["featureFilterPath"] = self._gene_var_filter - if self._matrix_gene_var_filter is not None: - options["obsFeatureMatrix"]["initialFeatureFilterPath"] = self._matrix_gene_var_filter - if self._feature_labels is not None: - options["featureLabels"] = { - "path": self._feature_labels - } - if self._obs_labels_elems is not None: - if self._obs_labels_names is not None and len(self._obs_labels_elems) == len(self._obs_labels_names): - # A name was provided for each path element, so use those values. - names = self._obs_labels_names - else: - # Names were not provided for each path element, - # so fall back to using the final part of each path for the names. - names = [labels_path.split('/')[-1] for labels_path in self._obs_labels_elems] - obs_labels = [] - for path, name in zip(self._obs_labels_elems, names): - obs_labels.append({"path": path, "obsLabelsType": name}) - options["obsLabels"] = obs_labels + options = gen_obs_locations_schema(self._spatial_centroid_obsm, options) + options = gen_obs_segmentations_schema(self._spatial_polygon_obsm, options) + options = gen_obs_spots_schema(self._spatial_spots_obsm, options) + options = gen_obs_points_schema(self._spatial_points_obsm, options) + options = gen_obs_embedding_schema(options, self._mappings_obsm, self._mappings_obsm_names, self._mappings_obsm_dims) + options = gen_obs_sets_schema(options, self._obs_set_elems, self._obs_set_names,) + options = gen_obs_feature_matrix_schema(options, self._expression_matrix, self._gene_var_filter, self._matrix_gene_var_filter) + options = gen_feature_labels_schema(self._feature_labels, options) + options = gen_obs_labels_schema(options, self._obs_labels_elems, self._obs_labels_names) + if len(options.keys()) > 0: + if self.is_h5ad: + options["refSpecUrl"] = self.get_ref_url(base_url, dataset_uid, obj_i) + obj_file_def = { - "fileType": ft.ANNDATA_ZARR.value, - "url": self.get_zarr_url(base_url, dataset_uid, obj_i), + "fileType": ft.ANNDATA_ZARR.value if not self.is_h5ad else ft.ANNDATA_H5AD.value, + "url": self.get_zarr_url(base_url, dataset_uid, obj_i) if not self.is_h5ad else self.get_h5ad_url(base_url, dataset_uid, obj_i), "options": options } if self._request_init is not None: @@ -1154,6 +1285,158 @@ def auto_view_config(self, vc): / heatmap) +SpatialDataWrapperType = TypeVar('SpatialDataWrapperType', bound='SpatialDataWrapper') + + +class SpatialDataWrapper(AnnDataWrapper): + + def __init__(self, sdata_path: Optional[str] = None, sdata_url: Optional[str] = None, sdata_store: Optional[Union[str, zarr.storage.StoreLike]] = None, sdata_artifact: Optional[ln.Artifact] = None, image_path: Optional[str] = None, region: Optional[str] = None, coordinate_system: Optional[str] = None, affine_transformation: Optional[np.ndarray] = None, obs_spots_path: Optional[str] = None, labels_path: Optional[str] = None, table_path: str = "tables/table", **kwargs): + """ + Wrap a SpatialData object. + + :param sdata_path: SpatialData path, exclusive with other `{sdata,adata}_xxxx` arguments, by default None + :type sdata_path: Optional[str] + :param sdata_url: SpatialData url, exclusive with other `{sdata,adata}_xxxx` arguments, by default None + :type sdata_url: Optional[str] + :param sdata_store: SpatialData store, exclusive with other `{spatialdata,adata}_xxxx` arguments, by default None + :type sdata_store: Optional[Union[str, zarr.storage.StoreLike]] + :param sdata_artifact: Artifact that corresponds to a SpatialData object. + :type sdata_artifact: Optional[ln.Artifact] + :param image_path: Path to the image element of interest. By default, None. + :type image_path: Optional[str] + :param coordinate_system: Name of a target coordinate system. + :type coordinate_system: Optional[str] + :param affine_transformation: Transformation to be applied to the image. By default, None. Prefer coordinate_system. + :type affine_transformation: Optional[np.ndarray] + :param obs_spots_path: Location of shapes that should be interpreted as spot observations, by default None + :type obs_spots_path: Optional[str] + :param labels_path: Location of the labels (segmentation bitmask image), by default None + :type labels_path: Optional[str] + """ + raise_error_if_zero_or_more_than_one([ + sdata_path, + sdata_url, + sdata_store, + sdata_artifact, + ]) + raise_error_if_any([ + kwargs.get('adata_path', None), + kwargs.get('adata_url', None), + kwargs.get('adata_store', None), + kwargs.get('adata_artifact', None) + ]) + super().__init__(adata_path=sdata_path, adata_url=sdata_url, adata_store=sdata_store, adata_artifact=sdata_artifact, **kwargs) + self.local_dir_uid = make_unique_filename(".sdata.zarr") + self._image_path = image_path + self._region = region + self._coordinate_system = coordinate_system + self._affine_transformation = affine_transformation + self._kwargs = kwargs + self._obs_spots_path = obs_spots_path + self._labels_path = labels_path + if self._adata_path is not None: + self.zarr_folder = 'spatialdata.zarr' + self.obs_type_label = None + if self._coordination_values is not None and "obsType" in self._coordination_values: + self.obs_type_label = self._coordination_values["obsType"] + self._table_path = table_path + + @classmethod + def from_object(cls: Type[SpatialDataWrapperType], sdata: SpatialData, table_keys_to_image_elems: dict[str, Union[str, None]] = defaultdict(type(None)), table_keys_to_regions: dict[str, Union[str, None]] = defaultdict(type(None)), obs_type_label: str = "spot") -> list[SpatialDataWrapperType]: + """Instantiate a wrapper for SpatialData stores, one per table, directly from the SpatialData object. + By default, we "show everything" that can reasonable be inferred given the information. If you wish to have more control, + consider instantiating the object directly. This function will error if something cannot be inferred i.e., the user does not present + regions explicitly but there is more than one for a given table. + + + Parameters + ---------- + cls : Type[SpatialDataWrapperType] + _description_ + spatialdata : SpatialData + _description_ + table_keys_to_image_elems : dict[str, str], optional + which image paths to use for a given table for the visualization, by default None for each table key. + table_keys_to_regions : dict[str, str], optional + which regions to use for a given table for the visualization, by default None for each table key. + + Returns + ------- + list[SpatialDataWrapperType] + + Raises + ------ + ValueError + """ + wrappers = [] + parent_table_key = "table" if (sdata.path / "table").exists() else "tables" + for table_key, table in sdata.tables.items(): + spot_shapes_elem = None + image_elem = table_keys_to_image_elems[table_key] + labels_elem = None + spatialdata_attr = table.uns['spatialdata_attrs'] + region = table_keys_to_regions[table_key] + if region is not None: + assert region in spatialdata_attr['region'] + else: + region = spatialdata_attr['region'] + if isinstance(region, list): + if len(region) > 1: + raise ValueError("Vitessce cannot subset AnnData objects on the fly. Please provide an explicit region") + region = region[0] + if region in sdata.shapes: + spot_shapes_elem = f"shapes/{region}" + # Currently, only circle shapes are supported. + # TODO: add if statement to check that this region contains spot shapes rather than other types of shapes + if region in sdata.labels: + labels_elem = f"labels/{region}" + obs_feature_matrix_elem = f"{parent_table_key}/{table_key}/X" + if 'highly_variable' in table.var: + # TODO: fix first key needing to be "table" in vitessce-js + initial_feature_filter_elem = 'highly_variable' + else: + initial_feature_filter_elem = None + obs_set_elems = [f"{parent_table_key}/{table_key}/obs/{elem}" for elem in table.obs if table.obs[elem].dtype == 'category'] + wrappers += [ + cls( + sdata_path=str(sdata.path), + image_path=str(image_elem) if image_elem is not None else None, + labels_path=str(labels_elem) if labels_elem is not None else None, + obs_feature_matrix_path=str(obs_feature_matrix_elem), + obs_spots_path=str(spot_shapes_elem) if spot_shapes_elem is not None else None, + initial_feature_filter_path=initial_feature_filter_elem, + obs_set_paths=obs_set_elems, + coordination_values={"obsType": "spot"} # TODO: should we remove? + ) + ] + return wrappers + + def make_file_def_creator(self, dataset_uid: str, obj_i: str) -> Optional[Callable]: + def generator(base_url): + options = {} + options = gen_obs_labels_schema(options, self._obs_labels_elems, self._obs_labels_names) + options = gen_sdata_obs_feature_matrix_schema(options, self._expression_matrix, self._gene_var_filter, self._matrix_gene_var_filter, self._region) + options = gen_sdata_obs_sets_schema(options, self._obs_set_elems, self._obs_set_names, self._table_path, self._region) + options = gen_sdata_obs_spots_schema(options, self._obs_spots_path, self._table_path, self._region, self._coordinate_system) + options = gen_sdata_image_schema(options, self._image_path, self._coordinate_system, self._affine_transformation) + options = gen_sdata_labels_schema(options, self._labels_path, self._table_path, self._coordinate_system, self._affine_transformation) + options = gen_feature_labels_schema(self._feature_labels, options) + if len(options.keys()) > 0: + obj_file_def = { + "fileType": ft.SPATIALDATA_ZARR.value, + "url": self.get_zarr_url(base_url, dataset_uid, obj_i), + "options": options + } + if self._request_init is not None: + obj_file_def['requestInit'] = self._request_init + if self._coordination_values is not None: + obj_file_def['coordinationValues'] = self._coordination_values + return obj_file_def + return None + + return generator + + class MultivecZarrWrapper(AbstractWrapper): def __init__(self, zarr_path=None, zarr_url=None, **kwargs):