diff --git a/docs/notebooks/widget_neuroglancer_precomputed.ipynb b/docs/notebooks/widget_neuroglancer_precomputed.ipynb new file mode 100644 index 00000000..80aacdfd --- /dev/null +++ b/docs/notebooks/widget_neuroglancer_precomputed.ipynb @@ -0,0 +1,541 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "08d489b0", + "metadata": {}, + "source": [ + "# Vitessce Widget Tutorial" + ] + }, + { + "cell_type": "markdown", + "id": "d6dda7d0", + "metadata": {}, + "source": [ + "# Example usage of Neuroglancer precomputed segmentations and annotations\n", + "\n", + "This notebook demonstrates `ObsSegmentationsNgPrecomputedWrapper` and `ObsPointsNgAnnotationsWrapper`, which wrap Neuroglancer precomputed segmentation/mesh data and point-annotation data (e.g. as produced by the [tissue-map-tools](https://github.com/hms-dbmi/tissue-map-tools) library) for use with the `neuroglancer` and `layerControllerBeta` views.\n", + "\n", + "The `obsSets`-type file (and its corresponding view) is required for the segmentation layer to actually resolve and display any segments -- without it, segments will not be selected/colored dynamically. A static `segments` list can alternatively be passed via `options` for quick testing without a full `obsSets` pipeline." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "6d673a85", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "from vitessce import (\n", + " VitessceConfig,\n", + " CoordinationLevel as CL,\n", + " get_initial_coordination_scope_prefix,\n", + " ObsSegmentationsNgPrecomputedWrapper,\n", + " CsvWrapper,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "8c5bcdc5", + "metadata": {}, + "source": [ + "## 1. Configure Vitessce" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "ac0fb259", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "vc = VitessceConfig(schema_version=\"1.0.17\", name=\"Neuroglancer precomputed example\")\n", + "dataset = vc.add_dataset(\"Melanoma\")\n", + "\n", + "# A Neuroglancer precomputed segmentation meshes directory.\n", + "# fileUid here must match the value used below in link_views_by_dict's\n", + "# segmentationLayer coordination.\n", + "dataset.add_object(ObsSegmentationsNgPrecomputedWrapper(\n", + " data_url=\"https://data-2.vitessce.io/data/sorger/melanoma_meshes\",\n", + " coordination_values={\"fileUid\": \"segmentation\"},\n", + "))\n", + "\n", + "# An obsSets-type file is required for segments to be dynamically\n", + "# selected/colored -- obsType here must match the segmentationChannel's\n", + "# obsType coordination value set below.\n", + "dataset.add_object(CsvWrapper(\n", + " csv_url=\"https://storage.googleapis.com/vitessce-demo-data/neuroglancer-march-2025/melanoma_with_embedding_filtered_ids.csv\",\n", + " data_type=\"obsSets\",\n", + " coordination_values={\"obsType\": \"cell\"},\n", + " options={\n", + " \"obsIndex\": \"id\",\n", + " \"obsSets\": [{\"name\": \"Clusters\", \"column\": \"cluster\"}],\n", + " },\n", + "))" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "6babd185", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ng_view = vc.add_view(\"neuroglancer\", dataset=dataset)\n", + "lc_view = vc.add_view(\"layerControllerBeta\", dataset=dataset)\n", + "# TODO: until support to load the segments is added in NG-View\n", + "# The obsSets view is not required for the segmentation to load, but\n", + "# a mounted obsSets view is needed to trigger the underlying data hook \n", + "# that resolves obsSets data for the segmentation channel.\n", + "obs_sets_view = vc.add_view(\"obsSets\", dataset=dataset)\n", + "vc.layout(ng_view | (lc_view / obs_sets_view))" + ] + }, + { + "cell_type": "markdown", + "id": "c8aaa012", + "metadata": {}, + "source": [ + "## 2. Coordinate the views\n", + "\n", + "Two separate `link_views_by_dict` calls are needed:\n", + "- A plain (non-meta) link for shared spatial rendering mode and camera position/rotation.\n", + "- A multi-level (meta) link for the segmentation layer + channel, mirroring the shape\n", + " `obsSegmentations.ng-precomputed` files require to resolve correctly." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "648ca751", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "vc.link_views_by_dict([ng_view, lc_view], {\n", + " \"spatialRenderingMode\": \"3D\",\n", + " \"spatialZoom\": 0,\n", + " \"spatialTargetX\": 0,\n", + " \"spatialTargetY\": 0,\n", + " \"spatialTargetZ\": 0,\n", + " \"spatialRotationX\": 0,\n", + " \"spatialRotationY\": 0,\n", + " \"spatialRotationOrbit\": 0,\n", + "}, meta=False)\n", + "\n", + "vc.link_views_by_dict([ng_view, lc_view], {\n", + " \"segmentationLayer\": CL([{\n", + " \"fileUid\": \"segmentation\",\n", + " \"spatialLayerOpacity\": 1,\n", + " \"spatialTargetResolution\": None,\n", + " \"spatialLayerVisible\": True,\n", + " \"segmentationChannel\": CL([{\"obsType\": \"cell\", \"spatialChannelVisible\": True}]),\n", + " }]),\n", + "}, scope_prefix=get_initial_coordination_scope_prefix(\"A\", \"obsSegmentations\"))" + ] + }, + { + "cell_type": "markdown", + "id": "10210740", + "metadata": {}, + "source": [ + "## 3. Create the Vitessce widget" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "f562bfc8", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "dbd6e9854d6442ab80ba290784996dda", + "version_major": 2, + "version_minor": 1 + }, + "text/plain": [ + "" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "vw = vc.widget(custom_js_url=\"http://localhost:9001/packages/main/dev/dist/index.js\")\n", + "vw" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "be0c7d7e-e466-45c2-8b65-503501d6faf9", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8fbd9181-2d33-4018-be02-722161342c5c", + "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.12.13" + }, + "widgets": { + "application/vnd.jupyter.widget-state+json": { + "state": { + "7944f55009c64754a407dfff3408ca92": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "2.0.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "2.0.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "2.0.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border_bottom": null, + "border_left": null, + "border_right": null, + "border_top": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "c9218e280b5e411aacd04560cbb1ea89": { + "model_module": "anywidget", + "model_module_version": "~0.11.*", + "model_name": "AnyModel", + "state": { + "_anywidget_id": "vitessce.widget.VitessceWidget", + "_config": { + "coordinationSpace": { + "dataset": { + "A": "A" + }, + "fileUid": { + "init_A_obsSegmentations_0": "segmentation" + }, + "metaCoordinationScopes": { + "init_A_obsSegmentations_0": { + "segmentationLayer": [ + "init_A_obsSegmentations_0" + ] + } + }, + "metaCoordinationScopesBy": { + "init_A_obsSegmentations_0": { + "segmentationChannel": { + "obsType": { + "init_A_obsSegmentations_0": "init_A_obsSegmentations_0" + }, + "spatialChannelVisible": { + "init_A_obsSegmentations_0": "init_A_obsSegmentations_0" + } + }, + "segmentationLayer": { + "fileUid": { + "init_A_obsSegmentations_0": "init_A_obsSegmentations_0" + }, + "segmentationChannel": { + "init_A_obsSegmentations_0": [ + "init_A_obsSegmentations_0" + ] + }, + "spatialLayerOpacity": { + "init_A_obsSegmentations_0": "init_A_obsSegmentations_0" + }, + "spatialLayerVisible": { + "init_A_obsSegmentations_0": "init_A_obsSegmentations_0" + }, + "spatialTargetResolution": { + "init_A_obsSegmentations_0": "init_A_obsSegmentations_0" + } + } + } + }, + "obsType": { + "init_A_obsSegmentations_0": "cell" + }, + "segmentationChannel": { + "init_A_obsSegmentations_0": "__dummy__" + }, + "segmentationLayer": { + "init_A_obsSegmentations_0": "__dummy__" + }, + "spatialChannelVisible": { + "init_A_obsSegmentations_0": true + }, + "spatialLayerOpacity": { + "init_A_obsSegmentations_0": 1 + }, + "spatialLayerVisible": { + "init_A_obsSegmentations_0": true + }, + "spatialRenderingMode": { + "A": "3D" + }, + "spatialRotationOrbit": { + "A": 0 + }, + "spatialRotationX": { + "A": 0 + }, + "spatialRotationY": { + "A": 0 + }, + "spatialTargetResolution": { + "init_A_obsSegmentations_0": null + }, + "spatialTargetX": { + "A": 0 + }, + "spatialTargetY": { + "A": 0 + }, + "spatialTargetZ": { + "A": 0 + }, + "spatialZoom": { + "A": 0 + } + }, + "datasets": [ + { + "files": [ + { + "coordinationValues": { + "fileUid": "segmentation" + }, + "fileType": "obsSegmentations.ng-precomputed", + "url": "https://data-2.vitessce.io/data/sorger/melanoma_meshes" + }, + { + "coordinationValues": { + "obsType": "cell" + }, + "fileType": "obsSets.csv", + "options": { + "obsIndex": "id", + "obsSets": [ + { + "column": "cluster", + "name": "Clusters" + } + ] + }, + "url": "https://storage.googleapis.com/vitessce-demo-data/neuroglancer-march-2025/melanoma_with_embedding_filtered_ids.csv" + } + ], + "name": "Melanoma", + "uid": "A" + } + ], + "description": "", + "initStrategy": "auto", + "layout": [ + { + "component": "neuroglancer", + "coordinationScopes": { + "dataset": "A", + "metaCoordinationScopes": [ + "init_A_obsSegmentations_0" + ], + "metaCoordinationScopesBy": [ + "init_A_obsSegmentations_0" + ], + "spatialRenderingMode": "A", + "spatialRotationOrbit": "A", + "spatialRotationX": "A", + "spatialRotationY": "A", + "spatialTargetX": "A", + "spatialTargetY": "A", + "spatialTargetZ": "A", + "spatialZoom": "A" + }, + "coordinationScopesBy": {}, + "h": 12, + "w": 3, + "x": 0, + "y": 0 + }, + { + "component": "layerControllerBeta", + "coordinationScopes": { + "dataset": "A", + "metaCoordinationScopes": [ + "init_A_obsSegmentations_0" + ], + "metaCoordinationScopesBy": [ + "init_A_obsSegmentations_0" + ], + "spatialRenderingMode": "A", + "spatialRotationOrbit": "A", + "spatialRotationX": "A", + "spatialRotationY": "A", + "spatialTargetX": "A", + "spatialTargetY": "A", + "spatialTargetZ": "A", + "spatialZoom": "A" + }, + "coordinationScopesBy": {}, + "h": 12, + "w": 3, + "x": 3, + "y": 0 + }, + { + "component": "obsSets", + "coordinationScopes": { + "dataset": "A" + }, + "h": 12, + "w": 6, + "x": 6, + "y": 0 + } + ], + "name": "Neuroglancer precomputed example", + "version": "1.0.17" + }, + "_dom_classes": [], + "_esm": "\nlet importWithMap;\ntry {\n importWithMap = (await import('https://unpkg.com/dynamic-importmap@0.1.0')).importWithMap;\n} catch(e) {\n console.warn(\"Import of dynamic-importmap failed, trying fallback.\");\n importWithMap = (await import('https://cdn.vitessce.io/dynamic-importmap@0.1.0/dist/index.js')).importWithMap;\n}\n\nconst successfulImportMap = {\n imports: {\n\n },\n};\nconst importMap = {\n imports: {\n \"react\": \"https://esm.sh/react@18.2.0?dev\",\n \"react-dom\": \"https://esm.sh/react-dom@18.2.0?dev\",\n \"react-dom/client\": \"https://esm.sh/react-dom@18.2.0/client?dev\",\n },\n};\nconst fallbackImportMap = {\n imports: {\n \"react\": \"https://cdn.vitessce.io/react@18.2.0/index.js\",\n \"react-dom\": \"https://cdn.vitessce.io/react-dom@18.2.0/index.js\",\n \"react-dom/client\": \"https://cdn.vitessce.io/react-dom@18.2.0/es2022/client.mjs\",\n // Replaced with version-specific URL below.\n \"vitessce\": \"https://cdn.vitessce.io/vitessce@VERSION/dist/index.min.js\",\n },\n};\n/*\nconst fallbackDevImportMap = {\n imports: {\n \"react\": \"https://cdn.vitessce.io/react@18.2.0/index_dev.js\",\n \"react-dom\": \"https://cdn.vitessce.io/react-dom@18.2.0/index_dev.js\",\n \"react-dom/client\": \"https://cdn.vitessce.io/react-dom@18.2.0/es2022/client.development.mjs\",\n // Replaced with version-specific URL below.\n \"vitessce\": \"https://cdn.vitessce.io/@vitessce/dev@VERSION/dist/index.js\",\n },\n};\n*/\n\nasync function importWithMapAndFallback(moduleName, importMap, fallbackMap) {\n let result = null;\n if (!fallbackMap) {\n // fallbackMap is null, user may have provided custom JS URL.\n result = await importWithMap(moduleName, {\n imports: {\n ...importMap.imports,\n ...successfulImportMap.imports,\n },\n });\n successfulImportMap.imports[moduleName] = importMap.imports[moduleName];\n } else {\n try {\n result = await importWithMap(moduleName, {\n imports: {\n ...importMap.imports,\n ...successfulImportMap.imports,\n },\n });\n successfulImportMap.imports[moduleName] = importMap.imports[moduleName];\n } catch (e) {\n console.warn(`Importing ${moduleName} failed with importMap`, importMap, \"trying fallback\", fallbackMap, successfulImportMap);\n result = await importWithMap(moduleName, {\n imports: {\n ...fallbackMap.imports,\n ...successfulImportMap.imports,\n },\n });\n successfulImportMap.imports[moduleName] = fallbackMap.imports[moduleName];\n }\n }\n return result;\n}\n\n\nconst React = await importWithMapAndFallback(\"react\", importMap, fallbackImportMap);\nconst { createRoot } = await importWithMapAndFallback(\"react-dom/client\", importMap, fallbackImportMap);\n\nconst e = React.createElement;\n\nfunction isAbsoluteUrl(s) {\n return s?.startsWith('http://') || s?.startsWith('https://');\n}\nconst WORKSPACES_URL_KEYWORD = 'https://workspaces-pt';\nconst OPTIONS_URL_KEYS = ['offsetsUrl', 'refSpecUrl'];\nconst prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;\n// The jupyter server may be running through a proxy,\n// which means that the client needs to prepend the part of the URL before /proxy/8000 such as\n// https://hub.gke2.mybinder.org/user/vitessce-vitessce-python-swi31vcv/proxy/8000/A/0/cells\n// For workspaces: https://workspaces-pt.hubmapconsortium.org/passthrough/HOSTNAME/PORT/ADDITIONAL_PATH_INFO?QUERY_PARAMS=HELLO_WORLD\nfunction prependBaseUrl(config, proxy, hasHostName) {\n if (!proxy || hasHostName) {\n return config;\n }\n const { origin, pathname } = new URL(window.location.href);\n const isInWorkspaces = origin.startsWith(WORKSPACES_URL_KEYWORD);\n const jupyterLabConfigEl = document.getElementById('jupyter-config-data');\n\n let baseUrl;\n if (isInWorkspaces) {\n const pathSegments = pathname.split('/');\n const passthroughIndex = pathSegments.indexOf('passthrough');\n if (passthroughIndex !== -1) {\n baseUrl = pathSegments.slice(0, passthroughIndex + 3).join('/');\n baseUrl += '/';\n }\n } else if (jupyterLabConfigEl) {\n // This is jupyter lab\n baseUrl = JSON.parse(jupyterLabConfigEl.textContent || '').baseUrl;\n } else {\n // This is jupyter notebook\n baseUrl = document.getElementsByTagName('body')[0].getAttribute('data-base-url');\n }\n return {\n ...config,\n datasets: config.datasets.map(d => ({\n ...d,\n files: d.files.map(f => {\n const updatedFileDef = { ...f };\n if (f.url && !isAbsoluteUrl(f.url) ) {\n // Update the main file URL if necessary.\n updatedFileDef.url = `${origin}${baseUrl}${f.url}`;\n }\n if (f.options) {\n // Update any urls within the options object\n const updatedOptions = { ...f.options };\n OPTIONS_URL_KEYS.forEach(key => {\n const optionValue = updatedOptions[key];\n if (optionValue && !isAbsoluteUrl(optionValue)) {\n updatedOptions[key] = `${origin}${baseUrl}${optionValue}`;\n }\n });\n\n // Update image URLs if they exist\n if ('images' in f.options && Array.isArray(f.options.images)) {\n const updatedImages = f.options.images.map(image => {\n const updatedImage = { ...image };\n\n if (image.url && !isAbsoluteUrl(image.url)) {\n updatedImage.url = `${origin}${baseUrl}${image.url}`;\n }\n\n const metadata = { ...image.metadata };\n if (metadata?.omeTiffOffsetsUrl && !isAbsoluteUrl(metadata.omeTiffOffsetsUrl)) {\n metadata.omeTiffOffsetsUrl = `${origin}${baseUrl}${metadata.omeTiffOffsetsUrl}`;\n }\n\n updatedImage.metadata = metadata;\n\n return updatedImage;\n });\n\n updatedOptions.images = updatedImages;\n }\n updatedFileDef.options = updatedOptions;\n }\n return updatedFileDef;\n }),\n })),\n };\n}\n\n// Fallback UUID for non-secure (http://) contexts where crypto.randomUUID is unavailable.\n// Reference: https://stackoverflow.com/a/8809472\nfunction generateId() {\n let d = new Date().getTime(),\n d2 = ((typeof performance !== 'undefined') && performance.now && (performance.now() * 1000)) || 0;\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {\n let r = Math.random() * 16;\n if (d > 0) {\n r = (d + r) % 16 | 0;\n d = Math.floor(d / 16);\n } else {\n r = (d2 + r) % 16 | 0;\n d2 = Math.floor(d2 / 16);\n }\n return (c == 'x' ? r : (r & 0x3 | 0x8)).toString(16);\n });\n}\n\n// Custom invoke matching the anywidget-command protocol implemented on the Python side.\nfunction invoke(model, name, msg, buffers, signal) {\n const id = generateId();\n const abortSignal = signal ?? AbortSignal.timeout(30000);\n return new Promise((resolve, reject) => {\n if (abortSignal.aborted) { reject(abortSignal.reason); return; }\n abortSignal.addEventListener(\"abort\", () => {\n model.off(\"msg:custom\", handler);\n reject(abortSignal.reason);\n });\n function handler(responseMsg, responseBuffers) {\n if (!responseMsg || responseMsg.id !== id) return;\n model.off(\"msg:custom\", handler);\n resolve([responseMsg.response, responseBuffers]);\n }\n model.on(\"msg:custom\", handler);\n model.send({ id, kind: \"anywidget-command\", name, msg }, undefined, buffers ?? []);\n });\n}\n\n\nasync function render(view) {\n const cssUid = view.model.get('uid');\n const jsDevMode = view.model.get('js_dev_mode');\n const jsPackageVersion = view.model.get('js_package_version');\n const customJsUrl = view.model.get('custom_js_url');\n const pluginEsmArr = view.model.get('plugin_esm');\n const remountOnUidChange = view.model.get('remount_on_uid_change');\n const storeUrls = view.model.get('store_urls');\n const invokeTimeout = view.model.get('invoke_timeout');\n const invokeBatched = view.model.get('invoke_batched');\n const preventScroll = view.model.get('prevent_scroll');\n\n const pageMode = view.model.get('page_mode');\n const pageEsm = view.model.get('page_esm');\n\n const pkgName = (jsDevMode ? \"@vitessce/dev\" : \"vitessce\");\n\n const hasCustomJsUrl = customJsUrl.length > 0;\n\n importMap.imports[\"vitessce\"] = (hasCustomJsUrl\n ? customJsUrl\n : `https://unpkg.com/${pkgName}@${jsPackageVersion}`\n );\n let fallbackImportMapToUse = null;\n if (!hasCustomJsUrl) {\n fallbackImportMapToUse = fallbackImportMap;\n if (jsDevMode) {\n fallbackImportMapToUse.imports[\"vitessce\"] = `https://cdn.vitessce.io/vitessce@${jsPackageVersion}/dist/index.min.js`;\n } else {\n fallbackImportMapToUse.imports[\"vitessce\"] = `https://cdn.vitessce.io/@vitessce/dev@${jsPackageVersion}/dist/index.js`;\n }\n }\n\n const {\n Vitessce,\n PluginFileType,\n PluginViewType,\n PluginCoordinationType,\n PluginJointFileType,\n PluginAsyncFunction,\n z,\n useCoordination,\n usePageModeView,\n useGridItemSize,\n // TODO: names and function signatures are subject to change for the following functions\n // Reference: https://github.com/keller-mark/use-coordination/issues/37#issuecomment-1946226827\n useComplexCoordination,\n useMultiCoordinationScopesNonNull,\n useMultiCoordinationScopesSecondaryNonNull,\n useComplexCoordinationSecondary,\n useCoordinationScopes,\n useCoordinationScopesBy,\n } = await importWithMapAndFallback(\"vitessce\", importMap, fallbackImportMapToUse);\n\n let pluginViewTypes = [];\n let pluginCoordinationTypes = [];\n let pluginFileTypes = [];\n let pluginJointFileTypes = [];\n let pluginAsyncFunctions = [];\n\n let pending = [];\n let batchId = 0;\n\n async function processBatch(prevPendingArr) {\n const [dataArr, buffersArr] = await invoke(\n view.model,\n \"_zarr_get_multi\",\n prevPendingArr.map(d => d.params),\n null,\n AbortSignal.timeout(invokeTimeout),\n );\n prevPendingArr.forEach((prevPendingItem, i) => {\n const data = dataArr[i];\n const bufferData = buffersArr[i];\n const { params, resolve, reject } = prevPendingItem;\n const [storeUrl, key] = params;\n\n if (!data.success) {\n resolve(undefined);\n return;\n }\n\n if (ArrayBuffer.isView(bufferData)) {\n resolve(new Uint8Array(bufferData.buffer, bufferData.byteOffset, bufferData.byteLength));\n return;\n }\n resolve(new Uint8Array(bufferData.buffer));\n return;\n });\n }\n\n function run() {\n processBatch(pending);\n pending = [];\n batchId = 0;\n }\n\n function enqueue(params) {\n batchId = batchId || requestAnimationFrame(() => run());\n let { promise, resolve, reject } = Promise.withResolvers();\n pending.push({ params, resolve, reject });\n return promise;\n }\n\n\n const stores = Object.fromEntries(\n storeUrls.map(storeUrl => ([\n storeUrl,\n {\n async get(key) {\n if (invokeBatched) {\n return enqueue([storeUrl, key]);\n } else {\n // Do not submit zarr gets in batches. Instead, submit individually.\n const [data, buffers] = await invoke(\n view.model,\n \"_zarr_get\",\n [storeUrl, key],\n null,\n AbortSignal.timeout(invokeTimeout),\n );\n if (!data.success) return undefined;\n\n if (ArrayBuffer.isView(buffers[0])) {\n return new Uint8Array(buffers[0].buffer, buffers[0].byteOffset, buffers[0].byteLength);\n }\n return new Uint8Array(buffers[0].buffer);\n }\n },\n async getRange(key, rangeQuery) {\n if (invokeBatched) {\n return enqueue([storeUrl, key, rangeQuery]);\n } else {\n // Do not submit zarr gets in batches. Instead, submit individually.\n const [data, buffers] = await invoke(\n view.model,\n \"_zarr_get_range\",\n [storeUrl, key, rangeQuery],\n null,\n AbortSignal.timeout(invokeTimeout),\n );\n if (!data.success) return undefined;\n\n if (ArrayBuffer.isView(buffers[0])) {\n return new Uint8Array(buffers[0].buffer, buffers[0].byteOffset, buffers[0].byteLength);\n }\n return new Uint8Array(buffers[0].buffer);\n }\n },\n }\n ])),\n );\n\n function invokePluginCommand(commandName, commandParams, commandBuffers) {\n return invoke(\n view.model,\n \"_plugin_command\",\n [commandName, commandParams],\n commandBuffers ?? null,\n AbortSignal.timeout(invokeTimeout),\n );\n }\n\n for (const pluginEsm of pluginEsmArr) {\n try {\n const pluginEsmUrl = URL.createObjectURL(new Blob([pluginEsm], { type: \"text/javascript\" }));\n const pluginModule = (await import(pluginEsmUrl)).default;\n URL.revokeObjectURL(pluginEsmUrl);\n\n const pluginDeps = {\n React,\n PluginFileType,\n PluginViewType,\n PluginCoordinationType,\n PluginJointFileType,\n PluginAsyncFunction,\n z,\n invokeCommand: invokePluginCommand,\n useCoordination,\n useGridItemSize,\n useComplexCoordination,\n useMultiCoordinationScopesNonNull,\n useMultiCoordinationScopesSecondaryNonNull,\n useComplexCoordinationSecondary,\n useCoordinationScopes,\n useCoordinationScopesBy,\n };\n const pluginsObj = await pluginModule.createPlugins(pluginDeps);\n if(Array.isArray(pluginsObj.pluginViewTypes)) {\n pluginViewTypes = [...pluginViewTypes, ...pluginsObj.pluginViewTypes];\n }\n if(Array.isArray(pluginsObj.pluginCoordinationTypes)) {\n pluginCoordinationTypes = [...pluginCoordinationTypes, ...pluginsObj.pluginCoordinationTypes];\n }\n if(Array.isArray(pluginsObj.pluginFileTypes)) {\n pluginFileTypes = [...pluginFileTypes, ...pluginsObj.pluginFileTypes];\n }\n if(Array.isArray(pluginsObj.pluginJointFileTypes)) {\n pluginJointFileTypes = [...pluginJointFileTypes, ...pluginsObj.pluginJointFileTypes];\n }\n if(Array.isArray(pluginsObj.pluginAsyncFunctions)) {\n pluginAsyncFunctions = [...pluginAsyncFunctions, ...pluginsObj.pluginAsyncFunctions];\n }\n } catch(e) {\n console.error(\"Error loading plugin ESM or executing createPlugins function.\");\n console.error(e);\n }\n }\n\n let PageComponent;\n if(pageMode && pageEsm.length > 0) {\n try {\n const pageEsmUrl = URL.createObjectURL(new Blob([pageEsm], { type: \"text/javascript\" }));\n const pageModule = (await import(pageEsmUrl)).default;\n URL.revokeObjectURL(pageEsmUrl);\n\n const pageDeps = {\n React,\n usePageModeView,\n };\n PageComponent = await pageModule.createPage(pageDeps);\n } catch(e) {\n console.error(\"Error loading page ESM or executing createPage function.\")\n console.error(e);\n }\n }\n\n function VitessceWidget(props) {\n const { model, styleContainer } = props;\n\n const [config, setConfig] = React.useState(prependBaseUrl(model.get('_config'), model.get('proxy'), model.get('has_host_name')));\n const [validateConfig, setValidateConfig] = React.useState(true);\n const height = model.get('height');\n const theme = model.get('theme') === 'auto' ? (prefersDark ? 'dark' : 'light') : model.get('theme');\n\n const divRef = React.useRef();\n\n React.useEffect(() => {\n if(!divRef.current || !preventScroll) {\n return () => {};\n }\n\n function handleMouseEnter() {\n const jpn = divRef.current.closest('.jp-Notebook');\n if(jpn) {\n jpn.style.overflow = \"hidden\";\n }\n }\n function handleMouseLeave(event) {\n if(event.relatedTarget === null || (event.relatedTarget && event.relatedTarget.closest('.jp-Notebook')?.length)) return;\n const jpn = divRef.current.closest('.jp-Notebook');\n if(jpn) {\n jpn.style.overflow = \"auto\";\n }\n }\n divRef.current.addEventListener(\"mouseenter\", handleMouseEnter);\n divRef.current.addEventListener(\"mouseleave\", handleMouseLeave);\n\n return () => {\n if(divRef.current) {\n divRef.current.removeEventListener(\"mouseenter\", handleMouseEnter);\n divRef.current.removeEventListener(\"mouseleave\", handleMouseLeave);\n }\n };\n }, [divRef, preventScroll]);\n\n // Config changed on JS side (from within ),\n // send updated config to Python side.\n const onConfigChange = React.useCallback((config) => {\n model.set('_config', config);\n setValidateConfig(false);\n model.save_changes();\n }, [model]);\n\n // Config changed on Python side,\n // pass to component to it is updated on JS side.\n React.useEffect(() => {\n model.on('change:_config', () => {\n const newConfig = prependBaseUrl(model.get('_config'), model.get('proxy'), model.get('has_host_name'));\n\n // Force a re-render and re-validation by setting a new config.uid value.\n // TODO: make this conditional on a parameter from Python.\n //newConfig.uid = `random-${Math.random()}`;\n //console.log('newConfig', newConfig);\n setConfig(newConfig);\n });\n }, []);\n\n const vitessceProps = {\n height, theme, config, onConfigChange, validateConfig,\n pluginViewTypes, pluginCoordinationTypes,\n pluginFileTypes,pluginJointFileTypes, pluginAsyncFunctions,\n remountOnUidChange, stores, pageMode, styleContainer,\n };\n\n return e('div', { ref: divRef, style: { height: height + 'px' } },\n e(React.Suspense, { fallback: e('div', {}, 'Loading...') },\n e(React.StrictMode, {},\n e(Vitessce, vitessceProps,\n (pageMode ? e(PageComponent, {}) : null)\n ),\n ),\n ),\n );\n }\n\n const root = createRoot(view.el);\n // Marimo puts AnyWidgets in a Shadow Root, so we need to tell Emotion to\n // insert styles within the Shadow DOM.\n const rootNode = view.el.getRootNode();\n const styleContainer = rootNode === document ? undefined : rootNode;\n root.render(e(VitessceWidget, { model: view.model, styleContainer }));\n\n return () => {\n // Re-enable scrolling.\n const jpn = view.el.closest('.jp-Notebook');\n if(jpn) {\n jpn.style.overflow = \"auto\";\n }\n\n // Clean up React and DOM state.\n root.unmount();\n if(view._isFromDisplay) {\n view.el.remove();\n }\n };\n}\nexport default { render };\n", + "_model_module": "anywidget", + "_model_module_version": "~0.11.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.11.*", + "_view_name": "AnyView", + "custom_js_url": "", + "has_host_name": false, + "height": 600, + "invoke_batched": true, + "invoke_timeout": 300000, + "js_dev_mode": false, + "js_package_version": "3.9.11", + "layout": "IPY_MODEL_7944f55009c64754a407dfff3408ca92", + "page_esm": "", + "page_mode": false, + "plugin_esm": [], + "prevent_scroll": true, + "proxy": false, + "remount_on_uid_change": true, + "store_urls": [], + "tabbable": null, + "theme": "auto", + "tooltip": null, + "uid": "dd32" + } + } + }, + "version_major": 2, + "version_minor": 0 + } + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/vitessce/__init__.py b/src/vitessce/__init__.py index d60c306c..fcf60036 100644 --- a/src/vitessce/__init__.py +++ b/src/vitessce/__init__.py @@ -50,6 +50,8 @@ ImageOmeZarrWrapper, ObsSegmentationsOmeZarrWrapper, SpatialDataWrapper, + ObsSegmentationsNgPrecomputedWrapper, + ObsPointsNgAnnotationsWrapper, ) except ModuleNotFoundError as e: # pragma: no cover warn(f'Extra installs are necessary to use wrappers: {e}') diff --git a/src/vitessce/widget.py b/src/vitessce/widget.py index 583193c5..f04c2f3f 100644 --- a/src/vitessce/widget.py +++ b/src/vitessce/widget.py @@ -175,6 +175,8 @@ def get_uid_str(uid): const importMap = { imports: { "react": "https://esm.sh/react@18.2.0?dev", + // needed when using custom_js_url + "react/jsx-runtime": "https://esm.sh/react@18.2.0/jsx-runtime?dev", "react-dom": "https://esm.sh/react-dom@18.2.0?dev", "react-dom/client": "https://esm.sh/react-dom@18.2.0/client?dev", }, @@ -184,6 +186,8 @@ def get_uid_str(uid): "react": "https://cdn.vitessce.io/react@18.2.0/index.js", "react-dom": "https://cdn.vitessce.io/react-dom@18.2.0/index.js", "react-dom/client": "https://cdn.vitessce.io/react-dom@18.2.0/es2022/client.mjs", + // needed when using custom_js_url + "react/jsx-runtime": "https://esm.sh/react@18.2.0/jsx-runtime?dev", // Replaced with version-specific URL below. "vitessce": "https://cdn.vitessce.io/vitessce@VERSION/dist/index.min.js", }, diff --git a/src/vitessce/wrappers.py b/src/vitessce/wrappers.py index 82e0b14e..9fc871d4 100644 --- a/src/vitessce/wrappers.py +++ b/src/vitessce/wrappers.py @@ -4,7 +4,7 @@ from collections import defaultdict import os -from os.path import join +from os.path import join, isdir import tempfile from typing import Callable, Optional, Type, TypeVar, Union from uuid import uuid4 @@ -1642,3 +1642,109 @@ def genomic_profiles_file_def_creator(base_url): obj_file_def['requestInit'] = self._request_init return obj_file_def return genomic_profiles_file_def_creator + + +class ObsSegmentationsNgPrecomputedWrapper(AbstractWrapper): + """ + Wrap a Neuroglancer precomputed segmentation + meshes directory + (as produced by e.g. tissue_map_tools's igneous_converters functions). + """ + def __init__(self, data_path=None, data_url=None, coordination_values=None, options=None, **kwargs): + super().__init__(**kwargs) + raise_error_if_zero_or_more_than_one([data_path, data_url]) + self._data_path = data_path + self._data_url = data_url + self._coordination_values = coordination_values + self._options = options + self.is_remote = data_url is not None + self.local_dir_uid = "ng_precomputed_segmentation" + self._repr = make_repr(locals()) + + def convert_and_save(self, dataset_uid, obj_i, base_dir=None): + super().convert_and_save(dataset_uid, obj_i, base_dir=base_dir) + self.file_def_creators.append(self.make_file_def_creator(dataset_uid, obj_i)) + self.routes += self.make_routes(dataset_uid, obj_i) + + def make_routes(self, dataset_uid, obj_i): + if not self.is_remote: + if not isdir(self._data_path): + raise ValueError(f"data_path {self._data_path} is not a directory") + return self.get_local_dir_route(dataset_uid, obj_i, self._data_path, self.local_dir_uid) + return [] + + def get_data_url(self, base_url="", dataset_uid="", obj_i=""): + if self.is_remote: + return self._data_url + return self.get_local_dir_url(base_url, dataset_uid, obj_i, self._data_path, self.local_dir_uid) + + def make_file_def_creator(self, dataset_uid, obj_i): + def file_def_creator(base_url): + file_def = { + "fileType": "obsSegmentations.ng-precomputed", + "url": self.get_data_url(base_url, dataset_uid, obj_i), + } + if self._coordination_values is not None: + file_def["coordinationValues"] = self._coordination_values + if self._options is not None: + file_def["options"] = self._options + return file_def + return file_def_creator + + +class ObsPointsNgAnnotationsWrapper(AbstractWrapper): + """ + Wrap a single Neuroglancer precomputed point-annotation directory + (e.g. one produced by tissue_map_tools's + from_spatialdata_points_to_precomputed_points). + + :param str data_path: Path to a local annotation directory (a subdirectory + of a precomputed root, containing its own `info` file). Mutually + exclusive with `data_url`. + :param str data_url: URL to a remote annotation directory already being + served. Mutually exclusive with `data_path`. + :param dict coordination_values: Coordination values for this file, e.g. + `{"fileUid": "annotation_inv_points"}`. + :param dict options: Options for this file type, e.g. + `{"featureIndexProp": "phenotype", "pointIndexProp": "cell_id"}`. + """ + + def __init__(self, data_path=None, data_url=None, coordination_values=None, options=None, **kwargs): + super().__init__(**kwargs) + raise_error_if_zero_or_more_than_one([data_path, data_url]) + self._data_path = data_path + self._data_url = data_url + self._coordination_values = coordination_values + self._options = options + self.is_remote = data_url is not None + self.local_dir_uid = "ng_precomputed_annotations" + self._repr = make_repr(locals()) + + def convert_and_save(self, dataset_uid, obj_i, base_dir=None): + super().convert_and_save(dataset_uid, obj_i, base_dir=base_dir) + self.file_def_creators.append(self.make_file_def_creator(dataset_uid, obj_i)) + self.routes += self.make_routes(dataset_uid, obj_i) + + def make_routes(self, dataset_uid, obj_i): + if not self.is_remote: + if not isdir(self._data_path): + raise ValueError(f"data_path {self._data_path} is not a directory") + return self.get_local_dir_route(dataset_uid, obj_i, self._data_path, self.local_dir_uid) + return [] + + def get_data_url(self, base_url="", dataset_uid="", obj_i=""): + if self.is_remote: + return self._data_url + return self.get_local_dir_url(base_url, dataset_uid, obj_i, self._data_path, self.local_dir_uid) + + def make_file_def_creator(self, dataset_uid, obj_i): + def file_def_creator(base_url): + file_def = { + "fileType": "obsPoints.ng-annotations", + "url": self.get_data_url(base_url, dataset_uid, obj_i), + } + if self._coordination_values is not None: + file_def["coordinationValues"] = self._coordination_values + if self._options is not None: + file_def["options"] = self._options + return file_def + return file_def_creator diff --git a/tests/test_wrappers.py b/tests/test_wrappers.py index 046108f1..2eb71b2f 100644 --- a/tests/test_wrappers.py +++ b/tests/test_wrappers.py @@ -21,6 +21,8 @@ ObsSegmentationsOmeTiffWrapper, ImageOmeZarrWrapper, ObsSegmentationsOmeZarrWrapper, + ObsSegmentationsNgPrecomputedWrapper, + ObsPointsNgAnnotationsWrapper, ) from vitessce.wrappers import SpatialDataWrapper, file_path_to_url_path @@ -529,3 +531,72 @@ def test_spatial_data_with_base_dir_2(self): "obsType": "spot" } }) + + def test_obs_segmentations_ng_precomputed_local(self): + w = ObsSegmentationsNgPrecomputedWrapper( + data_path=data_path / 'test.ng.precomputed', + coordination_values={'fileUid': 'segmentation'}, + options={'segments': ['1', '2', '3']}, + ) + w.local_dir_uid = 'test.ng.precomputed' + + file_def_creator = w.make_file_def_creator("A", "0") + file_def = file_def_creator('http://localhost:8000') + self.assertEqual(file_def, { + 'fileType': 'obsSegmentations.ng-precomputed', + 'url': 'http://localhost:8000/A/0/test.ng.precomputed', + 'coordinationValues': {'fileUid': 'segmentation'}, + 'options': {'segments': ['1', '2', '3']}, + }) + + def test_obs_segmentations_ng_precomputed_remote(self): + w = ObsSegmentationsNgPrecomputedWrapper( + data_url='https://example.com/data/precomputed', + coordination_values={'fileUid': 'segmentation'}, + ) + file_def_creator = w.make_file_def_creator("A", "0") + file_def = file_def_creator('http://localhost:8000') + self.assertEqual(file_def, { + 'fileType': 'obsSegmentations.ng-precomputed', + 'url': 'https://example.com/data/precomputed', + 'coordinationValues': {'fileUid': 'segmentation'}, + }) + self.assertEqual(w.make_routes("A", "0"), []) + + def test_obs_segmentations_ng_precomputed_requires_exactly_one_source(self): + with self.assertRaises(ValueError): + ObsSegmentationsNgPrecomputedWrapper() + with self.assertRaises(ValueError): + ObsSegmentationsNgPrecomputedWrapper( + data_path=data_path / 'test.ng.precomputed', + data_url='https://example.com/data/precomputed', + ) + + def test_obs_points_ng_annotations_local(self): + w = ObsPointsNgAnnotationsWrapper( + data_path=data_path / 'test.ng.annotations', + coordination_values={'fileUid': 'annotation_points'}, + options={'featureIndexProp': 'phenotype', 'pointIndexProp': 'cell_id'}, + ) + w.local_dir_uid = 'test.ng.annotations' + + file_def_creator = w.make_file_def_creator("A", "0") + file_def = file_def_creator('http://localhost:8000') + self.assertEqual(file_def, { + 'fileType': 'obsPoints.ng-annotations', + 'url': 'http://localhost:8000/A/0/test.ng.annotations', + 'coordinationValues': {'fileUid': 'annotation_points'}, + 'options': {'featureIndexProp': 'phenotype', 'pointIndexProp': 'cell_id'}, + }) + + def test_obs_points_ng_annotations_remote(self): + w = ObsPointsNgAnnotationsWrapper( + data_url='https://example.com/data/annotations', + ) + file_def_creator = w.make_file_def_creator("A", "0") + file_def = file_def_creator('http://localhost:8000') + self.assertEqual(file_def, { + 'fileType': 'obsPoints.ng-annotations', + 'url': 'https://example.com/data/annotations', + }) + self.assertEqual(w.make_routes("A", "0"), []) diff --git a/uv.lock b/uv.lock index 16a969ea..fdd1e857 100644 --- a/uv.lock +++ b/uv.lock @@ -4943,4 +4943,4 @@ source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/3f/50/bad581df71744867e9468ebd0bcd6505de3b275e06f202c2cb016e3ff56f/zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4", size = 24545 } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/1a/7e4798e9339adc931158c9d69ecc34f5e6791489d469f5e50ec15e35f458/zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931", size = 9630 }, -] +] \ No newline at end of file