diff --git a/docs/notebooks/widget_neuroglancer_precomputed.ipynb b/docs/notebooks/widget_neuroglancer_precomputed.ipynb index 80aacdfd..67a64025 100644 --- a/docs/notebooks/widget_neuroglancer_precomputed.ipynb +++ b/docs/notebooks/widget_neuroglancer_precomputed.ipynb @@ -17,7 +17,7 @@ "\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." + "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 for quick testing without a full `obsSets` pipeline -- see the alternative method below." ] }, { @@ -57,7 +57,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 2, @@ -77,9 +77,9 @@ " coordination_values={\"fileUid\": \"segmentation\"},\n", "))\n", "\n", - "# An obsSets-type file is required for segments to be dynamically\n", + "# An obsSets-type file can be used for segments to be dynamically\n", "# selected/colored -- obsType here must match the segmentationChannel's\n", - "# obsType coordination value set below.\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", @@ -102,7 +102,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 3, @@ -111,7 +111,18 @@ } ], "source": [ - "ng_view = vc.add_view(\"neuroglancer\", dataset=dataset)\n", + "ng_view = vc.add_view(\"neuroglancer\", dataset=dataset).set_props(\n", + " initialNgCameraState={\n", + " 'position': [49.5, 1000.5, 5209.5],\n", + " 'projectionScale': 1024,\n", + " 'projectionOrientation': [\n", + " -0.636204183101654,\n", + " -0.5028395652770996,\n", + " 0.5443811416625977,\n", + " 0.2145828753709793,\n", + " ],\n", + " },\n", + ")\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", @@ -124,7 +135,9 @@ { "cell_type": "markdown", "id": "c8aaa012", - "metadata": {}, + "metadata": { + "tags": [] + }, "source": [ "## 2. Coordinate the views\n", "\n", @@ -145,7 +158,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 4, @@ -195,12 +208,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "dbd6e9854d6442ab80ba290784996dda", + "model_id": "c80f5124cd2745b1bfaa24ec34c81822", "version_major": 2, "version_minor": 1 }, "text/plain": [ - "" + "" ] }, "execution_count": 5, @@ -209,25 +222,128 @@ } ], "source": [ - "vw = vc.widget(custom_js_url=\"http://localhost:9001/packages/main/dev/dist/index.js\")\n", + "vw = vc.widget(custom_js_url=\"http://localhost:9000/packages/main/dev/dist/index.js\")\n", "vw" ] }, { - "cell_type": "code", - "execution_count": null, - "id": "be0c7d7e-e466-45c2-8b65-503501d6faf9", + "cell_type": "markdown", + "id": "0418b571", "metadata": {}, - "outputs": [], - "source": [] + "source": [ + "## 4. Alternate way to load segments: providing an explicit array\n", + "\n", + "Instead of pointing at a remote `obsSets` CSV (as in section 1 above), you can select and color a specific, known set of segments directly from a Python list/dict. Native Neuroglancer itself supports specifying segments this way, as a plain array (\"segments\": [...]) in its own JSON state — this section replicates that same capability through Vitessce's own coordination system.\n", + "This adds two files instead of one `obsSets.csv`\n", + "- `obsFeatureMatrix.csv` -- just the segment IDs, defining which observations exist.\n", + "- `obsColors.csv` -- an explicit `id -> color` mapping. -- optional\n", + "\n", + "The `segmentationChannel` coordination also changes slightly: `obsColorEncoding` is set to `'obsColors'` (using the explicit colors above) instead of relying on cluster-based coloring." + ] }, { "cell_type": "code", - "execution_count": null, - "id": "8fbd9181-2d33-4018-be02-722161342c5c", + "execution_count": 6, + "id": "27b7431d", "metadata": {}, - "outputs": [], - "source": [] + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "2858ecabc0e34b8f85731d41886f8c4a", + "version_major": 2, + "version_minor": 1 + }, + "text/plain": [ + "" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from vitessce import make_ids_csv_data_url, make_colors_csv_data_url\n", + "\n", + "segment_ids = [612, 3351, 4328, 6531, 8446]\n", + "segment_colors = {\n", + " 612: '#d74242',\n", + " 3351: '#b9d742',\n", + " 4328: '#42d77d',\n", + " 6531: '#427dd7',\n", + " 8446: '#b942d7',\n", + "}\n", + "\n", + "vc_alt = VitessceConfig(\n", + " schema_version='1.0.17',\n", + " name='Neuroglancer precomputed example (explicit segments)',\n", + ")\n", + "dataset_alt = vc_alt.add_dataset('Melanoma')\n", + "\n", + "dataset_alt.add_object(ObsSegmentationsNgPrecomputedWrapper(\n", + " data_url='https://data-2.vitessce.io/data/sorger/melanoma_meshes',\n", + " coordination_values={'fileUid': 'segmentation'},\n", + "))\n", + "\n", + "# IDs only -- defines which observations exist for this obsType.\n", + "dataset_alt.add_object(CsvWrapper(\n", + " csv_url=make_ids_csv_data_url(segment_ids),\n", + " data_type='obsFeatureMatrix',\n", + " coordination_values={\n", + " 'obsType': 'cell', 'featureType': 'feature', 'featureValueType': 'value',\n", + " },\n", + "))\n", + "\n", + "# Explicit id -> color mapping.\n", + "dataset_alt.add_object(CsvWrapper(\n", + " csv_url=make_colors_csv_data_url(segment_colors),\n", + " data_type='obsColors',\n", + " options={'obsIndex': 'id', 'obsColors': 'color'},\n", + " coordination_values={'obsType': 'cell'},\n", + "))\n", + "\n", + "ng_view_alt = vc_alt.add_view('neuroglancer', dataset=dataset_alt).set_props(\n", + " initialNgCameraState={\n", + " 'position': [49.5, 1000.5, 5209.5],\n", + " 'projectionScale': 1024,\n", + " 'projectionOrientation': [\n", + " -0.636204183101654,\n", + " -0.5028395652770996,\n", + " 0.5443811416625977,\n", + " 0.2145828753709793,\n", + " ],\n", + " },\n", + ")\n", + "lc_view_alt = vc_alt.add_view('layerControllerBeta', dataset=dataset_alt)\n", + "vc_alt.layout(ng_view_alt | lc_view_alt)\n", + "\n", + "vc_alt.link_views_by_dict([ng_view_alt, lc_view_alt], {\n", + " 'spatialRenderingMode': '3D',\n", + " 'spatialZoom': 0, 'spatialTargetX': 0, 'spatialTargetY': 0, 'spatialTargetZ': 0,\n", + " 'spatialRotationX': 0, 'spatialRotationY': 0, 'spatialRotationOrbit': 0,\n", + "}, meta=False)\n", + "\n", + "vc_alt.link_views_by_dict([ng_view_alt, lc_view_alt], {\n", + " 'segmentationLayer': CL([{\n", + " 'fileUid': 'segmentation',\n", + " 'spatialLayerOpacity': 1,\n", + " 'spatialTargetResolution': None,\n", + " 'spatialLayerVisible': True,\n", + " 'segmentationChannel': CL([{\n", + " 'obsType': 'cell',\n", + " 'featureType': 'feature',\n", + " 'featureValueType': 'value',\n", + " 'spatialChannelVisible': True,\n", + " 'obsColorEncoding': 'obsColors',\n", + " }]),\n", + " }]),\n", + "}, scope_prefix=get_initial_coordination_scope_prefix('A', 'obsSegmentations'))\n", + "\n", + "# TODO: drop the custom_js_url when updates released\n", + "vw_alt = vc_alt.widget(custom_js_url='http://localhost:9000/packages/main/dev/dist/index.js')\n", + "vw_alt" + ] } ], "metadata": { @@ -246,7 +362,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.13" + "version": "3.12.7" }, "widgets": { "application/vnd.jupyter.widget-state+json": { diff --git a/src/vitessce/__init__.py b/src/vitessce/__init__.py index fcf60036..623b7be9 100644 --- a/src/vitessce/__init__.py +++ b/src/vitessce/__init__.py @@ -13,6 +13,8 @@ from .utils import ( get_initial_coordination_scope_prefix, get_initial_coordination_scope_name, + make_ids_csv_data_url, + make_colors_csv_data_url, ) from .repr import make_repr diff --git a/src/vitessce/utils.py b/src/vitessce/utils.py index fdb31c27..bbb58c12 100644 --- a/src/vitessce/utils.py +++ b/src/vitessce/utils.py @@ -33,3 +33,51 @@ def get_initial_coordination_scope_prefix(dataset_uid, data_type): def get_initial_coordination_scope_name(dataset_uid, data_type, i=None): prefix = get_initial_coordination_scope_prefix(dataset_uid, data_type) return f"{prefix}{0 if i is None else i}" + + +def make_ids_csv_data_url(ids): + """ + Build a `data:` URL containing a small inline CSV with a single `id` + column, given a list of observation IDs (e.g. segment IDs). + + Useful for defining a small, explicit `obsFeatureMatrix.csv` / + `obsSets.csv`-style observation list without needing to host a + separate CSV file. + + :param list ids: A list of observation IDs. + :rtype: str + :returns: A `data:text/csv,...` URL. + """ + import csv + import io + from urllib.parse import quote + + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(["id"]) + writer.writerows([[i] for i in ids]) + return f"data:text/csv,{quote(buf.getvalue())}" + + +def make_colors_csv_data_url(id_to_color): + """ + Build a `data:` URL containing a small inline CSV with `id` and + `color` columns, given a dict mapping observation ID to a color + string (e.g. a hex color). + + Useful for defining explicit per-observation colors (`obsColors.csv`) + without needing to host a separate CSV file. + + :param dict id_to_color: A dict mapping observation ID to color. + :rtype: str + :returns: A `data:text/csv,...` URL. + """ + import csv + import io + from urllib.parse import quote + + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(["id", "color"]) + writer.writerows(id_to_color.items()) + return f"data:text/csv,{quote(buf.getvalue())}" diff --git a/src/vitessce/widget.py b/src/vitessce/widget.py index c3f92503..ae825c78 100644 --- a/src/vitessce/widget.py +++ b/src/vitessce/widget.py @@ -245,7 +245,7 @@ def get_uid_str(uid): const e = React.createElement; function isAbsoluteUrl(s) { - return s?.startsWith('http://') || s?.startsWith('https://'); + return s?.startsWith('http://') || s?.startsWith('https://') || s?.startsWith('data:') || s?.startsWith('blob:') || s?.startsWith('file:') || s?.startsWith('//'); } const WORKSPACES_URL_KEYWORD = 'https://workspaces-pt'; const OPTIONS_URL_KEYS = ['offsetsUrl', 'refSpecUrl']; diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..800068c1 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,31 @@ +import unittest +from urllib.parse import unquote + +from vitessce.utils import make_ids_csv_data_url, make_colors_csv_data_url + + +class TestCsvDataUrlUtils(unittest.TestCase): + + def test_make_ids_csv_data_url(self): + url = make_ids_csv_data_url([612, 3351, 4328]) + self.assertTrue(url.startswith("data:text/csv,")) + decoded = unquote(url[len("data:text/csv,"):]) + self.assertEqual(decoded, "id\r\n612\r\n3351\r\n4328\r\n") + + def test_make_ids_csv_data_url_empty(self): + url = make_ids_csv_data_url([]) + decoded = unquote(url[len("data:text/csv,"):]) + self.assertEqual(decoded, "id\r\n") + + def test_make_colors_csv_data_url(self): + url = make_colors_csv_data_url({ + 612: "#d74242", + 3351: "#b9d742", + }) + self.assertTrue(url.startswith("data:text/csv,")) + decoded = unquote(url[len("data:text/csv,"):]) + self.assertEqual(decoded, "id,color\r\n612,#d74242\r\n3351,#b9d742\r\n") + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index be4e8379..a86ef26d 100644 --- a/uv.lock +++ b/uv.lock @@ -3892,7 +3892,7 @@ wheels = [ [[package]] name = "vitessce" -version = "3.9.2" +version = "3.9.4" source = { editable = "." } dependencies = [ { name = "black" },