plexora 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plexora/__init__.py +129 -0
- plexora/__main__.py +15 -0
- plexora/_url.py +105 -0
- plexora/api/__init__.py +55 -0
- plexora/api/dataset.py +398 -0
- plexora/api/http.py +19 -0
- plexora/api/plugin.py +541 -0
- plexora/api/store.py +221 -0
- plexora/cli.py +777 -0
- plexora/client/dist/354_bundle.js +2 -0
- plexora/client/dist/354_bundle.js.LICENSE.txt +20 -0
- plexora/client/dist/418_bundle.js +2 -0
- plexora/client/dist/418_bundle.js.LICENSE.txt +1 -0
- plexora/client/dist/693b935cb1f907814be9c6199a68c217.svg +19 -0
- plexora/client/dist/713c134ff47cd328a5b711526f151082.svg +18 -0
- plexora/client/dist/770_bundle.js +1 -0
- plexora/client/dist/ff743f408972e0e96ca9153ed5ae4c83.svg +23 -0
- plexora/client/dist/vendor_bundle.js +2 -0
- plexora/client/dist/vendor_bundle.js.LICENSE.txt +308 -0
- plexora/client/external/openseadragon-bin-2.4.0/LICENSE.txt +28 -0
- plexora/client/external/openseadragon-bin-2.4.0/canvas-overlay-hd.js +140 -0
- plexora/client/external/openseadragon-bin-2.4.0/changelog.txt +501 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/.gitattributes +17 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/LICENSE.txt +116 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/Toolbar.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/fullpage.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/home.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/next.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/previous.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateleft.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateright.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/zoom.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/README.md +9 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-scalebar.js +562 -0
- plexora/client/src/css/import.css +474 -0
- plexora/client/src/css/main.css +903 -0
- plexora/client/src/css/openProject.css +363 -0
- plexora/client/src/css/quickView.css +166 -0
- plexora/client/src/css/tokens.css +51 -0
- plexora/client/src/css/viewer.css +1655 -0
- plexora/client/src/img/apple-touch-icon.png +0 -0
- plexora/client/src/img/favicon.ico +0 -0
- plexora/client/src/img/logo.ai +8892 -33
- plexora/client/src/img/logo.svg +1 -0
- plexora/client/src/img/logo_with_text.ai +9011 -34
- plexora/client/src/img/logo_with_text.svg +1 -0
- plexora/client/src/js/main.js +704 -0
- plexora/client/src/js/pluginRegistry.js +108 -0
- plexora/client/src/js/services/appStatus.js +286 -0
- plexora/client/src/js/services/browsePicker.js +73 -0
- plexora/client/src/js/services/dataLayer.js +400 -0
- plexora/client/src/js/services/datasetContext.js +143 -0
- plexora/client/src/js/services/glRenderer.js +143 -0
- plexora/client/src/js/services/importFormValidation.js +257 -0
- plexora/client/src/js/services/numericData.js +100 -0
- plexora/client/src/js/services/passVariablesToFrontend.js +20 -0
- plexora/client/src/js/services/simpleEventHandler.js +25 -0
- plexora/client/src/js/vendor.js +29 -0
- plexora/client/src/js/views/channelList.js +663 -0
- plexora/client/src/js/views/colorSwatchPicker.js +202 -0
- plexora/client/src/js/views/columnClassifier.js +146 -0
- plexora/client/src/js/views/coordinateField.js +133 -0
- plexora/client/src/js/views/dataSourceField.js +276 -0
- plexora/client/src/js/views/imageViewer.js +3306 -0
- plexora/client/src/js/views/miniMap.js +787 -0
- plexora/client/src/js/views/navbarControls.js +178 -0
- plexora/client/src/js/views/openProjectPage.js +239 -0
- plexora/client/src/js/views/projectEdit.js +335 -0
- plexora/client/src/js/views/quickViewLanding.js +136 -0
- plexora/client/src/js/views/rainbow.js +326 -0
- plexora/client/src/js/views/requirementsModal.js +572 -0
- plexora/client/src/js/views/rgbImageViewer.js +115 -0
- plexora/client/src/js/views/searchableSelect.js +411 -0
- plexora/client/src/js/views/segmentationProgress.js +122 -0
- plexora/client/src/js/views/toolLoader.js +747 -0
- plexora/client/src/js/views/viewerControls.js +689 -0
- plexora/client/src/js/views/viewerManager.js +434 -0
- plexora/client/src/js/views/viewerSidebar.js +1117 -0
- plexora/client/src/js/workers/tileDecoder.js +130 -0
- plexora/client/src/shaders/frag.glsl +528 -0
- plexora/client/src/shaders/vert.glsl +12 -0
- plexora/client/templates/base.html +212 -0
- plexora/client/templates/index.html +238 -0
- plexora/client/templates/open_project.html +96 -0
- plexora/client/templates/project_columns.html +86 -0
- plexora/client/templates/project_edit.html +196 -0
- plexora/client/templates/upload.html +118 -0
- plexora/connect.py +528 -0
- plexora/datasource.py +782 -0
- plexora/jupyter.py +406 -0
- plexora/notebook_env.py +232 -0
- plexora/paths.py +486 -0
- plexora/plugins/__init__.py +12 -0
- plexora/plugins/cell_explorer/__init__.py +100 -0
- plexora/plugins/cell_explorer/server/__init__.py +11 -0
- plexora/plugins/cell_explorer/server/routes.py +167 -0
- plexora/plugins/cell_explorer/server/state.py +281 -0
- plexora/plugins/cell_explorer/server/values.py +145 -0
- plexora/plugins/cell_explorer/server/variables.py +367 -0
- plexora/plugins/cell_explorer/static/cellExplorerApi.js +158 -0
- plexora/plugins/cell_explorer/static/cellExplorerColors.js +316 -0
- plexora/plugins/cell_explorer/static/cellExplorerContinuous.js +403 -0
- plexora/plugins/cell_explorer/static/cellExplorerFigureBridge.js +133 -0
- plexora/plugins/cell_explorer/static/cellExplorerLegend.js +188 -0
- plexora/plugins/cell_explorer/static/cellExplorerRoiBridge.js +822 -0
- plexora/plugins/cell_explorer/static/cellExplorerSidebarController.js +826 -0
- plexora/plugins/cell_explorer/static/cellExplorerState.js +372 -0
- plexora/plugins/cell_explorer/static/cell_explorer.css +752 -0
- plexora/plugins/cell_explorer/templates/cell_explorer/panel.html +137 -0
- plexora/plugins/figure_builder/__init__.py +125 -0
- plexora/plugins/figure_builder/server/__init__.py +7 -0
- plexora/plugins/figure_builder/server/compose.py +365 -0
- plexora/plugins/figure_builder/server/export.py +784 -0
- plexora/plugins/figure_builder/server/export_jobs.py +148 -0
- plexora/plugins/figure_builder/server/operations.py +556 -0
- plexora/plugins/figure_builder/server/pixels.py +142 -0
- plexora/plugins/figure_builder/server/provenance.py +174 -0
- plexora/plugins/figure_builder/server/render.py +278 -0
- plexora/plugins/figure_builder/server/repository.py +701 -0
- plexora/plugins/figure_builder/server/routes.py +594 -0
- plexora/plugins/figure_builder/server/schema.py +1023 -0
- plexora/plugins/figure_builder/server/sources.py +120 -0
- plexora/plugins/figure_builder/server/textmetrics.py +155 -0
- plexora/plugins/figure_builder/static/figureActions.js +363 -0
- plexora/plugins/figure_builder/static/figureBuilderApi.js +255 -0
- plexora/plugins/figure_builder/static/figureCanvas.js +2362 -0
- plexora/plugins/figure_builder/static/figureCaptureBoxes.js +369 -0
- plexora/plugins/figure_builder/static/figureCaptureDock.js +466 -0
- plexora/plugins/figure_builder/static/figureCaptureTool.js +1034 -0
- plexora/plugins/figure_builder/static/figureConfirm.js +136 -0
- plexora/plugins/figure_builder/static/figureContextBar.js +1193 -0
- plexora/plugins/figure_builder/static/figureContextMenu.js +253 -0
- plexora/plugins/figure_builder/static/figureDocumentState.js +275 -0
- plexora/plugins/figure_builder/static/figureExportUi.js +174 -0
- plexora/plugins/figure_builder/static/figureLibrary.js +247 -0
- plexora/plugins/figure_builder/static/figureQuickEdit.js +628 -0
- plexora/plugins/figure_builder/static/figureRichText.js +634 -0
- plexora/plugins/figure_builder/static/figureSceneSnapshot.js +361 -0
- plexora/plugins/figure_builder/static/figureSchema.js +218 -0
- plexora/plugins/figure_builder/static/figureSidebarController.js +1159 -0
- plexora/plugins/figure_builder/static/figureTextEditor.js +503 -0
- plexora/plugins/figure_builder/static/figureTextPanel.js +491 -0
- plexora/plugins/figure_builder/static/figureViewOptions.js +273 -0
- plexora/plugins/figure_builder/static/figureWorkspace.js +2030 -0
- plexora/plugins/figure_builder/static/figure_builder.css +3292 -0
- plexora/plugins/figure_builder/templates/figure_builder/library.html +74 -0
- plexora/plugins/figure_builder/templates/figure_builder/workspace.html +27 -0
- plexora/plugins/figure_builder/templates/figure_builder/workspace_body.html +458 -0
- plexora/plugins/gating/__init__.py +76 -0
- plexora/plugins/gating/server/__init__.py +0 -0
- plexora/plugins/gating/server/anndata_gates.py +367 -0
- plexora/plugins/gating/server/database.py +16 -0
- plexora/plugins/gating/server/model.py +320 -0
- plexora/plugins/gating/server/routes.py +235 -0
- plexora/plugins/gating/static/csvGatingList.js +947 -0
- plexora/plugins/gating/static/gating.css +289 -0
- plexora/plugins/gating/static/gatingApi.js +221 -0
- plexora/plugins/gating/static/gatingSidebarController.js +513 -0
- plexora/plugins/gating/templates/gating/legacy.html +26 -0
- plexora/plugins/gating/templates/gating/panel.html +69 -0
- plexora/plugins/roi/__init__.py +95 -0
- plexora/plugins/roi/server/__init__.py +8 -0
- plexora/plugins/roi/server/adapters.py +655 -0
- plexora/plugins/roi/server/geojson.py +267 -0
- plexora/plugins/roi/server/geometry.py +181 -0
- plexora/plugins/roi/server/mapping.py +169 -0
- plexora/plugins/roi/server/operations.py +293 -0
- plexora/plugins/roi/server/repository.py +215 -0
- plexora/plugins/roi/server/routes.py +409 -0
- plexora/plugins/roi/server/schema.py +323 -0
- plexora/plugins/roi/static/roi.css +583 -0
- plexora/plugins/roi/static/roiApi.js +177 -0
- plexora/plugins/roi/static/roiFigureBridge.js +107 -0
- plexora/plugins/roi/static/roiGeometry.js +350 -0
- plexora/plugins/roi/static/roiRenderer.js +278 -0
- plexora/plugins/roi/static/roiSidebarController.js +1038 -0
- plexora/plugins/roi/static/roiState.js +518 -0
- plexora/plugins/roi/static/roiTools.js +993 -0
- plexora/plugins/roi/templates/roi/panel.html +240 -0
- plexora/proxy.py +57 -0
- plexora/server/models/adapters/__init__.py +94 -0
- plexora/server/models/adapters/anndata_adapter.py +388 -0
- plexora/server/models/adapters/base.py +93 -0
- plexora/server/models/adapters/classify.py +192 -0
- plexora/server/models/adapters/csv_adapter.py +105 -0
- plexora/server/models/adapters/inspection.py +265 -0
- plexora/server/models/adapters/spatialdata_adapter.py +248 -0
- plexora/server/models/centroid_tiles.py +305 -0
- plexora/server/models/data_model.py +1659 -0
- plexora/server/models/database_model.py +169 -0
- plexora/server/models/project.py +1270 -0
- plexora/server/plugins.py +199 -0
- plexora/server/routes/browse_routes.py +41 -0
- plexora/server/routes/data_routes.py +294 -0
- plexora/server/routes/import_routes.py +603 -0
- plexora/server/routes/page_routes.py +139 -0
- plexora/server/routes/project_routes.py +438 -0
- plexora/server/routes/quick_view_routes.py +62 -0
- plexora/server/routes/system_routes.py +39 -0
- plexora/server/routes/tool_routes.py +418 -0
- plexora/server/utils/addHEColumns.py +11 -0
- plexora/server/utils/fast_png.py +97 -0
- plexora/server/utils/fullConversion.py +50 -0
- plexora/server/utils/native_dialog.py +168 -0
- plexora/server/utils/pre_normalization.py +47 -0
- plexora/server/utils/segmentation_pyramid.py +598 -0
- plexora/server/utils/smallestenclosingcircle.py +126 -0
- plexora/server/utils/tiffsurgeon.py +372 -0
- plexora/server_cli.py +48 -0
- plexora/test.py +0 -0
- plexora-0.0.1.dist-info/METADATA +341 -0
- plexora-0.0.1.dist-info/RECORD +248 -0
- plexora-0.0.1.dist-info/WHEEL +5 -0
- plexora-0.0.1.dist-info/entry_points.txt +12 -0
- plexora-0.0.1.dist-info/licenses/LICENSE +207 -0
- plexora-0.0.1.dist-info/top_level.txt +1 -0
plexora/datasource.py
ADDED
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import polars as pl
|
|
9
|
+
|
|
10
|
+
from plexora.server.models.adapters.classify import classify_columns
|
|
11
|
+
from plexora.server.models.project import (
|
|
12
|
+
ColumnGroups,
|
|
13
|
+
ColumnRoles,
|
|
14
|
+
DataSpec,
|
|
15
|
+
ImageSpec,
|
|
16
|
+
Project,
|
|
17
|
+
SegmentationSpec,
|
|
18
|
+
write_config,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _now():
|
|
23
|
+
return datetime.datetime.now().isoformat()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _image_channel_entries(name, channel_info, channel_names, segmentation_path):
|
|
27
|
+
"""The `imageData` list: one entry per servable layer, in the order the
|
|
28
|
+
viewer expects -- the 'Area' segmentation placeholder first when there is
|
|
29
|
+
a mask, then one per image channel."""
|
|
30
|
+
entries = []
|
|
31
|
+
if segmentation_path:
|
|
32
|
+
label_name = _segmentation_channel_name(segmentation_path)
|
|
33
|
+
entries.append({
|
|
34
|
+
"name": "Area",
|
|
35
|
+
"fullname": "Area",
|
|
36
|
+
"src": f"/generated/data/{name}/{label_name}/",
|
|
37
|
+
})
|
|
38
|
+
generated = channel_info["channel_names"]
|
|
39
|
+
for idx in range(channel_info["num_channels"]):
|
|
40
|
+
display_name = str(channel_names[idx])
|
|
41
|
+
entries.append({
|
|
42
|
+
"name": display_name,
|
|
43
|
+
"fullname": display_name,
|
|
44
|
+
"src": f"/generated/data/{name}/{generated[idx]}/",
|
|
45
|
+
})
|
|
46
|
+
return entries
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _image_spec(name, image_path, channel_info, channel_names, segmentation_path):
|
|
50
|
+
return ImageSpec(
|
|
51
|
+
src=str(image_path),
|
|
52
|
+
kind="ome_tiff",
|
|
53
|
+
channels=tuple(
|
|
54
|
+
_image_channel_entries(name, channel_info, channel_names, segmentation_path)
|
|
55
|
+
),
|
|
56
|
+
width=channel_info["width"],
|
|
57
|
+
height=channel_info["height"],
|
|
58
|
+
max_level=channel_info["maxLevel"],
|
|
59
|
+
tile_width=channel_info["tileWidth"],
|
|
60
|
+
tile_height=channel_info["tileHeight"],
|
|
61
|
+
num_channels=channel_info["num_channels"],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _segmentation_spec(fields):
|
|
66
|
+
"""SegmentationSpec from what _segmentation_config_fields() produced."""
|
|
67
|
+
return SegmentationSpec(
|
|
68
|
+
derived=fields.get("segmentation"),
|
|
69
|
+
source=fields.get("segmentationSource"),
|
|
70
|
+
source_key=fields.get("segmentationSourceKey"),
|
|
71
|
+
mode=fields.get("segmentationMode"),
|
|
72
|
+
status=fields.get("segmentation_status", "ready"),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _copy_if_requested(path, target_dir, copy):
|
|
77
|
+
path = Path(path).expanduser().resolve()
|
|
78
|
+
if not copy:
|
|
79
|
+
return path
|
|
80
|
+
target = target_dir / path.name
|
|
81
|
+
if path != target:
|
|
82
|
+
# A SpatialData store is a .zarr *directory*, not a file -- copy2
|
|
83
|
+
# raises IsADirectoryError on it.
|
|
84
|
+
if path.is_dir():
|
|
85
|
+
shutil.copytree(path, target, dirs_exist_ok=True)
|
|
86
|
+
else:
|
|
87
|
+
shutil.copy2(path, target)
|
|
88
|
+
return target
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _segmentation_channel_name(segmentation_path):
|
|
92
|
+
name = Path(segmentation_path).name
|
|
93
|
+
lowered = name.lower()
|
|
94
|
+
for suffix in (".ome.tiff", ".ome.tif", ".tiff", ".tif", ".png", ".zarr"):
|
|
95
|
+
if lowered.endswith(suffix):
|
|
96
|
+
channel_name = name[: -len(suffix)]
|
|
97
|
+
break
|
|
98
|
+
else:
|
|
99
|
+
channel_name = Path(name).stem
|
|
100
|
+
if re.match(r".*_(\d*)$", channel_name):
|
|
101
|
+
channel_name = f"{channel_name}_segmentation"
|
|
102
|
+
return channel_name
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _segmentation_config_fields(segmentation_path, dataset_dir, segmentation_async,
|
|
106
|
+
segmentation_mode=None):
|
|
107
|
+
"""Build the segmentation-related config keys for a registration.
|
|
108
|
+
|
|
109
|
+
Returns (fields, source_needing_generation). A returned source means the
|
|
110
|
+
caller should start a background job for it *after* writing config.json,
|
|
111
|
+
since the job patches that same file when it completes.
|
|
112
|
+
|
|
113
|
+
`segmentationSource`/`segmentationSourceKey` are recorded either way: they
|
|
114
|
+
let a later load confirm the derived mask still matches its source with a
|
|
115
|
+
stat, instead of re-deriving it or re-sampling its pixels.
|
|
116
|
+
|
|
117
|
+
`segmentation_mode` picks what gets stored: "filled" (the default) stores a
|
|
118
|
+
filled label pyramid -- served untouched when the user's mask already is
|
|
119
|
+
one -- and leaves boundary-finding to renderLabelTile. "outlines" bakes the
|
|
120
|
+
boundaries into the file instead; nothing in the UI asks for it any more,
|
|
121
|
+
but it stays supported for callers that pass it explicitly.
|
|
122
|
+
"""
|
|
123
|
+
from plexora.server.models import data_model
|
|
124
|
+
from plexora.server.utils import segmentation_pyramid
|
|
125
|
+
|
|
126
|
+
if not segmentation_path:
|
|
127
|
+
return {"segmentation": None, "segmentation_status": "ready"}, None
|
|
128
|
+
|
|
129
|
+
mode = (
|
|
130
|
+
segmentation_pyramid.MODE_OUTLINES
|
|
131
|
+
if segmentation_mode == segmentation_pyramid.MODE_OUTLINES
|
|
132
|
+
else segmentation_pyramid.DEFAULT_MODE
|
|
133
|
+
)
|
|
134
|
+
fields = {
|
|
135
|
+
"segmentationSource": str(segmentation_path),
|
|
136
|
+
"segmentationSourceKey": segmentation_pyramid.source_fingerprint(segmentation_path),
|
|
137
|
+
"segmentationMode": mode,
|
|
138
|
+
}
|
|
139
|
+
if segmentation_async:
|
|
140
|
+
# Converting a large mask takes tens of seconds, far too long to hold a
|
|
141
|
+
# form submission open -- the import page waits on the job's reported
|
|
142
|
+
# progress instead and opens the viewer when it finishes.
|
|
143
|
+
fields["segmentation"] = None
|
|
144
|
+
fields["segmentation_status"] = "pending"
|
|
145
|
+
return fields, segmentation_path
|
|
146
|
+
|
|
147
|
+
label_info = data_model.convertOmeTiff(
|
|
148
|
+
segmentation_path,
|
|
149
|
+
dataDirectory=str(dataset_dir),
|
|
150
|
+
isLabelImg=True,
|
|
151
|
+
segmentation_mode_=mode,
|
|
152
|
+
)
|
|
153
|
+
fields["segmentation"] = label_info["segmentation"]
|
|
154
|
+
fields["segmentation_status"] = "ready"
|
|
155
|
+
return fields, None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _derive_dataset_name_from_path(path):
|
|
159
|
+
"""Server-side mirror of importFormValidation.js's deriveDatasetName() --
|
|
160
|
+
kept in sync deliberately (same suffix vocabulary) so a quick-viewed file
|
|
161
|
+
and a full-wizard import of the same file suggest the same base name."""
|
|
162
|
+
stem = Path(path).name
|
|
163
|
+
return re.sub(
|
|
164
|
+
r"\.(ome\.tiff|ome\.tif|ome\.zarr|tiff|tif|svs|zarr|png|jpg|jpeg|qptiff)$",
|
|
165
|
+
"",
|
|
166
|
+
stem,
|
|
167
|
+
flags=re.IGNORECASE,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _dedupe_dataset_name(base_name, existing_names):
|
|
172
|
+
"""Suffix base_name with _2, _3, ... until it doesn't collide with any of
|
|
173
|
+
existing_names -- callers pass get_config_names() in."""
|
|
174
|
+
existing = set(existing_names)
|
|
175
|
+
if base_name not in existing:
|
|
176
|
+
return base_name
|
|
177
|
+
i = 2
|
|
178
|
+
while f"{base_name}_{i}" in existing:
|
|
179
|
+
i += 1
|
|
180
|
+
return f"{base_name}_{i}"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _find_existing_datasource_for_image(image_path, config):
|
|
184
|
+
"""Return the name of an already-registered datasource pointing at the
|
|
185
|
+
same on-disk image file, or None. Every registration path (quick view,
|
|
186
|
+
the full import wizard, anndata) stamps config[name]['channelFile'] with
|
|
187
|
+
the image path it was given, so resolving both sides (expanduser,
|
|
188
|
+
symlinks, '..', relative vs. absolute) catches the same file being
|
|
189
|
+
quick-viewed twice, or quick-viewed after already being imported."""
|
|
190
|
+
try:
|
|
191
|
+
target = Path(image_path).expanduser().resolve()
|
|
192
|
+
except OSError:
|
|
193
|
+
return None
|
|
194
|
+
for name, entry in (config or {}).items():
|
|
195
|
+
channel_file = (entry or {}).get("channelFile")
|
|
196
|
+
if not channel_file:
|
|
197
|
+
continue
|
|
198
|
+
try:
|
|
199
|
+
if Path(channel_file).expanduser().resolve() == target:
|
|
200
|
+
return name
|
|
201
|
+
except OSError:
|
|
202
|
+
continue
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _sniff_quick_view_kind(path):
|
|
207
|
+
"""Classify a dropped/browsed file as 'ome_tiff' (goes through the full
|
|
208
|
+
multi-channel zarr/tile pipeline) or 'rgb' (flat single-image display,
|
|
209
|
+
no channels) purely by extension, with a PIL-based content sniff on the
|
|
210
|
+
RGB branch as a guard against a mislabeled file. Raises ValueError for
|
|
211
|
+
anything else -- quick view has no format-detection fallback."""
|
|
212
|
+
suffix = Path(path).suffix.lower()
|
|
213
|
+
if suffix in (".tif", ".tiff"):
|
|
214
|
+
return "ome_tiff"
|
|
215
|
+
if suffix in (".png", ".jpg", ".jpeg"):
|
|
216
|
+
from PIL import Image
|
|
217
|
+
with Image.open(path) as img:
|
|
218
|
+
img.verify()
|
|
219
|
+
return "rgb"
|
|
220
|
+
raise ValueError(f"Unsupported file type for quick view: {suffix or path}")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def rename_channels(name, channel_names, data_dir=None):
|
|
224
|
+
"""Rename an already-registered datasource's image channels in place --
|
|
225
|
+
used by the viewer's channel-names CSV upload to fix gating/channel
|
|
226
|
+
auto-matching after the fact, without re-registering (and re-running
|
|
227
|
+
image pyramid generation for) the whole datasource. Caller is
|
|
228
|
+
responsible for reloading the runtime datasource afterward (see
|
|
229
|
+
data_model.load_datasource(name, reload=True)) so the cached description
|
|
230
|
+
and in-memory config pick up the change.
|
|
231
|
+
"""
|
|
232
|
+
from plexora import paths
|
|
233
|
+
|
|
234
|
+
data_root = Path(data_dir).expanduser().resolve() if data_dir else paths.data_root()
|
|
235
|
+
try:
|
|
236
|
+
project = Project.load(name, data_root)
|
|
237
|
+
except KeyError:
|
|
238
|
+
raise ValueError(f"No datasource named {name!r}.") from None
|
|
239
|
+
|
|
240
|
+
renamable = [c for c in project.image.channels if c.get("name") != "Area"]
|
|
241
|
+
if len(channel_names) != len(renamable):
|
|
242
|
+
raise ValueError(
|
|
243
|
+
f"channel_names has {len(channel_names)} entries but {name!r} has {len(renamable)} channels."
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Rebuilt rather than mutated in place: ImageSpec is frozen, and editing
|
|
247
|
+
# the channel dicts it holds would also edit whatever the caller passed in.
|
|
248
|
+
new_names = list(str(n) for n in channel_names)
|
|
249
|
+
channels = []
|
|
250
|
+
for channel in project.image.channels:
|
|
251
|
+
channel = dict(channel)
|
|
252
|
+
if channel.get("name") != "Area":
|
|
253
|
+
renamed = new_names.pop(0)
|
|
254
|
+
channel["name"] = renamed
|
|
255
|
+
channel["fullname"] = renamed
|
|
256
|
+
channels.append(channel)
|
|
257
|
+
|
|
258
|
+
updated = project.patch(image=replace(project.image, channels=tuple(channels)))
|
|
259
|
+
return updated.save(data_root)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _channel_names_from_ome_xml(image_path, n_channels):
|
|
263
|
+
"""Channel names embedded in the image's own OME-XML metadata, if
|
|
264
|
+
present and if the count matches -- returns None otherwise. Shared by
|
|
265
|
+
derive_anndata_channel_names (tier 2 there) and derive_image_channel_names
|
|
266
|
+
(tier 2 here); a pure extraction, same tifffile/ome_types read as before."""
|
|
267
|
+
import tifffile as tf
|
|
268
|
+
from ome_types import from_xml
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
with tf.TiffFile(str(image_path), is_ome=False) as tiff:
|
|
272
|
+
xml = tiff.pages[0].tags['ImageDescription'].value
|
|
273
|
+
ome_channels = from_xml(xml).images[0].pixels.channels
|
|
274
|
+
ome_names = [c.name for c in ome_channels]
|
|
275
|
+
if len(ome_names) == n_channels and all(ome_names):
|
|
276
|
+
return [str(n) for n in ome_names]
|
|
277
|
+
except Exception:
|
|
278
|
+
pass
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def derive_image_channel_names(image_path, n_channels):
|
|
283
|
+
"""Resolve display names for a quick-view (no feature table) image:
|
|
284
|
+
OME-XML channel names if present and complete, else generic "Channel N".
|
|
285
|
+
Same tier-2/tier-4 logic as derive_anndata_channel_names, minus the
|
|
286
|
+
var_names/all_markers tiers that only make sense with an AnnData table.
|
|
287
|
+
"""
|
|
288
|
+
ome_names = _channel_names_from_ome_xml(image_path, n_channels)
|
|
289
|
+
if ome_names is not None:
|
|
290
|
+
return ome_names, "image metadata"
|
|
291
|
+
return [f"Channel {i + 1}" for i in range(n_channels)], "generic"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def derive_anndata_channel_names(image_path, features_path, n_channels):
|
|
295
|
+
"""Resolve a display name for every image channel, trying progressively
|
|
296
|
+
less-authoritative sources in order and only accepting one that accounts
|
|
297
|
+
for every channel -- a partial or wrong-length source is more likely to
|
|
298
|
+
silently mislabel a channel than a generic name is, so it's skipped:
|
|
299
|
+
|
|
300
|
+
1. adata.var_names (deduplicated) -- the marker panel actually used for
|
|
301
|
+
analysis/gating. Checked first (ahead of embedded image metadata)
|
|
302
|
+
because gating always matches channels to var_names by name; a
|
|
303
|
+
length match here is treated as proof the two are already in the
|
|
304
|
+
same per-channel order, so it's linked by index rather than by
|
|
305
|
+
comparing text -- var_names' own text is what's used, even if the
|
|
306
|
+
image's embedded metadata disagrees or uses different wording for
|
|
307
|
+
the same channels.
|
|
308
|
+
2. Channel names embedded in the image's own OME-XML metadata --
|
|
309
|
+
falls back to this only when var_names' length doesn't fit (e.g. QC
|
|
310
|
+
trimmed the panel), since matching gating out of the box beats a
|
|
311
|
+
more "authoritative" name that gating can't use.
|
|
312
|
+
3. adata.uns['all_markers'] -- some pipelines (e.g. scimap) keep the full
|
|
313
|
+
acquisition panel here separately from var_names, which may have been
|
|
314
|
+
trimmed by QC.
|
|
315
|
+
4. Generic "Channel N" names.
|
|
316
|
+
|
|
317
|
+
Whichever tier resolves, the result is returned alongside a short label
|
|
318
|
+
identifying the source, so callers can surface it to the user -- names
|
|
319
|
+
from tiers 2-4 are not guaranteed to match the vocabulary gating markers
|
|
320
|
+
use (e.g. a marker panel recorded as gene symbols in var_names vs.
|
|
321
|
+
antibody/clinical names in all_markers), so auto-matching between gating
|
|
322
|
+
and image channels may still require renaming channels (e.g. via the
|
|
323
|
+
channel-list CSV upload in the viewer) or manual matching there.
|
|
324
|
+
"""
|
|
325
|
+
import anndata as ad
|
|
326
|
+
|
|
327
|
+
adata = ad.read_h5ad(features_path, backed='r')
|
|
328
|
+
try:
|
|
329
|
+
return _derive_channel_names_from_adata(image_path, adata, n_channels)
|
|
330
|
+
finally:
|
|
331
|
+
if adata.isbacked:
|
|
332
|
+
adata.file.close()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def derive_spatialdata_channel_names(image_path, store, table, n_channels):
|
|
336
|
+
"""derive_anndata_channel_names() for one table inside a SpatialData
|
|
337
|
+
store -- identical tier order and semantics, since the resolved table is
|
|
338
|
+
an AnnData with its own var_names and uns['all_markers']."""
|
|
339
|
+
from plexora.server.models.adapters.spatialdata_adapter import read_spatialdata_table
|
|
340
|
+
|
|
341
|
+
adata = read_spatialdata_table(store, table)
|
|
342
|
+
return _derive_channel_names_from_adata(image_path, adata, n_channels)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _derive_channel_names_from_adata(image_path, adata, n_channels):
|
|
346
|
+
"""Tier logic of derive_anndata_channel_names(), against an already-open
|
|
347
|
+
AnnData so the .h5ad and SpatialData entry points share one
|
|
348
|
+
implementation. Caller owns opening and closing `adata`."""
|
|
349
|
+
from plexora.server.models.adapters.anndata_adapter import _deduplicate_names
|
|
350
|
+
|
|
351
|
+
var_names = _deduplicate_names([str(v) for v in adata.var_names])
|
|
352
|
+
if len(var_names) == n_channels:
|
|
353
|
+
return var_names, "adata.var_names"
|
|
354
|
+
|
|
355
|
+
ome_names = _channel_names_from_ome_xml(image_path, n_channels)
|
|
356
|
+
if ome_names is not None:
|
|
357
|
+
return ome_names, "image metadata"
|
|
358
|
+
|
|
359
|
+
all_markers = adata.uns.get('all_markers')
|
|
360
|
+
if all_markers is not None:
|
|
361
|
+
all_markers = [str(m) for m in all_markers]
|
|
362
|
+
if len(all_markers) == n_channels:
|
|
363
|
+
return all_markers, "adata.uns['all_markers']"
|
|
364
|
+
|
|
365
|
+
return [f"Channel {i + 1}" for i in range(n_channels)], "generic"
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def register_datasource(
|
|
369
|
+
name,
|
|
370
|
+
image,
|
|
371
|
+
features,
|
|
372
|
+
x=None,
|
|
373
|
+
y=None,
|
|
374
|
+
segmentation=None,
|
|
375
|
+
id_column=None,
|
|
376
|
+
celltype_column=None,
|
|
377
|
+
channel_names=None,
|
|
378
|
+
copy=False,
|
|
379
|
+
data_dir=None,
|
|
380
|
+
segmentation_async=False,
|
|
381
|
+
segmentation_mode=None,
|
|
382
|
+
):
|
|
383
|
+
"""Register a dataset in Plexora's config without using the upload UI.
|
|
384
|
+
|
|
385
|
+
`segmentation_async` defers mask conversion to a background job and leaves
|
|
386
|
+
`segmentation_status` as "pending"; callers then poll
|
|
387
|
+
/get_segmentation_status. It defaults to off so programmatic callers get a
|
|
388
|
+
fully-registered datasource back from a single call.
|
|
389
|
+
"""
|
|
390
|
+
from plexora import paths
|
|
391
|
+
from plexora.server.models import data_model
|
|
392
|
+
|
|
393
|
+
data_root = Path(data_dir).expanduser().resolve() if data_dir else paths.data_root()
|
|
394
|
+
dataset_dir = data_root / name
|
|
395
|
+
dataset_dir.mkdir(parents=True, exist_ok=True)
|
|
396
|
+
config_path = data_root / "config.json"
|
|
397
|
+
if not config_path.exists():
|
|
398
|
+
write_config(config_path, {})
|
|
399
|
+
|
|
400
|
+
image_path = _copy_if_requested(image, dataset_dir, copy)
|
|
401
|
+
segmentation_path = _copy_if_requested(segmentation, dataset_dir, copy) if segmentation else None
|
|
402
|
+
features_path = _copy_if_requested(features, dataset_dir, copy)
|
|
403
|
+
|
|
404
|
+
feature_table = pl.read_csv(features_path, n_rows=1)
|
|
405
|
+
|
|
406
|
+
# One predictor for the marker/metadata split and the column roles, shared
|
|
407
|
+
# with the import UI (adapters/classify.py). Explicit arguments win over
|
|
408
|
+
# its guesses -- a caller who named the coordinate columns has answered
|
|
409
|
+
# already -- and anything left unset is a role nobody has established yet,
|
|
410
|
+
# which is a legitimate state rather than an error. Whatever first needs
|
|
411
|
+
# one asks for it (plexora/api/plugin.py's Requires).
|
|
412
|
+
classified = classify_columns(
|
|
413
|
+
[{"name": c, "dtype": str(dt)} for c, dt in feature_table.schema.items()]
|
|
414
|
+
)
|
|
415
|
+
guessed = classified["roles"]
|
|
416
|
+
roles = ColumnRoles(
|
|
417
|
+
cell_id=id_column or guessed.get("cell_id"),
|
|
418
|
+
x=x or guessed.get("x"),
|
|
419
|
+
y=y or guessed.get("y"),
|
|
420
|
+
celltype=celltype_column or guessed.get("celltype"),
|
|
421
|
+
image_id=guessed.get("image_id"),
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
named = {role: column for role, column in roles.to_dict().items()}
|
|
425
|
+
missing = [c for c in named.values() if c not in feature_table.columns]
|
|
426
|
+
if missing:
|
|
427
|
+
raise ValueError("Missing feature column(s): " + ", ".join(sorted(missing)))
|
|
428
|
+
markers = [c for c in classified["markers"] if c not in named.values()]
|
|
429
|
+
metadata = [c for c in feature_table.columns if c not in markers]
|
|
430
|
+
|
|
431
|
+
channel_info = data_model.convertOmeTiff(image_path, isLabelImg=False)
|
|
432
|
+
segmentation_fields, pending_segmentation_source = _segmentation_config_fields(
|
|
433
|
+
segmentation_path, dataset_dir, segmentation_async, segmentation_mode
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
n_channels = channel_info["num_channels"]
|
|
437
|
+
if channel_names is None:
|
|
438
|
+
channel_names = markers[:n_channels]
|
|
439
|
+
if len(channel_names) < n_channels:
|
|
440
|
+
stem = image_path.name
|
|
441
|
+
channel_names = list(channel_names) + [f"{stem}_{i}" for i in range(len(channel_names), n_channels)]
|
|
442
|
+
|
|
443
|
+
project = Project(
|
|
444
|
+
name=name,
|
|
445
|
+
image=_image_spec(name, image_path, channel_info, channel_names, segmentation_path),
|
|
446
|
+
segmentation=_segmentation_spec(segmentation_fields),
|
|
447
|
+
dataset=DataSpec(
|
|
448
|
+
type="csv",
|
|
449
|
+
src=str(features_path),
|
|
450
|
+
roles=roles,
|
|
451
|
+
columns=ColumnGroups(markers=tuple(markers), metadata=tuple(metadata)),
|
|
452
|
+
),
|
|
453
|
+
created_at=_now(),
|
|
454
|
+
)
|
|
455
|
+
entry = project.save(data_root)
|
|
456
|
+
|
|
457
|
+
if pending_segmentation_source:
|
|
458
|
+
data_model.start_segmentation_job(
|
|
459
|
+
name, pending_segmentation_source, dataset_dir,
|
|
460
|
+
segmentation_fields["segmentationMode"],
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
return entry
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def register_anndata_datasource(
|
|
467
|
+
name,
|
|
468
|
+
image,
|
|
469
|
+
features=None,
|
|
470
|
+
adata=None,
|
|
471
|
+
segmentation=None,
|
|
472
|
+
coordinate_source=None,
|
|
473
|
+
obsm_key=None,
|
|
474
|
+
x=None,
|
|
475
|
+
y=None,
|
|
476
|
+
feature_source="X",
|
|
477
|
+
layer=None,
|
|
478
|
+
feature_obs_columns=None,
|
|
479
|
+
obs_id_field=None,
|
|
480
|
+
celltype_column=None,
|
|
481
|
+
subset_by=None,
|
|
482
|
+
subset_value=None,
|
|
483
|
+
apply_log_transform=False,
|
|
484
|
+
channel_names=None,
|
|
485
|
+
copy=False,
|
|
486
|
+
data_dir=None,
|
|
487
|
+
table=None,
|
|
488
|
+
segmentation_async=False,
|
|
489
|
+
segmentation_mode=None,
|
|
490
|
+
):
|
|
491
|
+
"""Register an AnnData (.h5ad)-backed dataset in Plexora's config.
|
|
492
|
+
|
|
493
|
+
Exactly one of `features` (a path to an existing .h5ad file) or `adata`
|
|
494
|
+
(an in-memory AnnData object) must be given -- an in-memory object is
|
|
495
|
+
always written to `<dataset_dir>/<name>.h5ad` first, since the runtime
|
|
496
|
+
server process (a separate subprocess for Jupyter) reads datasources
|
|
497
|
+
from config.json/disk, never from a Python object held by the caller.
|
|
498
|
+
|
|
499
|
+
`coordinate_source`/`feature_source` etc. mirror AnnDataAdapter's
|
|
500
|
+
dataSource config fields (see adapters/anndata_adapter.py); explicit
|
|
501
|
+
arguments always override auto-detection (requirements §8). Leaving
|
|
502
|
+
`coordinate_source` unset auto-detects adata.obsm['spatial'] if present
|
|
503
|
+
and unambiguous.
|
|
504
|
+
|
|
505
|
+
Setting `table` switches this to SpatialData mode: `features` is then a
|
|
506
|
+
.zarr store and `table` names the table inside it to load. Every other
|
|
507
|
+
argument keeps its meaning, because the selected table is itself an
|
|
508
|
+
AnnData -- only the reader differs (see adapters/spatialdata_adapter.py).
|
|
509
|
+
register_spatialdata_datasource() below is the friendlier entry point.
|
|
510
|
+
"""
|
|
511
|
+
from plexora import paths
|
|
512
|
+
from plexora.server.models import data_model
|
|
513
|
+
from plexora.server.models.adapters.anndata_adapter import AnnDataAdapter
|
|
514
|
+
from plexora.server.models.adapters.spatialdata_adapter import SpatialDataAdapter
|
|
515
|
+
|
|
516
|
+
if (adata is None) == (features is None):
|
|
517
|
+
raise ValueError("Provide exactly one of `adata` (in-memory) or `features` (.h5ad path)")
|
|
518
|
+
if table and adata is not None:
|
|
519
|
+
raise ValueError("`table` selects a table inside a .zarr store, so pass `features`, not `adata`")
|
|
520
|
+
|
|
521
|
+
data_root = Path(data_dir).expanduser().resolve() if data_dir else paths.data_root()
|
|
522
|
+
dataset_dir = data_root / name
|
|
523
|
+
dataset_dir.mkdir(parents=True, exist_ok=True)
|
|
524
|
+
config_path = data_root / "config.json"
|
|
525
|
+
if not config_path.exists():
|
|
526
|
+
write_config(config_path, {})
|
|
527
|
+
|
|
528
|
+
image_path = _copy_if_requested(image, dataset_dir, copy)
|
|
529
|
+
segmentation_path = _copy_if_requested(segmentation, dataset_dir, copy) if segmentation else None
|
|
530
|
+
|
|
531
|
+
if adata is not None:
|
|
532
|
+
features_path = dataset_dir / f"{name}.h5ad"
|
|
533
|
+
adata.write_h5ad(features_path)
|
|
534
|
+
else:
|
|
535
|
+
features_path = _copy_if_requested(features, dataset_dir, copy)
|
|
536
|
+
|
|
537
|
+
coordinates_config = {}
|
|
538
|
+
if coordinate_source == "obsm":
|
|
539
|
+
coordinates_config = {"source": "obsm", "obsm_key": obsm_key or "spatial"}
|
|
540
|
+
elif coordinate_source == "obs":
|
|
541
|
+
if not x or not y:
|
|
542
|
+
raise ValueError("x and y are required when coordinate_source='obs'")
|
|
543
|
+
coordinates_config = {"source": "obs", "x_column": x, "y_column": y}
|
|
544
|
+
elif coordinate_source is not None:
|
|
545
|
+
raise ValueError(f"Unknown coordinate_source: {coordinate_source!r}")
|
|
546
|
+
# coordinate_source left as None: coordinates_config stays empty and
|
|
547
|
+
# AnnDataAdapter auto-detects adata.obsm['spatial'] if unambiguous.
|
|
548
|
+
|
|
549
|
+
if feature_source == "X":
|
|
550
|
+
features_config = {"source": "X"}
|
|
551
|
+
elif feature_source == "layer":
|
|
552
|
+
if not layer:
|
|
553
|
+
raise ValueError("layer is required when feature_source='layer'")
|
|
554
|
+
features_config = {"source": "layer", "layer": layer}
|
|
555
|
+
elif feature_source == "obs":
|
|
556
|
+
if not feature_obs_columns:
|
|
557
|
+
raise ValueError("feature_obs_columns is required when feature_source='obs'")
|
|
558
|
+
features_config = {"source": "obs", "obs_columns": list(feature_obs_columns)}
|
|
559
|
+
else:
|
|
560
|
+
raise ValueError(f"Unknown feature_source: {feature_source!r}")
|
|
561
|
+
|
|
562
|
+
subset_config = {}
|
|
563
|
+
if subset_by:
|
|
564
|
+
subset_config = {"column": subset_by, "value": subset_value}
|
|
565
|
+
|
|
566
|
+
spec = DataSpec(
|
|
567
|
+
type="spatialdata" if table else "anndata",
|
|
568
|
+
# In SpatialData mode this is the *store root*, with the chosen table
|
|
569
|
+
# named alongside it, so a plugin needing the store's other elements
|
|
570
|
+
# (images/labels/shapes) can open it from here.
|
|
571
|
+
src=str(features_path),
|
|
572
|
+
table=str(table) if table else None,
|
|
573
|
+
coordinates=coordinates_config,
|
|
574
|
+
features=features_config,
|
|
575
|
+
subset=subset_config,
|
|
576
|
+
# True only when apply_log_transform is explicitly requested --
|
|
577
|
+
# no heuristic guessing at whether the chosen feature source "looks"
|
|
578
|
+
# already transformed. This also gates whether the gate slider/
|
|
579
|
+
# auto-gate keep float precision or round to whole numbers, so an
|
|
580
|
+
# incorrect guess here would silently destroy narrow-range gates
|
|
581
|
+
# (e.g. rounding a real [1.85, 2.23] gate to [1, 3] matches nearly
|
|
582
|
+
# every cell) -- the user's call, every time.
|
|
583
|
+
is_transformed=bool(apply_log_transform),
|
|
584
|
+
obs_id_field=obs_id_field,
|
|
585
|
+
roles=ColumnRoles(
|
|
586
|
+
# The adapter synthesizes X/Y columns with these literal names.
|
|
587
|
+
x="X",
|
|
588
|
+
y="Y",
|
|
589
|
+
# Defaults to the adapter's own positional "id" column (0..n-1,
|
|
590
|
+
# always int -- matches NormalizedDatasource.id_column), not
|
|
591
|
+
# DEFAULT_ID_COLUMN ("obs_id"). The cell_id role has to be
|
|
592
|
+
# uint32-castable: get_all_cells() packs [cell_id, X, Y] into one
|
|
593
|
+
# flat array and casts the whole thing to uint32 for the fast
|
|
594
|
+
# binary cell-loading path (numericData.js), which crashes if it
|
|
595
|
+
# holds adata.obs_names strings -- the common case, since those
|
|
596
|
+
# are rarely small integers. An explicit obs_id_field is still
|
|
597
|
+
# honored as-is; a non-numeric choice there is the caller's
|
|
598
|
+
# informed tradeoff, not a silent default.
|
|
599
|
+
#
|
|
600
|
+
# This is a description of the emitted table, NOT an answer to the
|
|
601
|
+
# cell-id question -- `obs_id_field` is where that lives, and it
|
|
602
|
+
# stays None here until somebody says otherwise. Reading the role
|
|
603
|
+
# as the answer is what let every import arrive pre-answered with
|
|
604
|
+
# a row number nobody chose (see plugin.py's `_answered`).
|
|
605
|
+
cell_id=obs_id_field or "id",
|
|
606
|
+
celltype=celltype_column,
|
|
607
|
+
image_id=subset_by or None,
|
|
608
|
+
),
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
# Validate end-to-end (subset/coordinates/features resolve, coordinates
|
|
612
|
+
# are finite, etc.) before writing anything or doing any expensive image
|
|
613
|
+
# pyramid work. The resolved table also tells us the marker/metadata split
|
|
614
|
+
# for free, which is why the result is kept rather than discarded: for
|
|
615
|
+
# AnnData the file already draws that line (var = markers, obs = metadata),
|
|
616
|
+
# so unlike CSV the user is never asked to confirm it.
|
|
617
|
+
adapter_class = SpatialDataAdapter if table else AnnDataAdapter
|
|
618
|
+
normalized = adapter_class(spec).load_table()
|
|
619
|
+
markers = list(normalized.feature_columns)
|
|
620
|
+
metadata = [c for c in normalized.table.columns if c not in set(markers)]
|
|
621
|
+
spec = replace(
|
|
622
|
+
spec,
|
|
623
|
+
columns=ColumnGroups(markers=tuple(markers), metadata=tuple(metadata)),
|
|
624
|
+
# Kept alongside the split, and not the same thing: `metadata` is what
|
|
625
|
+
# the loaded table holds, while these are the file's own annotations --
|
|
626
|
+
# the list a user picks from when saying which column holds the cell id
|
|
627
|
+
# or the coordinates (see Project.role_columns).
|
|
628
|
+
obs_columns=tuple(normalized.obs_columns),
|
|
629
|
+
# Likewise: the other matrices the file carries, so the choice of which
|
|
630
|
+
# one to threshold on stays changeable after import.
|
|
631
|
+
layers=tuple(normalized.layers),
|
|
632
|
+
# And the obsm arrays, so the coordinate source stays changeable too.
|
|
633
|
+
# Without these recorded the coordinate question has nothing to offer,
|
|
634
|
+
# and the importer's name-based pick is the only one there will ever be.
|
|
635
|
+
obsm=tuple(normalized.obsm),
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
channel_info = data_model.convertOmeTiff(image_path, isLabelImg=False)
|
|
639
|
+
segmentation_fields, pending_segmentation_source = _segmentation_config_fields(
|
|
640
|
+
segmentation_path, dataset_dir, segmentation_async, segmentation_mode
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
n_channels = channel_info["num_channels"]
|
|
644
|
+
if channel_names is None:
|
|
645
|
+
if table:
|
|
646
|
+
channel_names, _ = derive_spatialdata_channel_names(
|
|
647
|
+
image_path, features_path, table, n_channels
|
|
648
|
+
)
|
|
649
|
+
else:
|
|
650
|
+
channel_names, _ = derive_anndata_channel_names(image_path, features_path, n_channels)
|
|
651
|
+
elif len(channel_names) != n_channels:
|
|
652
|
+
raise ValueError(
|
|
653
|
+
f"channel_names has {len(channel_names)} entries but the image has {n_channels} channels."
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
project = Project(
|
|
657
|
+
name=name,
|
|
658
|
+
image=_image_spec(name, image_path, channel_info, channel_names, segmentation_path),
|
|
659
|
+
segmentation=_segmentation_spec(segmentation_fields),
|
|
660
|
+
dataset=spec,
|
|
661
|
+
created_at=_now(),
|
|
662
|
+
)
|
|
663
|
+
entry = project.save(data_root)
|
|
664
|
+
|
|
665
|
+
if pending_segmentation_source:
|
|
666
|
+
data_model.start_segmentation_job(
|
|
667
|
+
name, pending_segmentation_source, dataset_dir,
|
|
668
|
+
segmentation_fields["segmentationMode"],
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
return entry
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def register_spatialdata_datasource(
|
|
675
|
+
name,
|
|
676
|
+
image,
|
|
677
|
+
store,
|
|
678
|
+
table,
|
|
679
|
+
**kwargs,
|
|
680
|
+
):
|
|
681
|
+
"""Register one table of a SpatialData (.zarr) store as a dataset.
|
|
682
|
+
|
|
683
|
+
Thin wrapper over register_anndata_datasource() -- a SpatialData table is
|
|
684
|
+
an AnnData, so `coordinate_source`, `feature_source`, `subset_by`,
|
|
685
|
+
`celltype_column`, `channel_names`, `copy`, `data_dir` etc. all behave
|
|
686
|
+
exactly as they do there and are accepted as keyword arguments.
|
|
687
|
+
|
|
688
|
+
`store` is the .zarr store root and `table` is the name of the table
|
|
689
|
+
inside it (see spatialdata_adapter.list_spatialdata_tables() to
|
|
690
|
+
enumerate them). Only that one table is read, never the whole store.
|
|
691
|
+
"""
|
|
692
|
+
if not table:
|
|
693
|
+
raise ValueError("`table` is required -- name which table inside the .zarr store to load.")
|
|
694
|
+
return register_anndata_datasource(
|
|
695
|
+
name=name,
|
|
696
|
+
image=image,
|
|
697
|
+
features=store,
|
|
698
|
+
table=table,
|
|
699
|
+
**kwargs,
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def register_image_datasource(name, image, channel_names=None, copy=False, data_dir=None):
|
|
704
|
+
"""Register a datasource from just an OME-TIFF/TIFF image -- no feature
|
|
705
|
+
table, no segmentation. Used by the quick-view landing page for a fast
|
|
706
|
+
first look, and the floor of the new import flow: an image is the only
|
|
707
|
+
thing a project must have.
|
|
708
|
+
|
|
709
|
+
A project with no `dataset` block is the first-class "image only" state --
|
|
710
|
+
load_datasource(), load_ball_tree() and every direct consumer of the
|
|
711
|
+
feature table/ball tree check `project.has_table` rather than requiring a
|
|
712
|
+
real (or synthesized) feature CSV to exist on disk.
|
|
713
|
+
"""
|
|
714
|
+
from plexora import paths
|
|
715
|
+
from plexora.server.models import data_model
|
|
716
|
+
|
|
717
|
+
data_root = Path(data_dir).expanduser().resolve() if data_dir else paths.data_root()
|
|
718
|
+
dataset_dir = data_root / name
|
|
719
|
+
dataset_dir.mkdir(parents=True, exist_ok=True)
|
|
720
|
+
config_path = data_root / "config.json"
|
|
721
|
+
if not config_path.exists():
|
|
722
|
+
write_config(config_path, {})
|
|
723
|
+
|
|
724
|
+
image_path = _copy_if_requested(image, dataset_dir, copy)
|
|
725
|
+
|
|
726
|
+
channel_info = data_model.convertOmeTiff(image_path, isLabelImg=False)
|
|
727
|
+
n_channels = channel_info["num_channels"]
|
|
728
|
+
if channel_names is None:
|
|
729
|
+
channel_names, _ = derive_image_channel_names(image_path, n_channels)
|
|
730
|
+
elif len(channel_names) != n_channels:
|
|
731
|
+
raise ValueError(
|
|
732
|
+
f"channel_names has {len(channel_names)} entries but the image has {n_channels} channels."
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
# dataset=None is the explicit "no feature table" state. Everything that
|
|
736
|
+
# needs one -- load_datasource(), load_ball_tree(), and the Tools menu via
|
|
737
|
+
# Requires.missing_from() -- reads it as such, and the tool menu turns it
|
|
738
|
+
# into a request for the missing data rather than hiding the tool.
|
|
739
|
+
project = Project(
|
|
740
|
+
name=name,
|
|
741
|
+
image=_image_spec(name, image_path, channel_info, channel_names, None),
|
|
742
|
+
dataset=None,
|
|
743
|
+
created_at=_now(),
|
|
744
|
+
)
|
|
745
|
+
return project.save(data_root)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def register_rgb_datasource(name, image, copy=False, data_dir=None):
|
|
749
|
+
"""Register a datasource from a flat RGB image (PNG/JPEG) -- the
|
|
750
|
+
minimal quick-view path: view-only, no channels, no gating. Displayed
|
|
751
|
+
client-side via OpenSeadragon's native single-image tile source (see
|
|
752
|
+
RgbImageViewer), served whole by GET /generated/rgb/<name>, not tiled.
|
|
753
|
+
"""
|
|
754
|
+
from PIL import Image
|
|
755
|
+
|
|
756
|
+
from plexora import paths
|
|
757
|
+
|
|
758
|
+
data_root = Path(data_dir).expanduser().resolve() if data_dir else paths.data_root()
|
|
759
|
+
dataset_dir = data_root / name
|
|
760
|
+
dataset_dir.mkdir(parents=True, exist_ok=True)
|
|
761
|
+
|
|
762
|
+
image_path = _copy_if_requested(image, dataset_dir, copy)
|
|
763
|
+
with Image.open(image_path) as img:
|
|
764
|
+
width, height = img.size
|
|
765
|
+
|
|
766
|
+
project = Project(
|
|
767
|
+
name=name,
|
|
768
|
+
image=ImageSpec(
|
|
769
|
+
src=str(image_path),
|
|
770
|
+
# 'rgb' is permanently incompatible with marker tools -- a flat
|
|
771
|
+
# image has no channels to threshold. Requires.applies_to() reads
|
|
772
|
+
# this, so those tools are hidden rather than offered and blocked.
|
|
773
|
+
kind="rgb",
|
|
774
|
+
channels=(),
|
|
775
|
+
width=width,
|
|
776
|
+
height=height,
|
|
777
|
+
num_channels=0,
|
|
778
|
+
),
|
|
779
|
+
dataset=None,
|
|
780
|
+
created_at=_now(),
|
|
781
|
+
)
|
|
782
|
+
return project.save(data_root)
|