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
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import polars as pl
|
|
5
|
+
|
|
6
|
+
from .base import MetadataColumn, NormalizedDatasource
|
|
7
|
+
|
|
8
|
+
# Column name used to hold the resolved observation ID (adata.obs_names, or a
|
|
9
|
+
# custom obs column when `obs_id_field` is set) in the materialized table,
|
|
10
|
+
# when the caller doesn't provide an explicit obs_id_field. data_model.py's
|
|
11
|
+
# gating/query code (e.g. get_channel_cells) treats config['featureData'][0]
|
|
12
|
+
# ['idField'] as a literal column name that must exist in the loaded table --
|
|
13
|
+
# unlike CSV, AnnData has no single "the ID column" without this fallback.
|
|
14
|
+
DEFAULT_ID_COLUMN = "obs_id"
|
|
15
|
+
|
|
16
|
+
# These three column names are load-bearing: 'id' is the positional row index
|
|
17
|
+
# every downstream gating/query function keys off, 'X'/'Y' are the resolved
|
|
18
|
+
# coordinate columns. If a real obs column (e.g. a literal "id" column, seen
|
|
19
|
+
# in real exemplar data) were used as obs_id_field/celltype without this
|
|
20
|
+
# guard, it would silently overwrite one of these dict keys when the table is
|
|
21
|
+
# built below -- corrupting the positional id or coordinates rather than
|
|
22
|
+
# raising. See _reject_reserved_collisions().
|
|
23
|
+
_RESERVED_COLUMN_NAMES = {"id", "X", "Y"}
|
|
24
|
+
|
|
25
|
+
# The "does this column identify an image/sample/region" heuristic lives in
|
|
26
|
+
# classify.py, which owns every column-name vocabulary in one place. Re-exported
|
|
27
|
+
# here because this module is where the ambiguity guard below enforces it, and
|
|
28
|
+
# adapters/inspection.py has always imported it from this name.
|
|
29
|
+
from .classify import is_likely_image_identifier_name # noqa: F401
|
|
30
|
+
from .classify import is_numeric_dtype
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def describe_obsm(adata) -> list[dict]:
|
|
34
|
+
"""Each obsm array as {"name", "shape"}.
|
|
35
|
+
|
|
36
|
+
Lives here rather than in inspection.py because both this adapter and that
|
|
37
|
+
module need it, and inspection already imports from this direction --
|
|
38
|
+
the reverse would be a cycle.
|
|
39
|
+
|
|
40
|
+
Shape is read off the array's own metadata, never by materializing it: a
|
|
41
|
+
backed h5ad and a zarr group both report it without a read, and an
|
|
42
|
+
embedding on a million-cell table is not something to load in order to
|
|
43
|
+
label a dropdown. An entry whose shape cannot be determined still appears,
|
|
44
|
+
without one -- leaving it out would hide a candidate, which is the failure
|
|
45
|
+
this list exists to prevent.
|
|
46
|
+
"""
|
|
47
|
+
entries = []
|
|
48
|
+
for name in adata.obsm.keys():
|
|
49
|
+
if not name:
|
|
50
|
+
continue
|
|
51
|
+
entry = {"name": str(name)}
|
|
52
|
+
shape = getattr(adata.obsm[name], "shape", None)
|
|
53
|
+
if shape is not None:
|
|
54
|
+
entry["shape"] = [int(dim) for dim in shape]
|
|
55
|
+
entries.append(entry)
|
|
56
|
+
return entries
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _likely_image_identifier_columns(adata) -> list[str]:
|
|
60
|
+
candidates = []
|
|
61
|
+
for column in adata.obs.columns:
|
|
62
|
+
if not is_likely_image_identifier_name(column):
|
|
63
|
+
continue
|
|
64
|
+
if adata.obs[column].nunique(dropna=True) > 1:
|
|
65
|
+
candidates.append(column)
|
|
66
|
+
return candidates
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _deduplicate_names(names: list[str]) -> list[str]:
|
|
70
|
+
"""Real multiplexed-imaging panels commonly re-stain/re-image the same
|
|
71
|
+
marker across cycles (e.g. PTPRC/CD45 twice), producing duplicate
|
|
72
|
+
adata.var_names -- confirmed against real exemplar data. Auto-suffixing
|
|
73
|
+
(matching anndata's own var_names_make_unique() convention) is more
|
|
74
|
+
useful than hard-failing on a very ordinary occurrence.
|
|
75
|
+
"""
|
|
76
|
+
seen: dict[str, int] = {}
|
|
77
|
+
result = []
|
|
78
|
+
for name in names:
|
|
79
|
+
if name not in seen:
|
|
80
|
+
seen[name] = 0
|
|
81
|
+
result.append(name)
|
|
82
|
+
else:
|
|
83
|
+
seen[name] += 1
|
|
84
|
+
result.append(f"{name}_{seen[name]}")
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class AnnDataAdapter:
|
|
89
|
+
"""Adapter for AnnData (.h5ad)-backed datasources.
|
|
90
|
+
|
|
91
|
+
Takes the project's DataSpec (server/models/project.py). `coordinates`,
|
|
92
|
+
`features` and `subset` are the read spec -- how to get from the file to a
|
|
93
|
+
table -- and are the adapter's own vocabulary; the project record stores
|
|
94
|
+
them without interpreting them. Roles describe the table that comes out.
|
|
95
|
+
|
|
96
|
+
The table this produces always has a positional 'id' column plus 'X'/'Y',
|
|
97
|
+
which is why those three names are reserved below.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, spec):
|
|
101
|
+
self.spec = spec
|
|
102
|
+
self.path = spec.src
|
|
103
|
+
self.coordinates = dict(spec.coordinates or {})
|
|
104
|
+
self.features = dict(spec.features or {}) or {'source': 'X'}
|
|
105
|
+
self.subset = dict(spec.subset or {})
|
|
106
|
+
self.celltype_column = spec.roles.celltype
|
|
107
|
+
# The obs column the user named as the image identifier, if they did.
|
|
108
|
+
# An answer beats the name heuristic below: it is the only way to catch
|
|
109
|
+
# a table keyed on a column called "roi" or "core", which the heuristic
|
|
110
|
+
# does not recognise and would wave through.
|
|
111
|
+
self.image_id_column = spec.roles.image_id
|
|
112
|
+
# Which obs column supplies the identifier, or None for the positional
|
|
113
|
+
# row index (the default -- see the uint32-packing note in
|
|
114
|
+
# datasource.py). Deliberately read from the read spec rather than
|
|
115
|
+
# inferred from the cell_id role: an obs column literally named "id"
|
|
116
|
+
# exists in real data, and it must hit the reserved-name guard below
|
|
117
|
+
# rather than being mistaken for "just number the rows".
|
|
118
|
+
self.obs_id_field = spec.obs_id_field
|
|
119
|
+
# Explicit opt-in only -- no heuristic guessing at whether the
|
|
120
|
+
# chosen feature source "looks" already transformed. Same contract and
|
|
121
|
+
# same transform as CsvAdapter, so a threshold means the same thing
|
|
122
|
+
# whichever format the project was imported from.
|
|
123
|
+
self.apply_log_transform = bool(spec.is_transformed)
|
|
124
|
+
|
|
125
|
+
def _read_adata(self):
|
|
126
|
+
"""The one format-specific step in load_table() -- everything after
|
|
127
|
+
this point is plain AnnData manipulation and is shared verbatim by
|
|
128
|
+
SpatialDataAdapter, which overrides only this method (see
|
|
129
|
+
adapters/spatialdata_adapter.py)."""
|
|
130
|
+
import anndata as ad
|
|
131
|
+
|
|
132
|
+
return ad.read_h5ad(self.path)
|
|
133
|
+
|
|
134
|
+
def _read_obs(self):
|
|
135
|
+
"""adata.obs on its own, as a pandas DataFrame.
|
|
136
|
+
|
|
137
|
+
Deliberately not `self._read_adata().obs`: that materializes X, which is
|
|
138
|
+
the expensive part of the file and the one part a metadata column can
|
|
139
|
+
never need. Reading the obs element alone turns "colour cells by a
|
|
140
|
+
phenotype column" from a full re-import into a directory read.
|
|
141
|
+
|
|
142
|
+
`anndata.io.read_elem` is the public reader for one element of an
|
|
143
|
+
on-disk AnnData; the private path is the fallback for older versions,
|
|
144
|
+
matching what the ROI plugin's exporter does against the same files.
|
|
145
|
+
"""
|
|
146
|
+
import h5py
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
from anndata.io import read_elem # anndata >= 0.10, public API
|
|
150
|
+
except ImportError: # pragma: no cover - older anndata
|
|
151
|
+
from anndata._io.specs import read_elem
|
|
152
|
+
|
|
153
|
+
with h5py.File(self.path, "r") as handle:
|
|
154
|
+
return read_elem(handle["obs"])
|
|
155
|
+
|
|
156
|
+
def _subset_mask(self, obs):
|
|
157
|
+
"""The rows load_table() keeps, or None when it keeps every row.
|
|
158
|
+
|
|
159
|
+
The single source of truth for the subset, so anything reading obs
|
|
160
|
+
outside load_table() lands on the same cells. Getting this wrong is not
|
|
161
|
+
a visible error -- the values simply belong to different cells than the
|
|
162
|
+
ones on screen, shifted by however many rows the other image contributed.
|
|
163
|
+
"""
|
|
164
|
+
column = self.subset.get('column')
|
|
165
|
+
if not column:
|
|
166
|
+
return None
|
|
167
|
+
if column not in obs.columns:
|
|
168
|
+
raise ValueError(f"Subset column {column!r} not found in adata.obs")
|
|
169
|
+
value = self.subset.get('value')
|
|
170
|
+
mask = obs[column].astype(str).to_numpy() == str(value)
|
|
171
|
+
if not mask.any():
|
|
172
|
+
raise ValueError(f"No observations match {column}={value!r}")
|
|
173
|
+
return mask
|
|
174
|
+
|
|
175
|
+
def read_obs_column(self, name: str) -> MetadataColumn:
|
|
176
|
+
"""One obs column, subset the same way the loaded table was.
|
|
177
|
+
|
|
178
|
+
Raises KeyError for a column the file does not have, which is how the
|
|
179
|
+
caller tells "no such column" from "this format has nothing to add"
|
|
180
|
+
(CsvAdapter returns None for the latter).
|
|
181
|
+
"""
|
|
182
|
+
obs = self._read_obs()
|
|
183
|
+
if name not in obs.columns:
|
|
184
|
+
raise KeyError(name)
|
|
185
|
+
column = obs[name]
|
|
186
|
+
mask = self._subset_mask(obs)
|
|
187
|
+
if mask is not None:
|
|
188
|
+
column = column[mask]
|
|
189
|
+
|
|
190
|
+
categories = None
|
|
191
|
+
if hasattr(column, "cat"):
|
|
192
|
+
# The file's own level order (see MetadataColumn.categories). Taken
|
|
193
|
+
# before the values are flattened to strings, which is the only
|
|
194
|
+
# moment it still exists.
|
|
195
|
+
categories = tuple(str(level) for level in column.cat.categories)
|
|
196
|
+
column = column.astype(object)
|
|
197
|
+
values = column.to_numpy()
|
|
198
|
+
return MetadataColumn(name=name, values=values, categories=categories)
|
|
199
|
+
|
|
200
|
+
def load_table(self) -> NormalizedDatasource:
|
|
201
|
+
adata = self._read_adata()
|
|
202
|
+
|
|
203
|
+
subset_column = self.subset.get('column')
|
|
204
|
+
if subset_column:
|
|
205
|
+
mask = self._subset_mask(adata.obs)
|
|
206
|
+
adata = adata[mask].copy()
|
|
207
|
+
else:
|
|
208
|
+
named = self.image_id_column
|
|
209
|
+
if named and named in adata.obs.columns:
|
|
210
|
+
# The user told us which column identifies the image, so ask
|
|
211
|
+
# that column rather than guessing which one to ask. This is
|
|
212
|
+
# the check the name heuristic below cannot make: it only fires
|
|
213
|
+
# for conventionally-named columns, so a table keyed on "roi"
|
|
214
|
+
# or "core" loaded whole and drew several images' cells over
|
|
215
|
+
# one image, with nothing said.
|
|
216
|
+
images = adata.obs[named].astype(str).nunique(dropna=True)
|
|
217
|
+
if images > 1:
|
|
218
|
+
raise ValueError(
|
|
219
|
+
f"Column {named!r} covers {images} images, but no "
|
|
220
|
+
"subset was specified -- loading them all would draw "
|
|
221
|
+
"several images' cells over one image. Choose which "
|
|
222
|
+
"image to load."
|
|
223
|
+
)
|
|
224
|
+
else:
|
|
225
|
+
ambiguous = _likely_image_identifier_columns(adata)
|
|
226
|
+
if ambiguous:
|
|
227
|
+
raise ValueError(
|
|
228
|
+
"AnnData object has candidate image/sample identifier "
|
|
229
|
+
f"column(s) {ambiguous} with more than one distinct "
|
|
230
|
+
"value, but no subset was specified -- refusing to "
|
|
231
|
+
"silently load all observations. Set dataSource.subset "
|
|
232
|
+
"(or subset_by/subset_value) to pick one image/sample."
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
n_obs = adata.n_obs
|
|
236
|
+
if n_obs == 0:
|
|
237
|
+
raise ValueError("Resolved AnnData subset has zero observations")
|
|
238
|
+
|
|
239
|
+
x_values, y_values = self._resolve_coordinates(adata)
|
|
240
|
+
if not np.isfinite(x_values).all() or not np.isfinite(y_values).all():
|
|
241
|
+
raise ValueError("Resolved X/Y coordinates contain non-finite values")
|
|
242
|
+
|
|
243
|
+
feature_matrix, feature_names = self._resolve_features(adata)
|
|
244
|
+
|
|
245
|
+
if self.obs_id_field:
|
|
246
|
+
if self.obs_id_field not in adata.obs.columns:
|
|
247
|
+
raise ValueError(f"obs_id_field {self.obs_id_field!r} not found in adata.obs")
|
|
248
|
+
if self.obs_id_field in _RESERVED_COLUMN_NAMES:
|
|
249
|
+
raise ValueError(
|
|
250
|
+
f"obs_id_field {self.obs_id_field!r} collides with a reserved "
|
|
251
|
+
f"column name ({sorted(_RESERVED_COLUMN_NAMES)}) -- choose a "
|
|
252
|
+
"different observation ID column, or leave it unset to use "
|
|
253
|
+
"adata.obs_names."
|
|
254
|
+
)
|
|
255
|
+
column = adata.obs[self.obs_id_field]
|
|
256
|
+
# A numeric obs column stays numeric. The usual reason to name one
|
|
257
|
+
# here is that it holds the segmentation mask's own label values,
|
|
258
|
+
# and the centroid cache packs the cell id into a uint32 -- a
|
|
259
|
+
# stringified integer only survives that round trip by being parsed
|
|
260
|
+
# back out again. adata.obs_names below has no such expectation and
|
|
261
|
+
# is genuinely text, so it keeps the str cast.
|
|
262
|
+
id_values = (column.to_numpy() if is_numeric_dtype(column.dtype)
|
|
263
|
+
else column.astype(str).to_numpy())
|
|
264
|
+
id_field_name = self.obs_id_field
|
|
265
|
+
else:
|
|
266
|
+
id_values = np.asarray([str(v) for v in adata.obs_names])
|
|
267
|
+
id_field_name = DEFAULT_ID_COLUMN
|
|
268
|
+
|
|
269
|
+
feature_names = _deduplicate_names(feature_names)
|
|
270
|
+
reserved = _RESERVED_COLUMN_NAMES | {id_field_name}
|
|
271
|
+
collisions = [name for name in feature_names if name in reserved]
|
|
272
|
+
if collisions:
|
|
273
|
+
raise ValueError(f"Feature name(s) collide with reserved columns: {collisions}")
|
|
274
|
+
|
|
275
|
+
columns = {
|
|
276
|
+
"id": np.arange(n_obs, dtype=np.int64),
|
|
277
|
+
"X": x_values,
|
|
278
|
+
"Y": y_values,
|
|
279
|
+
id_field_name: id_values,
|
|
280
|
+
}
|
|
281
|
+
for i, feature_name in enumerate(feature_names):
|
|
282
|
+
values = feature_matrix[:, i].astype(np.float64)
|
|
283
|
+
values = np.where(np.isneginf(values), 0.0, values)
|
|
284
|
+
if self.apply_log_transform:
|
|
285
|
+
values = np.log1p(values)
|
|
286
|
+
columns[feature_name] = values
|
|
287
|
+
|
|
288
|
+
celltype_column = None
|
|
289
|
+
if self.celltype_column:
|
|
290
|
+
if self.celltype_column not in adata.obs.columns:
|
|
291
|
+
raise ValueError(f"celltype column {self.celltype_column!r} not found in adata.obs")
|
|
292
|
+
if self.celltype_column in reserved:
|
|
293
|
+
raise ValueError(
|
|
294
|
+
f"celltype column {self.celltype_column!r} collides with a "
|
|
295
|
+
f"reserved/ID column name -- choose a different column."
|
|
296
|
+
)
|
|
297
|
+
columns[self.celltype_column] = adata.obs[self.celltype_column].astype(str).to_numpy()
|
|
298
|
+
celltype_column = self.celltype_column
|
|
299
|
+
|
|
300
|
+
table = pl.DataFrame(columns)
|
|
301
|
+
source_obs_ids = [str(v) for v in adata.obs_names]
|
|
302
|
+
|
|
303
|
+
return NormalizedDatasource(
|
|
304
|
+
table=table,
|
|
305
|
+
id_column="id",
|
|
306
|
+
source_obs_ids=source_obs_ids,
|
|
307
|
+
x_column="X",
|
|
308
|
+
y_column="Y",
|
|
309
|
+
feature_columns=list(feature_names),
|
|
310
|
+
celltype_column=celltype_column,
|
|
311
|
+
obs_columns=[str(c) for c in adata.obs.columns],
|
|
312
|
+
# anndata's Layers mapping can report a spurious `None` key
|
|
313
|
+
# (observed with anndata 0.13.2) even when no such layer exists --
|
|
314
|
+
# filtered out here the same way adapters/inspection.py does.
|
|
315
|
+
layers=[str(k) for k in adata.layers.keys() if k],
|
|
316
|
+
# Recorded so the coordinate question has candidates to offer
|
|
317
|
+
# without reopening the file. Same codec as inspection's, so the
|
|
318
|
+
# import path and the edit page describe an array identically.
|
|
319
|
+
obsm=describe_obsm(adata),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def _resolve_coordinates(self, adata):
|
|
323
|
+
source = self.coordinates.get('source')
|
|
324
|
+
if source is None:
|
|
325
|
+
if 'spatial' not in adata.obsm:
|
|
326
|
+
raise ValueError(
|
|
327
|
+
"No coordinate source specified and adata.obsm['spatial'] "
|
|
328
|
+
"is absent -- set dataSource.coordinates (coordinate_source"
|
|
329
|
+
"='obsm'/'obs' plus the relevant keys/columns) explicitly."
|
|
330
|
+
)
|
|
331
|
+
source, obsm_key = 'obsm', 'spatial'
|
|
332
|
+
else:
|
|
333
|
+
obsm_key = self.coordinates.get('obsm_key')
|
|
334
|
+
|
|
335
|
+
if source == 'obsm':
|
|
336
|
+
obsm_key = obsm_key or 'spatial'
|
|
337
|
+
if obsm_key not in adata.obsm:
|
|
338
|
+
raise ValueError(f"obsm key {obsm_key!r} not found in adata.obsm")
|
|
339
|
+
xy = np.asarray(adata.obsm[obsm_key])
|
|
340
|
+
if xy.ndim != 2 or xy.shape[1] < 2:
|
|
341
|
+
raise ValueError(f"adata.obsm[{obsm_key!r}] must be 2D with at least 2 columns")
|
|
342
|
+
return xy[:, 0].astype(np.float64), xy[:, 1].astype(np.float64)
|
|
343
|
+
|
|
344
|
+
if source == 'obs':
|
|
345
|
+
x_col = self.coordinates.get('x_column')
|
|
346
|
+
y_col = self.coordinates.get('y_column')
|
|
347
|
+
if not x_col or not y_col:
|
|
348
|
+
raise ValueError(
|
|
349
|
+
"dataSource.coordinates.x_column/y_column are required "
|
|
350
|
+
"when coordinates.source='obs'"
|
|
351
|
+
)
|
|
352
|
+
if x_col not in adata.obs.columns or y_col not in adata.obs.columns:
|
|
353
|
+
raise ValueError(f"Coordinate columns {x_col!r}/{y_col!r} not found in adata.obs")
|
|
354
|
+
x_values = adata.obs[x_col].to_numpy(dtype=np.float64)
|
|
355
|
+
y_values = adata.obs[y_col].to_numpy(dtype=np.float64)
|
|
356
|
+
return x_values, y_values
|
|
357
|
+
|
|
358
|
+
raise ValueError(f"Unknown coordinates.source: {source!r}")
|
|
359
|
+
|
|
360
|
+
def _resolve_features(self, adata):
|
|
361
|
+
source = self.features.get('source', 'X')
|
|
362
|
+
if source == 'X':
|
|
363
|
+
matrix = adata.X
|
|
364
|
+
names = [str(v) for v in adata.var_names]
|
|
365
|
+
elif source == 'layer':
|
|
366
|
+
layer = self.features.get('layer')
|
|
367
|
+
if not layer:
|
|
368
|
+
raise ValueError("dataSource.features.layer is required when features.source='layer'")
|
|
369
|
+
if layer not in adata.layers:
|
|
370
|
+
raise ValueError(f"Layer {layer!r} not found in adata.layers")
|
|
371
|
+
matrix = adata.layers[layer]
|
|
372
|
+
names = [str(v) for v in adata.var_names]
|
|
373
|
+
elif source == 'obs':
|
|
374
|
+
obs_columns = self.features.get('obs_columns') or []
|
|
375
|
+
if not obs_columns:
|
|
376
|
+
raise ValueError("dataSource.features.obs_columns is required when features.source='obs'")
|
|
377
|
+
missing = [c for c in obs_columns if c not in adata.obs.columns]
|
|
378
|
+
if missing:
|
|
379
|
+
raise ValueError(f"obs feature columns not found in adata.obs: {missing}")
|
|
380
|
+
matrix = adata.obs[obs_columns].to_numpy(dtype=np.float64)
|
|
381
|
+
names = list(obs_columns)
|
|
382
|
+
else:
|
|
383
|
+
raise ValueError(f"Unknown features.source: {source!r}")
|
|
384
|
+
|
|
385
|
+
if hasattr(matrix, 'toarray'):
|
|
386
|
+
matrix = matrix.toarray()
|
|
387
|
+
matrix = np.asarray(matrix, dtype=np.float64)
|
|
388
|
+
return matrix, names
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import polars as pl
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class MetadataColumn:
|
|
12
|
+
"""One annotation column, aligned row-for-row with the loaded table.
|
|
13
|
+
|
|
14
|
+
Exists because `NormalizedDatasource.table` is deliberately narrow for the
|
|
15
|
+
structural formats: AnnDataAdapter materializes id/X/Y/the id field/the
|
|
16
|
+
markers/the celltype column and nothing else, so an arbitrary `.obs` column
|
|
17
|
+
is named by `obs_columns` but is not IN the table. A tool that colours cells
|
|
18
|
+
by an annotation needs the values, and re-reading the whole file to get one
|
|
19
|
+
column would cost the same as the import did.
|
|
20
|
+
|
|
21
|
+
`categories` is the source's own category order when it declares one -- a
|
|
22
|
+
pandas Categorical in obs. It is carried rather than re-derived because it
|
|
23
|
+
is the one ordering that cannot be recovered from the values: a legend
|
|
24
|
+
sorted alphabetically puts "Stage 10" before "Stage 2", and a file that
|
|
25
|
+
already says what order its levels go in should be believed. None means the
|
|
26
|
+
source said nothing, and the caller is free to sort.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
values: np.ndarray
|
|
31
|
+
categories: tuple[str, ...] | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class NormalizedDatasource:
|
|
36
|
+
"""Source-agnostic view of an observation/cell table.
|
|
37
|
+
|
|
38
|
+
`table` always carries a positional 'id' column plus the resolved
|
|
39
|
+
x/y/feature/celltype columns under the exact names data_model.py's
|
|
40
|
+
downstream gating/query code already expects (the same names stored in
|
|
41
|
+
config[...]['featureData'][0]['xCoordinate']/['yCoordinate']/etc.) --
|
|
42
|
+
adapters differ only in how they produce this table, never in its shape.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
table: pl.DataFrame
|
|
46
|
+
id_column: str
|
|
47
|
+
source_obs_ids: list[str]
|
|
48
|
+
x_column: str
|
|
49
|
+
y_column: str
|
|
50
|
+
feature_columns: list[str]
|
|
51
|
+
celltype_column: str | None
|
|
52
|
+
obs_metadata: pl.DataFrame | None = None
|
|
53
|
+
# The two below describe what else the SOURCE offers -- not what this table
|
|
54
|
+
# ended up holding. They exist for adapters that build `table` from a read
|
|
55
|
+
# spec rather than reading a file verbatim, and they are what a user later
|
|
56
|
+
# picks from when changing that spec. Both are empty for a CSV, where the
|
|
57
|
+
# table's columns already are the file's columns and there is one matrix.
|
|
58
|
+
|
|
59
|
+
#: The source file's own annotation column names, in its own order. What a
|
|
60
|
+
#: user chooses between when saying which column holds the cell id or the
|
|
61
|
+
#: coordinates -- see Project.role_columns.
|
|
62
|
+
obs_columns: list[str] = field(default_factory=list)
|
|
63
|
+
#: The names of the extra expression matrices the file carries alongside
|
|
64
|
+
#: `X`. What a user chooses between when saying which one holds the marker
|
|
65
|
+
#: intensities -- see Project.feature_source.
|
|
66
|
+
layers: list[str] = field(default_factory=list)
|
|
67
|
+
#: The source file's obsm arrays as {"name", "shape"}. What a user chooses
|
|
68
|
+
#: between when saying where the cell coordinates are -- see
|
|
69
|
+
#: Project.coordinate_options. The shape is carried because it is the only
|
|
70
|
+
#: other thing on offer, and even it cannot separate a position from an
|
|
71
|
+
#: embedding: `spatial` and `X_umap` are routinely both (n, 2) float32.
|
|
72
|
+
obsm: list[dict] = field(default_factory=list)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class DatasourceAdapter(Protocol):
|
|
76
|
+
"""One instance per (config entry, load). Stateless with respect to
|
|
77
|
+
data_model.py's module globals -- an adapter only knows how to turn a
|
|
78
|
+
featureData config dict into a NormalizedDatasource. All I/O happens in
|
|
79
|
+
load_table(), called once per load_datasource().
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def load_table(self) -> NormalizedDatasource: ...
|
|
83
|
+
|
|
84
|
+
def read_obs_column(self, name: str) -> MetadataColumn | None:
|
|
85
|
+
"""One annotation column the loaded table does not carry.
|
|
86
|
+
|
|
87
|
+
None means "there is no such thing here" rather than "not found": a CSV
|
|
88
|
+
adapter's table IS the file, so every column is already in `frame()` and
|
|
89
|
+
there is nothing this could add. Adapters that build a table from a read
|
|
90
|
+
spec return the values, subset exactly as `load_table()` subset them, so
|
|
91
|
+
the result lines up row-for-row with the loaded frame.
|
|
92
|
+
"""
|
|
93
|
+
...
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Which columns are markers, and which are metadata.
|
|
2
|
+
|
|
3
|
+
This used to be answered in five places that disagreed: three hardcoded
|
|
4
|
+
denylists (`import_routes.listNotMarkers`, `datasource._default_marker_columns`,
|
|
5
|
+
`channelMatch.js markers_notToTransform`) and two independent "numeric column
|
|
6
|
+
that produced a histogram" derivations (`api/dataset.TableHandle.markers`,
|
|
7
|
+
`datasetContext.js`). Adding a morphology column meant remembering all of them.
|
|
8
|
+
|
|
9
|
+
It is one question about the imported data, so it gets one answer, computed
|
|
10
|
+
once at import and stored on the project (`DataSpec.columns`). Everything
|
|
11
|
+
downstream reads the stored answer rather than re-deriving it.
|
|
12
|
+
|
|
13
|
+
The prediction is a starting point, not a verdict -- for CSV the user confirms
|
|
14
|
+
it on the classification screen and drags anything we got wrong. Being roughly
|
|
15
|
+
right matters much more than being subtly clever, so the rules are blunt and
|
|
16
|
+
name-based, and deliberately biased towards calling a column metadata: a
|
|
17
|
+
metadata column wrongly offered as a marker shows up as a nonsense histogram in
|
|
18
|
+
every plugin, while a marker wrongly filed as metadata is one drag away.
|
|
19
|
+
|
|
20
|
+
Stdlib only, on purpose. `anndata_adapter` imports the name heuristics from
|
|
21
|
+
here rather than the other way round, so nothing that merely wants to classify
|
|
22
|
+
column names pays for numpy/polars/anndata.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
from typing import Any, Iterable, Mapping
|
|
29
|
+
|
|
30
|
+
# Conventional names for the column identifying which image/sample/region an
|
|
31
|
+
# observation belongs to. Also used by AnnDataAdapter to decide whether a file
|
|
32
|
+
# might span several images and therefore needs an explicit subset choice --
|
|
33
|
+
# deliberately not "any categorical column", since cell_type/cluster/condition
|
|
34
|
+
# are common in ordinary single-image data and must not trigger that.
|
|
35
|
+
_LIKELY_IMAGE_IDENTIFIER_NAMES = {
|
|
36
|
+
"imageid", "image", "sample", "sampleid", "region", "regionid",
|
|
37
|
+
"roi", "fov", "well", "slide", "core",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Non-marker columns a quantification table conventionally carries. Matched
|
|
41
|
+
# against the normalized name, so "X_centroid", "x centroid" and "XCentroid"
|
|
42
|
+
# are one entry. Grouped by what they are, because that is how they get
|
|
43
|
+
# extended.
|
|
44
|
+
_METADATA_NAMES = {
|
|
45
|
+
# identity
|
|
46
|
+
"id", "cellid", "cellindex", "objectid", "object", "label", "index",
|
|
47
|
+
# position
|
|
48
|
+
"x", "y", "xcentroid", "ycentroid", "centroidx", "centroidy",
|
|
49
|
+
"xposition", "yposition", "xcoordinate", "ycoordinate",
|
|
50
|
+
"columncentroid", "rowcentroid", "column", "row", "globalx", "globaly",
|
|
51
|
+
# morphology
|
|
52
|
+
"area", "convexarea", "filledarea", "bboxarea", "perimeter",
|
|
53
|
+
"majoraxislength", "minoraxislength", "axismajorlength", "axisminorlength",
|
|
54
|
+
"eccentricity", "solidity", "extent", "orientation", "circularity",
|
|
55
|
+
"equivalentdiameter", "feretdiametermax", "eulernumber", "formfactor",
|
|
56
|
+
"elongation", "compactness", "roundness", "aspectratio",
|
|
57
|
+
# annotation
|
|
58
|
+
"phenotype", "celltype", "cluster", "leiden", "louvain", "condition",
|
|
59
|
+
"treatment", "patient", "donor", "batch", "replicate", "timepoint",
|
|
60
|
+
} | _LIKELY_IMAGE_IDENTIFIER_NAMES
|
|
61
|
+
|
|
62
|
+
# Suffixes that mark a column as a derived measurement of something rather
|
|
63
|
+
# than an intensity: "CD3_area", "DNA1_orientation". Checked after the exact
|
|
64
|
+
# names above so a marker literally called "Area" still resolves.
|
|
65
|
+
_METADATA_SUFFIXES = (
|
|
66
|
+
"centroid", "area", "perimeter", "eccentricity", "solidity", "extent",
|
|
67
|
+
"orientation", "axislength", "diameter", "circularity",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
#: Role -> ordered patterns, most specific first. The first column matching the
|
|
71
|
+
#: earliest pattern wins, so "X_centroid" beats a bare "X" for the x role but a
|
|
72
|
+
#: table with only "X" still resolves.
|
|
73
|
+
_ROLE_PATTERNS = {
|
|
74
|
+
"cell_id": (r"^cellid$", r"^cellindex$", r"^objectid$", r"^cell$", r"^label$", r"^id$"),
|
|
75
|
+
"x": (r"^xcentroid$", r"^centroidx$", r"^xposition$", r"^xcoordinate$",
|
|
76
|
+
r"^columncentroid$", r"^globalx$", r"^x$"),
|
|
77
|
+
"y": (r"^ycentroid$", r"^centroidy$", r"^yposition$", r"^ycoordinate$",
|
|
78
|
+
r"^rowcentroid$", r"^globaly$", r"^y$"),
|
|
79
|
+
"celltype": (r"^phenotype$", r"^celltype$", r"^cluster$", r"^leiden$"),
|
|
80
|
+
"image_id": tuple(rf"^{re.escape(name)}$" for name in sorted(_LIKELY_IMAGE_IDENTIFIER_NAMES)),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_NUMERIC_DTYPE = re.compile(r"int|float|double|number", re.IGNORECASE)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def normalize_column_name(name: Any) -> str:
|
|
87
|
+
"""Strip separators and case so the name vocabularies above stay short."""
|
|
88
|
+
return re.sub(r"[\s_\-.]+", "", str(name)).lower()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def is_likely_image_identifier_name(column_name: Any) -> bool:
|
|
92
|
+
"""Whether a column name looks like it identifies an image/sample/region."""
|
|
93
|
+
return normalize_column_name(column_name) in _LIKELY_IMAGE_IDENTIFIER_NAMES
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def is_numeric_dtype(dtype: Any) -> bool:
|
|
97
|
+
"""Whether a reported dtype string is numeric.
|
|
98
|
+
|
|
99
|
+
Takes the string rather than the dtype object so this works for polars,
|
|
100
|
+
numpy and pandas alike, and so a caller with only a JSON payload (the
|
|
101
|
+
client sends dtypes as strings) gets the same answer.
|
|
102
|
+
"""
|
|
103
|
+
return bool(_NUMERIC_DTYPE.search(str(dtype or "")))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def looks_like_metadata(name: Any, dtype: Any = None) -> bool:
|
|
107
|
+
"""Whether one column should start life in the metadata box."""
|
|
108
|
+
normalized = normalize_column_name(name)
|
|
109
|
+
if not normalized:
|
|
110
|
+
return True
|
|
111
|
+
if dtype is not None and not is_numeric_dtype(dtype):
|
|
112
|
+
# A marker is an intensity. Anything non-numeric is an annotation,
|
|
113
|
+
# whatever it is called.
|
|
114
|
+
return True
|
|
115
|
+
if normalized in _METADATA_NAMES:
|
|
116
|
+
return True
|
|
117
|
+
return any(normalized.endswith(suffix) for suffix in _METADATA_SUFFIXES)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def guess_roles(columns: Iterable[Any]) -> dict:
|
|
121
|
+
"""Best guess at which column fills each role.
|
|
122
|
+
|
|
123
|
+
Returns only the roles it is confident about; an unresolved role is simply
|
|
124
|
+
absent, which the project record stores as None and something may later ask
|
|
125
|
+
the user to fill in.
|
|
126
|
+
"""
|
|
127
|
+
names = [str(c) for c in columns]
|
|
128
|
+
by_normalized = {}
|
|
129
|
+
for name in names:
|
|
130
|
+
by_normalized.setdefault(normalize_column_name(name), name)
|
|
131
|
+
|
|
132
|
+
roles = {}
|
|
133
|
+
taken = set()
|
|
134
|
+
for role, patterns in _ROLE_PATTERNS.items():
|
|
135
|
+
for pattern in patterns:
|
|
136
|
+
match = next(
|
|
137
|
+
(original for normalized, original in by_normalized.items()
|
|
138
|
+
if original not in taken and re.match(pattern, normalized)),
|
|
139
|
+
None,
|
|
140
|
+
)
|
|
141
|
+
if match is not None:
|
|
142
|
+
roles[role] = match
|
|
143
|
+
taken.add(match)
|
|
144
|
+
break
|
|
145
|
+
return roles
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def classify_columns(columns: Iterable[Mapping[str, Any] | str]) -> dict:
|
|
149
|
+
"""Split columns into markers and metadata, and guess the column roles.
|
|
150
|
+
|
|
151
|
+
`columns` is either bare names or `{"name": ..., "dtype": ...}` mappings;
|
|
152
|
+
supplying dtypes makes the split markedly better, since every non-numeric
|
|
153
|
+
column is metadata regardless of its name.
|
|
154
|
+
|
|
155
|
+
Returns `{"markers": [...], "metadata": [...], "roles": {...}}` with the
|
|
156
|
+
input order preserved inside each group -- a table's own column order is
|
|
157
|
+
usually meaningful to whoever produced it, and reordering makes the
|
|
158
|
+
classification screen harder to check.
|
|
159
|
+
"""
|
|
160
|
+
names, dtypes = [], {}
|
|
161
|
+
for column in columns or ():
|
|
162
|
+
if isinstance(column, Mapping):
|
|
163
|
+
name = column.get("name")
|
|
164
|
+
if name is None:
|
|
165
|
+
continue
|
|
166
|
+
names.append(str(name))
|
|
167
|
+
dtypes[str(name)] = column.get("dtype")
|
|
168
|
+
else:
|
|
169
|
+
names.append(str(column))
|
|
170
|
+
dtypes[str(column)] = None
|
|
171
|
+
|
|
172
|
+
metadata = [n for n in names if looks_like_metadata(n, dtypes.get(n))]
|
|
173
|
+
metadata_set = set(metadata)
|
|
174
|
+
markers = [n for n in names if n not in metadata_set]
|
|
175
|
+
return {"markers": markers, "metadata": metadata, "roles": guess_roles(names)}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def classify_from_inspection(inspection: Mapping[str, Any]) -> dict:
|
|
179
|
+
"""The split for an AnnData or SpatialData table, taken from its structure.
|
|
180
|
+
|
|
181
|
+
`var_names` are markers and `obs` columns are metadata by construction --
|
|
182
|
+
the file already draws the line this screen exists to draw for CSV, so the
|
|
183
|
+
user is never asked to confirm it. Roles are still guessed from the obs
|
|
184
|
+
names, since which obs column holds the cell id is not structural.
|
|
185
|
+
"""
|
|
186
|
+
obs_columns = inspection.get("obs_columns") or []
|
|
187
|
+
obs_names = [c.get("name") for c in obs_columns if isinstance(c, Mapping) and c.get("name")]
|
|
188
|
+
return {
|
|
189
|
+
"markers": [str(v) for v in (inspection.get("var_names") or ())],
|
|
190
|
+
"metadata": [str(n) for n in obs_names],
|
|
191
|
+
"roles": guess_roles(obs_names),
|
|
192
|
+
}
|