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,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import polars as pl
|
|
6
|
+
|
|
7
|
+
from .base import NormalizedDatasource
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CsvAdapter:
|
|
11
|
+
"""Adapter for the flat-CSV feature-table workflow.
|
|
12
|
+
|
|
13
|
+
Takes the project's DataSpec (server/models/project.py): `src` says where
|
|
14
|
+
the file is, `roles` say what its columns mean. A role the project never
|
|
15
|
+
recorded is None here, which is why the coordinate columns are optional --
|
|
16
|
+
a CSV imported but not yet fully described still loads, it just has no
|
|
17
|
+
usable coordinates until something asks the user for them.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, spec):
|
|
21
|
+
self.csv_path = Path(spec.src)
|
|
22
|
+
self.x_column = spec.roles.x
|
|
23
|
+
self.y_column = spec.roles.y
|
|
24
|
+
self.id_field = spec.roles.cell_id
|
|
25
|
+
self.celltype_column = spec.roles.celltype
|
|
26
|
+
# The marker/metadata split the user confirmed at import. A CSV header
|
|
27
|
+
# does not draw that line itself, which is the whole reason the
|
|
28
|
+
# classification screen exists -- so this is the only thing that knows
|
|
29
|
+
# Area and Eccentricity are measurements rather than stains.
|
|
30
|
+
self.marker_columns = list(spec.columns.markers)
|
|
31
|
+
# Explicit opt-in only -- no heuristic guessing at whether the values
|
|
32
|
+
# "look" already transformed. Same contract as AnnDataAdapter, which is
|
|
33
|
+
# where this used to be honoured and only there: a CSV kept the flag
|
|
34
|
+
# and read straight past it, so a user who asked for the transform got
|
|
35
|
+
# a project that said it was transformed and was not.
|
|
36
|
+
self.apply_log_transform = bool(spec.is_transformed)
|
|
37
|
+
|
|
38
|
+
def read_obs_column(self, name: str):
|
|
39
|
+
"""Always None: a CSV's table IS the file.
|
|
40
|
+
|
|
41
|
+
There is no second place to look -- `load_table()` reads every column,
|
|
42
|
+
so anything a caller could ask for is already in `frame()`. Returning
|
|
43
|
+
None says "nothing to add here", which is what lets the caller treat a
|
|
44
|
+
column that is missing from both as genuinely unknown rather than as a
|
|
45
|
+
format it forgot to handle.
|
|
46
|
+
"""
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
def load_table(self) -> NormalizedDatasource:
|
|
50
|
+
df = pl.read_csv(self.csv_path)
|
|
51
|
+
# Manufacture a stable positional 'id' column, mirroring pandas'
|
|
52
|
+
# implicit RangeIndex usage in the code this replaced -- must happen
|
|
53
|
+
# immediately after read_csv, before any other transform, since
|
|
54
|
+
# downstream code treats 'id' as a stable per-row identity.
|
|
55
|
+
df = df.with_row_index("id").with_columns(pl.col("id").cast(pl.Int64))
|
|
56
|
+
numeric_cols = [c for c, dt in df.schema.items() if dt in (pl.Float32, pl.Float64)]
|
|
57
|
+
df = df.with_columns([
|
|
58
|
+
pl.when(pl.col(c) == float("-inf")).then(0).otherwise(pl.col(c)).alias(c)
|
|
59
|
+
for c in numeric_cols
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
if self.id_field and self.id_field in df.columns:
|
|
63
|
+
source_obs_ids = df[self.id_field].cast(pl.Utf8).to_list()
|
|
64
|
+
else:
|
|
65
|
+
source_obs_ids = df["id"].cast(pl.Utf8).to_list()
|
|
66
|
+
|
|
67
|
+
feature_columns = self._feature_columns(df)
|
|
68
|
+
if self.apply_log_transform:
|
|
69
|
+
# After the -inf guard above and over the markers only, matching
|
|
70
|
+
# AnnDataAdapter: transforming a coordinate or a cell id would move
|
|
71
|
+
# every cell on the image. Numeric ones at that -- the split is the
|
|
72
|
+
# user's to correct, so a text column can end up in the marker box,
|
|
73
|
+
# and log1p on it is an error rather than a bad number.
|
|
74
|
+
transform = [c for c in feature_columns if df.schema[c].is_numeric()]
|
|
75
|
+
df = df.with_columns([pl.col(c).log1p().alias(c) for c in transform])
|
|
76
|
+
|
|
77
|
+
return NormalizedDatasource(
|
|
78
|
+
table=df,
|
|
79
|
+
id_column="id",
|
|
80
|
+
source_obs_ids=source_obs_ids,
|
|
81
|
+
x_column=self.x_column,
|
|
82
|
+
y_column=self.y_column,
|
|
83
|
+
feature_columns=feature_columns,
|
|
84
|
+
celltype_column=self.celltype_column,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def _feature_columns(self, df) -> list[str]:
|
|
88
|
+
"""Which columns hold marker intensities.
|
|
89
|
+
|
|
90
|
+
The recorded split when there is one, narrowed to columns this file
|
|
91
|
+
actually has: the answer can outlive the file it was given for, and a
|
|
92
|
+
marker naming a column that is gone would be log-transformed into a
|
|
93
|
+
polars error and offered as a gate with no data behind it.
|
|
94
|
+
|
|
95
|
+
Everything-but-the-roles is the fallback, for a project registered
|
|
96
|
+
before the classification screen ran. It cannot tell a stain from a
|
|
97
|
+
measurement -- that is what the screen is for -- so it is a last resort
|
|
98
|
+
and not the answer.
|
|
99
|
+
"""
|
|
100
|
+
excluded = {"id", self.x_column, self.y_column, self.id_field, self.celltype_column}
|
|
101
|
+
recorded = [c for c in self.marker_columns
|
|
102
|
+
if c in df.columns and c not in excluded]
|
|
103
|
+
if recorded:
|
|
104
|
+
return recorded
|
|
105
|
+
return [c for c in df.columns if c not in excluded]
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from . import classify
|
|
6
|
+
from .anndata_adapter import describe_obsm, is_likely_image_identifier_name
|
|
7
|
+
|
|
8
|
+
# Read-only structural inspection of not-yet-registered source files -- used
|
|
9
|
+
# by the standalone import wizard (a future Stage 3 endpoint) to populate a
|
|
10
|
+
# progressive-disclosure config UI, and reusable directly from Jupyter/tests.
|
|
11
|
+
# Deliberately separate from data_model.py's module-global-based caching:
|
|
12
|
+
# these operate on files that aren't loaded (or registered) yet.
|
|
13
|
+
|
|
14
|
+
_MAX_CANDIDATE_VALUES = 500
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _is_subset_candidate(series) -> bool:
|
|
18
|
+
"""A column is worth offering as an image/sample subset choice if it's
|
|
19
|
+
non-numeric (categorical/string-like) with more than one, but not an
|
|
20
|
+
unreasonably large number of, distinct values."""
|
|
21
|
+
try:
|
|
22
|
+
if np.issubdtype(series.dtype, np.number):
|
|
23
|
+
return False
|
|
24
|
+
except TypeError:
|
|
25
|
+
pass
|
|
26
|
+
n_unique = series.nunique(dropna=True)
|
|
27
|
+
return 1 < n_unique <= _MAX_CANDIDATE_VALUES
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _inspect_adata(adata) -> dict:
|
|
31
|
+
"""The format-independent half of inspection: everything the config UI
|
|
32
|
+
needs about an already-opened AnnData. Shared by inspect_anndata() and
|
|
33
|
+
inspect_spatialdata_table(), since a SpatialData table *is* an AnnData
|
|
34
|
+
and its config page offers exactly the same choices."""
|
|
35
|
+
obs_columns = []
|
|
36
|
+
for column in adata.obs.columns:
|
|
37
|
+
series = adata.obs[column]
|
|
38
|
+
is_candidate = _is_subset_candidate(series)
|
|
39
|
+
n_unique = series.nunique(dropna=True)
|
|
40
|
+
# Same name heuristic the adapter's own ambiguity guard enforces
|
|
41
|
+
# (adapters/anndata_adapter.py) -- lets the UI reserve its
|
|
42
|
+
# "this file may span multiple images" warning for genuine
|
|
43
|
+
# identifier-shaped columns instead of every categorical column
|
|
44
|
+
# (cell_type/cluster/condition columns are common and would
|
|
45
|
+
# otherwise trigger a misleading warning on ordinary single-image data).
|
|
46
|
+
likely_identifier = is_candidate and is_likely_image_identifier_name(column) and n_unique > 1
|
|
47
|
+
# Every column gets its actual values so the subset picker can
|
|
48
|
+
# always offer a dropdown of real categories instead of asking the
|
|
49
|
+
# user to type a value freehand -- is_subset_candidate above only
|
|
50
|
+
# gates the ambiguity-warning heuristic, not whether a column's
|
|
51
|
+
# values are shown at all. But a column with more distinct values
|
|
52
|
+
# than _MAX_CANDIDATE_VALUES (e.g. a per-cell numeric ID or
|
|
53
|
+
# centroid coordinate -- effectively unique per row) is never a
|
|
54
|
+
# usable subset choice, and for a real hundreds-of-thousands-of-rows
|
|
55
|
+
# table, listing it anyway inflates this inspection payload to tens
|
|
56
|
+
# of MB. A config page embeds that payload as a literal
|
|
57
|
+
# JavaScript object ({{data|tojson}}, not JSON.parse'd), so a
|
|
58
|
+
# bloated payload freezes the importing browser tab outright --
|
|
59
|
+
# capping here is what keeps that page loadable for large
|
|
60
|
+
# single-cell tables (confirmed against a real 686k-cell exemplar,
|
|
61
|
+
# whose id/centroid columns alone produced a 30MB payload).
|
|
62
|
+
values = (
|
|
63
|
+
sorted(str(v) for v in series.dropna().unique().tolist())
|
|
64
|
+
if n_unique <= _MAX_CANDIDATE_VALUES
|
|
65
|
+
else []
|
|
66
|
+
)
|
|
67
|
+
entry = {
|
|
68
|
+
"name": column,
|
|
69
|
+
"dtype": str(series.dtype),
|
|
70
|
+
"is_subset_candidate": is_candidate,
|
|
71
|
+
"likely_multi_image_identifier": likely_identifier,
|
|
72
|
+
"values": values,
|
|
73
|
+
}
|
|
74
|
+
obs_columns.append(entry)
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
"obs_count": int(adata.n_obs),
|
|
78
|
+
# anndata's Layers/AxisArrays mappings can report a spurious
|
|
79
|
+
# `None` key (observed with anndata 0.13.2) even when no real
|
|
80
|
+
# layer/obsm entry of that name exists -- filter it out.
|
|
81
|
+
"obsm_keys": [k for k in adata.obsm.keys() if k],
|
|
82
|
+
# The same arrays with their shapes, which is what a user picking
|
|
83
|
+
# between them actually needs: `spatial` and `X_umap` are routinely both
|
|
84
|
+
# (n, 2) float32, so the name is the only thing separating a cell's
|
|
85
|
+
# position from its embedding -- and the name is exactly what cannot be
|
|
86
|
+
# trusted. Kept beside `obsm_keys` rather than replacing it so existing
|
|
87
|
+
# callers of that key are untouched.
|
|
88
|
+
"obsm": describe_obsm(adata),
|
|
89
|
+
"layers": [k for k in adata.layers.keys() if k],
|
|
90
|
+
"obs_columns": obs_columns,
|
|
91
|
+
"var_names": [str(v) for v in adata.var_names],
|
|
92
|
+
"n_var": int(adata.n_vars),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def inspect_csv(path) -> dict:
|
|
97
|
+
"""Structural inspection of a flat CSV: its columns and their dtypes.
|
|
98
|
+
|
|
99
|
+
Reads a small number of rows rather than the file: a real quantification
|
|
100
|
+
table runs to millions of rows, and nothing here needs values -- only
|
|
101
|
+
names, and enough of each column to tell a number from a label. Polars
|
|
102
|
+
infers from the rows it is given, so a column that is empty at the top of
|
|
103
|
+
the file can be misread; that is acceptable because the user confirms the
|
|
104
|
+
split on the classification screen, which is the whole reason it exists.
|
|
105
|
+
"""
|
|
106
|
+
import polars as pl
|
|
107
|
+
|
|
108
|
+
frame = pl.read_csv(path, n_rows=_DTYPE_SAMPLE_ROWS)
|
|
109
|
+
columns = [{"name": name, "dtype": str(dtype)} for name, dtype in frame.schema.items()]
|
|
110
|
+
return {
|
|
111
|
+
"data_type": "csv",
|
|
112
|
+
"columns": columns,
|
|
113
|
+
**classify.classify_columns(columns),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
#: Rows read to infer CSV dtypes. Enough to get past a sparse first row,
|
|
118
|
+
#: cheap enough to run on every keystroke-triggered inspection.
|
|
119
|
+
_DTYPE_SAMPLE_ROWS = 200
|
|
120
|
+
|
|
121
|
+
#: obsm keys that conventionally hold cell centroids, in preference order.
|
|
122
|
+
_SPATIAL_OBSM_KEYS = ("spatial", "X_spatial", "spatial_centroid")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def propose_read_spec(inspection: dict) -> dict:
|
|
126
|
+
"""How to read this file, worked out from its own structure.
|
|
127
|
+
|
|
128
|
+
The import page asks for a path and nothing else, so everything the old
|
|
129
|
+
two-step config form made the user choose has to be answered from the file
|
|
130
|
+
itself. Almost all of it can be: `obsm['spatial']` is near-universal for
|
|
131
|
+
centroids, `X` is the feature matrix by definition, and a positional cell
|
|
132
|
+
id is the right default regardless (see the uint32-packing note in
|
|
133
|
+
datasource.py).
|
|
134
|
+
|
|
135
|
+
Returns the read spec plus `ambiguous`: the columns that make the file span
|
|
136
|
+
several images, which is the one thing that genuinely cannot be guessed --
|
|
137
|
+
picking an image for the user would silently load the wrong cells. The
|
|
138
|
+
caller turns that into a subset choice on the form.
|
|
139
|
+
|
|
140
|
+
An unresolved coordinate source is NOT an error here. It leaves the x/y
|
|
141
|
+
roles unset, and whatever first needs them asks for them.
|
|
142
|
+
"""
|
|
143
|
+
obsm_keys = inspection.get("obsm_keys") or []
|
|
144
|
+
obs_columns = inspection.get("obs_columns") or []
|
|
145
|
+
names = [c.get("name") for c in obs_columns if c.get("name")]
|
|
146
|
+
|
|
147
|
+
coordinates = {}
|
|
148
|
+
obsm_key = next((k for k in _SPATIAL_OBSM_KEYS if k in obsm_keys), None)
|
|
149
|
+
if obsm_key:
|
|
150
|
+
coordinates = {"source": "obsm", "obsm_key": obsm_key}
|
|
151
|
+
else:
|
|
152
|
+
guessed = classify.guess_roles(names)
|
|
153
|
+
if guessed.get("x") and guessed.get("y"):
|
|
154
|
+
coordinates = {"source": "obs", "x_column": guessed["x"], "y_column": guessed["y"]}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
"coordinates": coordinates,
|
|
158
|
+
"features": {"source": "X"},
|
|
159
|
+
"ambiguous": [
|
|
160
|
+
c["name"] for c in obs_columns if c.get("likely_multi_image_identifier")
|
|
161
|
+
],
|
|
162
|
+
**classify.classify_from_inspection(inspection),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def inspect_anndata(path) -> dict:
|
|
167
|
+
"""Structural inspection of an .h5ad file: obsm keys, layers, obs
|
|
168
|
+
columns (flagging which look like image/sample subset candidates and,
|
|
169
|
+
for those, their available values), var names, and observation count.
|
|
170
|
+
"""
|
|
171
|
+
import anndata as ad
|
|
172
|
+
|
|
173
|
+
adata = ad.read_h5ad(path, backed='r')
|
|
174
|
+
try:
|
|
175
|
+
return {"data_type": "anndata", **_inspect_adata(adata)}
|
|
176
|
+
finally:
|
|
177
|
+
if adata.isbacked:
|
|
178
|
+
adata.file.close()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def source_layers(spec) -> list[str]:
|
|
182
|
+
"""The extra expression matrices a project's source file carries.
|
|
183
|
+
|
|
184
|
+
Recorded at import (DataSpec.layers), so this is only reached for a project
|
|
185
|
+
imported before that was recorded -- but it has to be reached, because the
|
|
186
|
+
one control that can fix a project reading the wrong matrix would otherwise
|
|
187
|
+
be the one control it never offers.
|
|
188
|
+
|
|
189
|
+
Cheaper than a full inspection on purpose: a zarr group listing, or a backed
|
|
190
|
+
h5ad open, so nothing materializes a second matrix just to learn its name.
|
|
191
|
+
Best-effort -- an unreadable file means no picker, which is what every
|
|
192
|
+
surface here did before there was one.
|
|
193
|
+
"""
|
|
194
|
+
if spec is None or spec.type == "csv":
|
|
195
|
+
return []
|
|
196
|
+
if spec.layers:
|
|
197
|
+
return list(spec.layers)
|
|
198
|
+
try:
|
|
199
|
+
if spec.type == "spatialdata":
|
|
200
|
+
from .spatialdata_adapter import list_table_layers
|
|
201
|
+
|
|
202
|
+
return list_table_layers(spec.src, spec.table)
|
|
203
|
+
import anndata as ad
|
|
204
|
+
|
|
205
|
+
adata = ad.read_h5ad(spec.src, backed="r")
|
|
206
|
+
try:
|
|
207
|
+
return [str(name) for name in adata.layers.keys() if name]
|
|
208
|
+
finally:
|
|
209
|
+
if adata.isbacked:
|
|
210
|
+
adata.file.close()
|
|
211
|
+
except Exception:
|
|
212
|
+
return []
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def source_obsm(spec) -> list[dict]:
|
|
216
|
+
"""The obsm arrays a project's source file carries, with their shapes.
|
|
217
|
+
|
|
218
|
+
The coordinate question's counterpart to `source_layers`, and reached for
|
|
219
|
+
the same reason: a project imported before obsm was recorded would
|
|
220
|
+
otherwise be offered no candidates at all, and that is precisely the
|
|
221
|
+
project whose coordinate source was picked by a name heuristic nobody was
|
|
222
|
+
shown.
|
|
223
|
+
|
|
224
|
+
Best-effort in the same way -- an unreadable file means no obsm choices,
|
|
225
|
+
which is what every surface offered before this existed.
|
|
226
|
+
"""
|
|
227
|
+
if spec is None or spec.type == "csv":
|
|
228
|
+
return []
|
|
229
|
+
if spec.obsm:
|
|
230
|
+
return [dict(entry) for entry in spec.obsm]
|
|
231
|
+
try:
|
|
232
|
+
if spec.type == "spatialdata":
|
|
233
|
+
from .spatialdata_adapter import read_spatialdata_table
|
|
234
|
+
|
|
235
|
+
return describe_obsm(read_spatialdata_table(spec.src, spec.table))
|
|
236
|
+
import anndata as ad
|
|
237
|
+
|
|
238
|
+
adata = ad.read_h5ad(spec.src, backed="r")
|
|
239
|
+
try:
|
|
240
|
+
return describe_obsm(adata)
|
|
241
|
+
finally:
|
|
242
|
+
if adata.isbacked:
|
|
243
|
+
adata.file.close()
|
|
244
|
+
except Exception:
|
|
245
|
+
return []
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def inspect_spatialdata_table(store, table) -> dict:
|
|
249
|
+
"""Same inspection, for one table inside a SpatialData (.zarr) store.
|
|
250
|
+
|
|
251
|
+
Reads only the named table (see spatialdata_adapter.read_spatialdata_table
|
|
252
|
+
for why the whole store is deliberately not opened). There's no backed
|
|
253
|
+
mode for a zarr-backed AnnData, so unlike inspect_anndata this
|
|
254
|
+
materializes the table -- acceptable because the import form has the
|
|
255
|
+
user pick a single table first, and its shape is shown there.
|
|
256
|
+
"""
|
|
257
|
+
from .spatialdata_adapter import read_spatialdata_table
|
|
258
|
+
|
|
259
|
+
adata = read_spatialdata_table(store, table)
|
|
260
|
+
return {
|
|
261
|
+
"data_type": "spatialdata",
|
|
262
|
+
"store": str(store),
|
|
263
|
+
"table": str(table),
|
|
264
|
+
**_inspect_adata(adata),
|
|
265
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .anndata_adapter import AnnDataAdapter
|
|
6
|
+
|
|
7
|
+
# A SpatialData store keeps every annotation table as a plain AnnData group
|
|
8
|
+
# under <store>.zarr/tables/<name>, so once the user has picked *which* table
|
|
9
|
+
# to use it is an ordinary AnnData and every downstream config field,
|
|
10
|
+
# inspection result and adapter behavior is identical to the .h5ad path.
|
|
11
|
+
# That's why SpatialDataAdapter subclasses AnnDataAdapter and overrides only
|
|
12
|
+
# the read.
|
|
13
|
+
TABLES_GROUP = "tables"
|
|
14
|
+
|
|
15
|
+
# Zarr's own "this directory is a group" markers -- v3 writes zarr.json, v2
|
|
16
|
+
# writes .zgroup. Both appear in a single real store: spatialdata 0.7 wrote
|
|
17
|
+
# the exemplar LSP20209.zarr with a v3 root but v2 table groups, so anything
|
|
18
|
+
# sniffing the layout has to accept either.
|
|
19
|
+
_GROUP_MARKERS = ("zarr.json", ".zgroup")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def table_path(store, table) -> Path:
|
|
23
|
+
"""Resolve <store>/tables/<table>, rejecting anything that isn't a plain
|
|
24
|
+
table name. Without this an empty name resolves to the tables/ group
|
|
25
|
+
itself -- which *is* a valid zarr group, so the read gets that far and
|
|
26
|
+
then fails deep inside anndata with a confusing
|
|
27
|
+
"AnnData.__init__() got an unexpected keyword argument '<some table>'".
|
|
28
|
+
Separators are refused for the same reason a table name arriving from a
|
|
29
|
+
form post shouldn't be able to address anything outside tables/.
|
|
30
|
+
"""
|
|
31
|
+
name = str(table or '').strip()
|
|
32
|
+
if not name or name in ('.', '..') or '/' in name or '\\' in name:
|
|
33
|
+
raise ValueError(f"Invalid SpatialData table name: {str(table)!r}")
|
|
34
|
+
return Path(store) / TABLES_GROUP / name
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_group(path: Path) -> bool:
|
|
38
|
+
return path.is_dir() and any((path / marker).exists() for marker in _GROUP_MARKERS)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def read_spatialdata_table(store, table):
|
|
42
|
+
"""Read a single table out of a SpatialData store as an AnnData.
|
|
43
|
+
|
|
44
|
+
Deliberately reads *only* the named table rather than going through
|
|
45
|
+
spatialdata.read_zarr(store, selection=('tables',)), which eagerly
|
|
46
|
+
materializes every table's X: on the exemplar LSP20209.zarr that is
|
|
47
|
+
0.55s and +846MB (it loads two 1536-dim embedding tables nobody asked
|
|
48
|
+
for) versus 0.04s and +13MB for the one table actually being imported.
|
|
49
|
+
A future plugin that genuinely needs the other elements can open the
|
|
50
|
+
full store itself -- the config records the store path alongside the
|
|
51
|
+
table name for exactly that reason.
|
|
52
|
+
|
|
53
|
+
spatialdata._io.io_table._read_table is the same per-table reader
|
|
54
|
+
sd.read_zarr calls, so this keeps spatialdata's own TableModel version
|
|
55
|
+
parsing rather than reimplementing it. It's a private symbol though, so
|
|
56
|
+
a spatialdata upgrade that moves it falls back to anndata's reader
|
|
57
|
+
(_read_table is a thin wrapper over exactly that, plus attrs
|
|
58
|
+
normalization we don't consume) instead of breaking a packaged build.
|
|
59
|
+
A test pins the import so the move is noticed rather than silently
|
|
60
|
+
absorbed.
|
|
61
|
+
"""
|
|
62
|
+
path = table_path(store, table)
|
|
63
|
+
if not _is_group(path):
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"SpatialData store {str(store)!r} has no table named {str(table)!r} "
|
|
66
|
+
f"(expected a zarr group at {path})"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
from spatialdata._io.io_table import _read_table
|
|
71
|
+
except ImportError:
|
|
72
|
+
import anndata as ad
|
|
73
|
+
|
|
74
|
+
return ad.read_zarr(path)
|
|
75
|
+
return _read_table(path)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _node_length(node) -> int | None:
|
|
79
|
+
"""Length of an on-disk AnnData node along its first axis, from zarr
|
|
80
|
+
metadata only -- never reads values.
|
|
81
|
+
|
|
82
|
+
A column is a plain array under zarr v2 but frequently a *group* under
|
|
83
|
+
v3, where anndata encodes strings as 'nullable-string-array'
|
|
84
|
+
(values/mask) and categoricals as 'categorical' (codes/categories);
|
|
85
|
+
sparse X is a group too, carrying its shape in an attr. Both encodings
|
|
86
|
+
occur in one real store -- spatialdata 0.7 wrote the exemplar
|
|
87
|
+
LSP20209.zarr with a v3 root but v2 table groups -- so this handles all
|
|
88
|
+
of them rather than assuming either.
|
|
89
|
+
"""
|
|
90
|
+
shape = getattr(node, "shape", None)
|
|
91
|
+
if shape is not None:
|
|
92
|
+
return int(shape[0])
|
|
93
|
+
attr_shape = node.attrs.get("shape")
|
|
94
|
+
if attr_shape:
|
|
95
|
+
return int(attr_shape[0])
|
|
96
|
+
# A group standing in for one column: every encoding keeps a full-length
|
|
97
|
+
# array under one of these names ('categories' is deliberately absent --
|
|
98
|
+
# it holds the distinct levels, not one entry per observation).
|
|
99
|
+
for child in ("codes", "values", "data"):
|
|
100
|
+
try:
|
|
101
|
+
child_shape = getattr(node[child], "shape", None)
|
|
102
|
+
except KeyError:
|
|
103
|
+
continue
|
|
104
|
+
if child_shape is not None:
|
|
105
|
+
return int(child_shape[0])
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _frame_length(group, key) -> int | None:
|
|
110
|
+
"""Row count of an on-disk AnnData dataframe group (obs/var). Prefers the
|
|
111
|
+
index anndata records under the '_index' attr, falling back to any column
|
|
112
|
+
-- they all share the axis length."""
|
|
113
|
+
try:
|
|
114
|
+
frame = group[key]
|
|
115
|
+
except KeyError:
|
|
116
|
+
return None
|
|
117
|
+
index_name = frame.attrs.get("_index")
|
|
118
|
+
candidates = ([index_name] if index_name else []) + [
|
|
119
|
+
k for k in frame.keys() if k != index_name
|
|
120
|
+
]
|
|
121
|
+
for candidate in candidates:
|
|
122
|
+
try:
|
|
123
|
+
length = _node_length(frame[candidate])
|
|
124
|
+
except (KeyError, AttributeError, IndexError, TypeError):
|
|
125
|
+
continue
|
|
126
|
+
if length is not None:
|
|
127
|
+
return length
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _table_shape(group) -> tuple[int | None, int | None]:
|
|
132
|
+
"""(n_obs, n_var) for a table group. X carries both in one place when it
|
|
133
|
+
is readable (a dense array's shape, or a sparse group's 'shape' attr);
|
|
134
|
+
obs/var are the fallback for a table whose X is absent."""
|
|
135
|
+
try:
|
|
136
|
+
x_shape = getattr(group["X"], "shape", None) or group["X"].attrs.get("shape")
|
|
137
|
+
if x_shape is not None and len(x_shape) >= 2:
|
|
138
|
+
return int(x_shape[0]), int(x_shape[1])
|
|
139
|
+
except (KeyError, AttributeError, IndexError, TypeError):
|
|
140
|
+
pass
|
|
141
|
+
return _frame_length(group, "obs"), _frame_length(group, "var")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def list_spatialdata_tables(store) -> list[dict]:
|
|
145
|
+
"""Every table in a SpatialData store, with its shape, for the import
|
|
146
|
+
form's table picker. Metadata-only: shapes come from zarr's array
|
|
147
|
+
headers, so listing all three tables of the exemplar store (one of them
|
|
148
|
+
243348x1536) costs ~5ms and ~1MB rather than loading any values.
|
|
149
|
+
|
|
150
|
+
Raises ValueError if `store` isn't a readable SpatialData store, so the
|
|
151
|
+
form can tell "not a store" apart from "a store with no tables" (a real
|
|
152
|
+
state -- a store may hold only images/labels/shapes).
|
|
153
|
+
"""
|
|
154
|
+
import zarr
|
|
155
|
+
|
|
156
|
+
store_path = Path(store)
|
|
157
|
+
if not _is_group(store_path):
|
|
158
|
+
raise ValueError(f"{str(store)!r} is not a zarr store.")
|
|
159
|
+
tables_dir = store_path / TABLES_GROUP
|
|
160
|
+
if not tables_dir.is_dir():
|
|
161
|
+
return []
|
|
162
|
+
|
|
163
|
+
tables = []
|
|
164
|
+
for entry in sorted(tables_dir.iterdir()):
|
|
165
|
+
if not _is_group(entry):
|
|
166
|
+
continue
|
|
167
|
+
try:
|
|
168
|
+
# Path (not str) matches spatialdata's own reader: zarr v3 parses
|
|
169
|
+
# a string store as a URL, so table names containing characters
|
|
170
|
+
# like '#' would be truncated.
|
|
171
|
+
group = zarr.open_group(entry, mode="r")
|
|
172
|
+
except Exception:
|
|
173
|
+
continue
|
|
174
|
+
n_obs, n_var = _table_shape(group)
|
|
175
|
+
tables.append({"name": entry.name, "n_obs": n_obs, "n_var": n_var})
|
|
176
|
+
return tables
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def list_table_layers(store, table) -> list[str]:
|
|
180
|
+
"""The extra expression matrices one table carries alongside its X.
|
|
181
|
+
|
|
182
|
+
Metadata-only, like list_spatialdata_tables above: zarr's group listing
|
|
183
|
+
names the children without reading any of them, so this costs a directory
|
|
184
|
+
walk rather than the hundreds of MB materializing a second matrix would.
|
|
185
|
+
|
|
186
|
+
Best-effort by design -- an unreadable or layer-less table is an ordinary
|
|
187
|
+
empty answer, not an error. The caller is asking "is there anything to
|
|
188
|
+
choose between here?", and "no" is a fine reply.
|
|
189
|
+
"""
|
|
190
|
+
import zarr
|
|
191
|
+
|
|
192
|
+
path = table_path(store, table)
|
|
193
|
+
if not _is_group(path):
|
|
194
|
+
return []
|
|
195
|
+
try:
|
|
196
|
+
group = zarr.open_group(path, mode="r")
|
|
197
|
+
return sorted(str(name) for name in group["layers"].keys() if name)
|
|
198
|
+
except Exception:
|
|
199
|
+
return []
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class SpatialDataAdapter(AnnDataAdapter):
|
|
203
|
+
"""Adapter for a single table inside a SpatialData (.zarr) store.
|
|
204
|
+
|
|
205
|
+
`spec.src` is the store root and `spec.table` names the table within it;
|
|
206
|
+
every other field means exactly what it means for AnnData (see
|
|
207
|
+
anndata_adapter.py), because the resolved table *is* an AnnData.
|
|
208
|
+
|
|
209
|
+
Table coordinates are used as-is, in the pixel space of the registered
|
|
210
|
+
image -- no SpatialData coordinate-system transform is applied. That
|
|
211
|
+
holds for tables written alongside the image they annotate (the
|
|
212
|
+
exemplar store's obsm['spatial'] is literally its X_centroid/Y_centroid
|
|
213
|
+
columns); a store needing a transform would have to grow explicit
|
|
214
|
+
support here.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
def __init__(self, spec):
|
|
218
|
+
super().__init__(spec)
|
|
219
|
+
self.table = spec.table
|
|
220
|
+
if not self.table:
|
|
221
|
+
raise ValueError(
|
|
222
|
+
"A SpatialData datasource needs a table -- it names which "
|
|
223
|
+
"table inside the .zarr store to load."
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def _read_adata(self):
|
|
227
|
+
return read_spatialdata_table(self.path, self.table)
|
|
228
|
+
|
|
229
|
+
def _read_obs(self):
|
|
230
|
+
"""The table's obs group, without the rest of the table.
|
|
231
|
+
|
|
232
|
+
The same saving as the .h5ad path and for the same reason, only larger:
|
|
233
|
+
`read_spatialdata_table` already avoids the store's *other* tables, but
|
|
234
|
+
it still materializes this one's X -- and the exemplar store keeps a
|
|
235
|
+
1536-dimensional embedding table whose X is most of its 846 MB. An
|
|
236
|
+
annotation column is a directory read next to that.
|
|
237
|
+
"""
|
|
238
|
+
import zarr
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
from anndata.io import read_elem # anndata >= 0.10, public API
|
|
242
|
+
except ImportError: # pragma: no cover - older anndata
|
|
243
|
+
from anndata._io.specs import read_elem
|
|
244
|
+
|
|
245
|
+
# Path, not str: zarr v3 parses a string store as a URL, so a table name
|
|
246
|
+
# containing '#' would be truncated (same reason as list_spatialdata_tables).
|
|
247
|
+
group = zarr.open_group(table_path(self.path, self.table), mode="r")
|
|
248
|
+
return read_elem(group["obs"])
|