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,655 @@
|
|
|
1
|
+
"""Writing annotations into the file the project came from.
|
|
2
|
+
|
|
3
|
+
Everything here is explicit -- a button the user presses, never a save -- and
|
|
4
|
+
non-destructive. That is not caution for its own sake: the target is the user's
|
|
5
|
+
own measurements, often the only copy, frequently on a share, and an annotation
|
|
6
|
+
export that rewrites an .h5ad's X or drops a SpatialData table is a data-loss
|
|
7
|
+
bug wearing a feature's clothes.
|
|
8
|
+
|
|
9
|
+
Three rules the implementations below follow:
|
|
10
|
+
|
|
11
|
+
**Write the subtree, never the file.** An .h5ad is opened in-place with h5py and
|
|
12
|
+
only `uns/plexora` is rewritten. A read-and-write-back round trip would rebuild
|
|
13
|
+
X, obs and var from whatever anndata's current version thinks they should look
|
|
14
|
+
like, changing chunking, compression and dtypes of data this plugin has no
|
|
15
|
+
business touching.
|
|
16
|
+
|
|
17
|
+
**Never overwrite something without being told to.** `sdata.shapes["roi"]` may be
|
|
18
|
+
somebody's segmentation boundaries; `uns["plexora"]["rois"]` may be a colleague's
|
|
19
|
+
annotation pass. Both destinations are NAMED by the user, and a name that is
|
|
20
|
+
already taken is refused rather than replaced -- the caller has to say
|
|
21
|
+
`replace`, and for SpatialData it is not offered at all (see
|
|
22
|
+
`save_to_spatialdata`). Refusal happens before anything is unlinked.
|
|
23
|
+
|
|
24
|
+
**Never re-consolidate a store root.** See `_open_group`: a SpatialData root is
|
|
25
|
+
zarr v3 and real stores mix v2 tables into it, so rebuilding the root index
|
|
26
|
+
silently drops them. The refresh here is confined to the group actually written.
|
|
27
|
+
|
|
28
|
+
The live annotation state stays in the plugin store either way. These are
|
|
29
|
+
destinations, not the working copy -- which is what lets the drawing tools
|
|
30
|
+
behave identically whether the project came from a CSV, an .h5ad, or nothing but
|
|
31
|
+
an image.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import contextlib
|
|
37
|
+
import json
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
from plexora.plugins.roi.server import schema
|
|
41
|
+
|
|
42
|
+
#: Where annotations land inside an AnnData. Under a `plexora` group rather than
|
|
43
|
+
#: at the top of uns so Plexora owns one key in a namespace shared with whatever
|
|
44
|
+
#: else the user's pipeline put there.
|
|
45
|
+
UNS_GROUP = "plexora"
|
|
46
|
+
|
|
47
|
+
#: What the entry under that group is called unless the user names it something
|
|
48
|
+
#: else. One project can hold several: a second pass, a second annotator, a
|
|
49
|
+
#: version kept because the first one was worth keeping.
|
|
50
|
+
DEFAULT_UNS_KEY = "rois"
|
|
51
|
+
|
|
52
|
+
#: Default name for the shapes element. Prefixed, because `shapes["roi"]` is a
|
|
53
|
+
#: name a user's own pipeline could plausibly have used for something else.
|
|
54
|
+
DEFAULT_ELEMENT = "plexora_rois"
|
|
55
|
+
|
|
56
|
+
_GROUP_MARKERS = ("zarr.json", ".zgroup")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DestinationExists(Exception):
|
|
60
|
+
"""The name the user gave is already in use in their file.
|
|
61
|
+
|
|
62
|
+
Carries what is already there and a free name, so the caller can say which
|
|
63
|
+
names are taken rather than only that this one is.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, message, existing, suggestion):
|
|
67
|
+
super().__init__(message)
|
|
68
|
+
self.existing = existing
|
|
69
|
+
self.suggestion = suggestion
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ElementExists(DestinationExists):
|
|
73
|
+
"""The requested shapes element is already in the store."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, existing, suggestion):
|
|
76
|
+
super().__init__(f"element already exists; try {suggestion!r}", existing, suggestion)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class KeyExists(DestinationExists):
|
|
80
|
+
"""The requested uns/plexora key is already in the file."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, existing, suggestion):
|
|
83
|
+
super().__init__(f"key already exists; try {suggestion!r}", existing, suggestion)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ColumnExists(DestinationExists):
|
|
87
|
+
"""One of the two ROI columns is already a column of the cell table.
|
|
88
|
+
|
|
89
|
+
`existing` is every column name rather than just the colliding one: the two
|
|
90
|
+
names are derived from a single prefix rather than chosen separately, so a
|
|
91
|
+
user picking a new prefix is choosing against the whole table.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, existing, suggestion):
|
|
95
|
+
super().__init__(
|
|
96
|
+
f"a column of that name already exists; try {suggestion!r}",
|
|
97
|
+
existing, suggestion)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# -- AnnData ------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def save_to_anndata(dataset, state, plugin_version, key=None, replace=False):
|
|
104
|
+
"""Write `state` into the source file at uns/plexora/<key>.
|
|
105
|
+
|
|
106
|
+
Stored as a JSON string rather than as a nested structure. AnnData's element
|
|
107
|
+
codec writes mappings recursively, and an ROI document is heterogeneous all
|
|
108
|
+
the way down -- ragged coordinate arrays, nullable strings, per-feature
|
|
109
|
+
dicts with different keys -- which is exactly the shape that produces
|
|
110
|
+
unreadable object arrays or an outright encoder error. One string always
|
|
111
|
+
round-trips, in every language that can open the file, and
|
|
112
|
+
`json.loads(adata.uns["plexora"]["rois"])` is a one-liner on the way out.
|
|
113
|
+
|
|
114
|
+
`key` names the entry, so one file can hold several passes side by side
|
|
115
|
+
without them being each other's history. Writing over one that is already
|
|
116
|
+
there needs `replace`, and the refusal happens before `del uns[...]` -- an
|
|
117
|
+
export that unlinks the group and then declines to write is the one failure
|
|
118
|
+
mode here that destroys something.
|
|
119
|
+
"""
|
|
120
|
+
if dataset.source_kind != "anndata":
|
|
121
|
+
raise ValueError("this project's data did not come from an AnnData file")
|
|
122
|
+
|
|
123
|
+
source = dataset.table.source
|
|
124
|
+
if source is None or not source.path:
|
|
125
|
+
raise ValueError("no AnnData file is recorded for this project")
|
|
126
|
+
|
|
127
|
+
key = schema.element_name(key, DEFAULT_UNS_KEY)
|
|
128
|
+
document = _snapshot(state, dataset.name, plugin_version)
|
|
129
|
+
payload = json.dumps(document, separators=(",", ":"))
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
from anndata.io import read_elem, write_elem # anndata >= 0.10, public API
|
|
133
|
+
except ImportError: # pragma: no cover - older anndata fallback
|
|
134
|
+
from anndata._io.specs import read_elem, write_elem
|
|
135
|
+
|
|
136
|
+
with _open_group(source.path, writable=True) as handle:
|
|
137
|
+
uns = handle.require_group("uns")
|
|
138
|
+
|
|
139
|
+
existing = {}
|
|
140
|
+
if UNS_GROUP in uns:
|
|
141
|
+
existing = read_elem(uns[UNS_GROUP])
|
|
142
|
+
if not isinstance(existing, dict):
|
|
143
|
+
# Refused rather than replaced, and refused before anything is
|
|
144
|
+
# written: whatever is under that key belongs to somebody, and
|
|
145
|
+
# this plugin cannot tell what would be lost.
|
|
146
|
+
raise ValueError(
|
|
147
|
+
f"adata.uns[{UNS_GROUP!r}] already exists and is not a mapping, "
|
|
148
|
+
"so these annotations have nowhere to go without overwriting it"
|
|
149
|
+
)
|
|
150
|
+
if key in existing and not replace:
|
|
151
|
+
raise KeyExists(sorted(existing), _suggest(key, existing))
|
|
152
|
+
del uns[UNS_GROUP]
|
|
153
|
+
|
|
154
|
+
existing[key] = payload
|
|
155
|
+
write_elem(uns, UNS_GROUP, existing)
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
"path": source.path,
|
|
159
|
+
"name": key,
|
|
160
|
+
"key": f"uns/{UNS_GROUP}/{key}",
|
|
161
|
+
"n_rois": len(document["images"].get(schema.DEFAULT_IMAGE, {}).get("features", [])),
|
|
162
|
+
"n_categories": len(document["categories"]),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def existing_anndata_keys(dataset):
|
|
167
|
+
"""Names already used under `uns/plexora` in this project's file.
|
|
168
|
+
|
|
169
|
+
Asked on every panel open so a colliding name is visible before it is
|
|
170
|
+
typed. Read-only and forgiving: a file another process is holding open, or
|
|
171
|
+
one whose `uns/plexora` is not a mapping, produces an empty list rather
|
|
172
|
+
than a panel that will not load. The write path checks again for real, and
|
|
173
|
+
refuses there.
|
|
174
|
+
"""
|
|
175
|
+
if dataset.source_kind != "anndata":
|
|
176
|
+
return []
|
|
177
|
+
source = dataset.table.source
|
|
178
|
+
if source is None or not source.path:
|
|
179
|
+
return []
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
from anndata.io import read_elem
|
|
183
|
+
except ImportError: # pragma: no cover - older anndata fallback
|
|
184
|
+
from anndata._io.specs import read_elem
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
with _open_group(source.path) as handle:
|
|
188
|
+
uns = handle["uns"] if "uns" in handle else None
|
|
189
|
+
if uns is None or UNS_GROUP not in uns:
|
|
190
|
+
return []
|
|
191
|
+
stored = read_elem(uns[UNS_GROUP])
|
|
192
|
+
except Exception: # pragma: no cover - unreadable file; the panel still works
|
|
193
|
+
return []
|
|
194
|
+
return sorted(stored) if isinstance(stored, dict) else []
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _snapshot(state, datasource, plugin_version):
|
|
198
|
+
"""What gets written to a file: the annotations, without the revision.
|
|
199
|
+
|
|
200
|
+
The revision is a fact about this project's store -- who last wrote and
|
|
201
|
+
whether a client is stale -- and means nothing in a file somebody else
|
|
202
|
+
opens. Carrying it would invite a reader to treat the file as authoritative
|
|
203
|
+
about the live state, which it never is.
|
|
204
|
+
"""
|
|
205
|
+
return {
|
|
206
|
+
"schema_version": state["schema_version"],
|
|
207
|
+
"plugin_version": plugin_version,
|
|
208
|
+
"datasource": datasource,
|
|
209
|
+
"categories": state["categories"],
|
|
210
|
+
"images": state["images"],
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# -- SpatialData --------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def existing_shapes(dataset):
|
|
218
|
+
"""Names already used by shapes elements in this project's store."""
|
|
219
|
+
store = _spatialdata_store(dataset)
|
|
220
|
+
shapes_dir = Path(store) / "shapes"
|
|
221
|
+
if not shapes_dir.is_dir():
|
|
222
|
+
return []
|
|
223
|
+
return sorted(entry.name for entry in shapes_dir.iterdir() if _is_group(entry))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def save_to_spatialdata(dataset, state, element_name=DEFAULT_ELEMENT,
|
|
227
|
+
image_key=schema.DEFAULT_IMAGE):
|
|
228
|
+
"""Write the annotations into the store as a shapes element.
|
|
229
|
+
|
|
230
|
+
Coordinates go in untransformed, under an identity transformation into the
|
|
231
|
+
store's own coordinate system. That is correct here for the same reason the
|
|
232
|
+
importer reads tables as-is: Plexora's viewer lays the image out in its
|
|
233
|
+
pixel grid, and this project's shapes were drawn on that grid, so pixel
|
|
234
|
+
coordinates ARE the image element's coordinates. Attaching a scale nobody
|
|
235
|
+
measured would be the guess.
|
|
236
|
+
"""
|
|
237
|
+
store = _spatialdata_store(dataset)
|
|
238
|
+
element_name = schema.element_name(element_name, DEFAULT_ELEMENT)
|
|
239
|
+
taken = existing_shapes(dataset)
|
|
240
|
+
if element_name in taken:
|
|
241
|
+
# Overwriting an element in place is not offered. spatialdata's own
|
|
242
|
+
# element writer refuses it, and the workaround -- delete then rewrite
|
|
243
|
+
# -- has a window in which the user's data is gone and the replacement
|
|
244
|
+
# is not yet there. A second name costs nothing and cannot lose
|
|
245
|
+
# anything.
|
|
246
|
+
raise ElementExists(taken, _suggest(element_name, taken))
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
import geopandas as gpd
|
|
250
|
+
import shapely.geometry as sgeom
|
|
251
|
+
import spatialdata
|
|
252
|
+
from spatialdata.models import ShapesModel
|
|
253
|
+
from spatialdata.transformations import Identity
|
|
254
|
+
except ImportError as exc: # pragma: no cover - optional at runtime
|
|
255
|
+
raise ValueError(f"SpatialData export needs {exc.name!r} to be installed") from exc
|
|
256
|
+
|
|
257
|
+
entry = state["images"].get(image_key) or schema.empty_image()
|
|
258
|
+
categories = {c["id"]: c for c in state["categories"]}
|
|
259
|
+
if not entry["features"]:
|
|
260
|
+
raise ValueError("there are no ROIs to export")
|
|
261
|
+
|
|
262
|
+
rows, geometries = [], []
|
|
263
|
+
for feature in entry["features"]:
|
|
264
|
+
category = categories.get(feature["category_id"]) or schema.placeholder_category()
|
|
265
|
+
geometries.append(_shapely(feature["geometry"], sgeom))
|
|
266
|
+
rows.append({
|
|
267
|
+
"roi_id": feature["id"],
|
|
268
|
+
"name": feature.get("name") or "",
|
|
269
|
+
"category_id": feature["category_id"],
|
|
270
|
+
"category": category["label"],
|
|
271
|
+
"category_color": category["color"],
|
|
272
|
+
"locked": bool(feature.get("locked", False)),
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
frame = gpd.GeoDataFrame(rows, geometry=geometries)
|
|
276
|
+
element = ShapesModel.parse(frame, transformations={"global": Identity()})
|
|
277
|
+
|
|
278
|
+
# Only the shapes group: reading the tables would materialize every one of
|
|
279
|
+
# them, which on a real store is hundreds of megabytes of embeddings nobody
|
|
280
|
+
# asked for (the same trap the importer documents).
|
|
281
|
+
sdata = spatialdata.read_zarr(store, selection=("shapes",))
|
|
282
|
+
sdata.shapes[element_name] = element
|
|
283
|
+
sdata.write_element(element_name)
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
"path": str(store),
|
|
287
|
+
"name": element_name,
|
|
288
|
+
"element": f"shapes/{element_name}",
|
|
289
|
+
"n_rois": len(rows),
|
|
290
|
+
"coordinate_system": "global",
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# -- ROI columns on the cells -------------------------------------------
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def cell_column_names(prefix):
|
|
298
|
+
"""The two columns "Map to cells" writes, for one destination name.
|
|
299
|
+
|
|
300
|
+
Derived from the name the user already types when saving, so several
|
|
301
|
+
annotation passes can sit side by side in one table the same way several
|
|
302
|
+
`uns/plexora/<key>` entries can. `rois` -- not DEFAULT_ELEMENT -- is the
|
|
303
|
+
fallback for both formats: a SpatialData project would otherwise get
|
|
304
|
+
`plexora_rois_category`, which is a column name nobody wants to type.
|
|
305
|
+
"""
|
|
306
|
+
prefix = schema.element_name(prefix, DEFAULT_UNS_KEY)
|
|
307
|
+
return f"{prefix}_category", f"{prefix}_name"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def write_cell_columns(dataset, labels, names, prefix=None, replace=False):
|
|
311
|
+
"""Write two ROI annotation columns onto this project's cells.
|
|
312
|
+
|
|
313
|
+
`labels` and `names` are per-row values for the LOADED table, in its order
|
|
314
|
+
-- what `mapping.assign` returns. Getting them onto the right rows of the
|
|
315
|
+
file underneath is this function's whole job, and it is not a copy: the
|
|
316
|
+
loaded table is frequently a subset of the file's cells, and the file may
|
|
317
|
+
hold several images' worth of them.
|
|
318
|
+
|
|
319
|
+
Two guarantees, in the module's existing spirit:
|
|
320
|
+
|
|
321
|
+
Rows this project cannot see are never touched. A cell belonging to another
|
|
322
|
+
image in the same shared .h5ad keeps whatever it had, or gets a null if the
|
|
323
|
+
column is new -- so the same file can be annotated once per image without
|
|
324
|
+
each pass erasing the last.
|
|
325
|
+
|
|
326
|
+
That is also why blank and null mean different things here, and the
|
|
327
|
+
difference is worth keeping. A cell of THIS image that fell in no region
|
|
328
|
+
gets an empty string: it was tested, and the answer is "none". A cell of
|
|
329
|
+
another image gets a null: it was never tested. Collapsing the two would
|
|
330
|
+
make a half-annotated file indistinguishable from a fully annotated one in
|
|
331
|
+
which nothing overlapped.
|
|
332
|
+
|
|
333
|
+
An existing column is refused, not overwritten, and refused before anything
|
|
334
|
+
is written. `replace` is the user's answer to being asked.
|
|
335
|
+
"""
|
|
336
|
+
category_column, name_column = cell_column_names(prefix)
|
|
337
|
+
kind = dataset.source_kind
|
|
338
|
+
if kind == "csv":
|
|
339
|
+
return _write_csv_columns(dataset, labels, names, category_column,
|
|
340
|
+
name_column, replace)
|
|
341
|
+
if kind in ("anndata", "spatialdata"):
|
|
342
|
+
return _write_obs_columns(dataset, labels, names, category_column,
|
|
343
|
+
name_column, replace)
|
|
344
|
+
raise ValueError("this project has no cell-level data to annotate")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _write_obs_columns(dataset, labels, names, category_column, name_column, replace):
|
|
348
|
+
"""The AnnData/SpatialData path: rewrite `obs`, and nothing else.
|
|
349
|
+
|
|
350
|
+
`obs` is read through anndata's element codec, modified in memory and
|
|
351
|
+
written back over itself. That is one subtree: X, var, obsm, uns and every
|
|
352
|
+
other table in a SpatialData store are never opened, let alone rebuilt --
|
|
353
|
+
the property the module docstring insists on, and the reason this is not
|
|
354
|
+
`ad.read_h5ad()` plus `write_h5ad()`.
|
|
355
|
+
"""
|
|
356
|
+
try:
|
|
357
|
+
from anndata.io import read_elem, write_elem # anndata >= 0.10, public API
|
|
358
|
+
except ImportError: # pragma: no cover - older anndata fallback
|
|
359
|
+
from anndata._io.specs import read_elem, write_elem
|
|
360
|
+
|
|
361
|
+
path = _table_path(dataset)
|
|
362
|
+
source = dataset.table.source
|
|
363
|
+
|
|
364
|
+
with _open_group(path) as handle:
|
|
365
|
+
obs = read_elem(handle["obs"])
|
|
366
|
+
|
|
367
|
+
taken = [c for c in (category_column, name_column) if c in obs.columns]
|
|
368
|
+
if taken and not replace:
|
|
369
|
+
raise ColumnExists(sorted(obs.columns), _suggest_prefix(category_column, obs.columns))
|
|
370
|
+
|
|
371
|
+
mask = _project_rows(dataset, obs)
|
|
372
|
+
selected = int(mask.sum())
|
|
373
|
+
if selected != len(labels):
|
|
374
|
+
# The file has changed under the loaded table -- rows added, removed or
|
|
375
|
+
# reordered since it was read. Assigning anyway would put every label on
|
|
376
|
+
# the wrong cell, which is invisible in the file and wrong forever.
|
|
377
|
+
raise ValueError(
|
|
378
|
+
f"this project's table has {len(labels)} cells but the file now has "
|
|
379
|
+
f"{selected} matching rows; reopen the project and try again"
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
obs = _assign_column(obs, mask, category_column, labels,
|
|
383
|
+
dataset, source, categorical=True)
|
|
384
|
+
obs = _assign_column(obs, mask, name_column, names,
|
|
385
|
+
dataset, source, categorical=False)
|
|
386
|
+
|
|
387
|
+
with _open_group(path, writable=True) as handle:
|
|
388
|
+
# Unlinked and rewritten rather than patched in place: obs is a group
|
|
389
|
+
# whose column layout lives in its attributes, and a codec that writes
|
|
390
|
+
# the frame as a whole is the only one that keeps those honest.
|
|
391
|
+
del handle["obs"]
|
|
392
|
+
write_elem(handle, "obs", obs)
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
"path": str(path),
|
|
396
|
+
"columns": [category_column, name_column],
|
|
397
|
+
"n_cells": len(labels),
|
|
398
|
+
"n_assigned": sum(1 for value in names if value),
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _assign_column(obs, mask, column, values, dataset, source, categorical):
|
|
403
|
+
"""One column written onto the masked rows, aligned by cell id.
|
|
404
|
+
|
|
405
|
+
Alignment is by identifier wherever the project has one, never by position:
|
|
406
|
+
a project is routinely a subset of its file's obs, and a positional write
|
|
407
|
+
against the wrong offset shifts every label by the size of whatever came
|
|
408
|
+
before it. Row numbers are the fallback precisely because they are the case
|
|
409
|
+
where the user told us there is no identifier to align on.
|
|
410
|
+
"""
|
|
411
|
+
import numpy as np
|
|
412
|
+
import pandas as pd
|
|
413
|
+
|
|
414
|
+
id_field = getattr(dataset.project.dataset, "obs_id_field", None)
|
|
415
|
+
frame = dataset.table.frame()
|
|
416
|
+
cell_id = dataset.schema.cell_id if dataset.schema else None
|
|
417
|
+
|
|
418
|
+
series = obs[column] if column in obs.columns else pd.Series(
|
|
419
|
+
[None] * len(obs), index=obs.index, dtype="object")
|
|
420
|
+
series = series.astype("object")
|
|
421
|
+
|
|
422
|
+
positions = np.flatnonzero(np.asarray(mask))
|
|
423
|
+
if id_field and id_field in obs.columns and frame is not None and cell_id in frame.columns:
|
|
424
|
+
wanted = {str(key): value
|
|
425
|
+
for key, value in zip(frame[cell_id].to_list(), values)}
|
|
426
|
+
keys = obs[id_field].astype(str).to_numpy()
|
|
427
|
+
for position in positions:
|
|
428
|
+
if keys[position] in wanted:
|
|
429
|
+
series.iloc[position] = wanted[keys[position]]
|
|
430
|
+
else:
|
|
431
|
+
# No identifier column: the loaded table is the masked rows in file
|
|
432
|
+
# order, which the length check above has already confirmed.
|
|
433
|
+
for offset, position in enumerate(positions):
|
|
434
|
+
series.iloc[position] = values[offset]
|
|
435
|
+
|
|
436
|
+
# Not a plain object column: anndata's codec refuses one holding both
|
|
437
|
+
# strings and None ("Can't implicitly convert non-string objects to
|
|
438
|
+
# strings"), and rows belonging to another image are exactly the Nones. The
|
|
439
|
+
# category column becomes a pandas categorical, which is what obs columns of
|
|
440
|
+
# labels are everywhere else and what makes scanpy plot it without being
|
|
441
|
+
# asked twice; names stay free text as a nullable string array.
|
|
442
|
+
obs[column] = (series.astype("string").astype("category") if categorical
|
|
443
|
+
else series.astype("string"))
|
|
444
|
+
return obs
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _project_rows(dataset, obs):
|
|
448
|
+
"""A boolean mask over `obs` selecting the cells this project can see.
|
|
449
|
+
|
|
450
|
+
Two filters, and both matter. The registration subset is how a project was
|
|
451
|
+
narrowed to one image at import. The image-id column is the answer the user
|
|
452
|
+
gave later, through the requirements modal, and it is the one that makes
|
|
453
|
+
running this once per image against one shared file safe -- without it a
|
|
454
|
+
second pass would write over the first image's labels.
|
|
455
|
+
"""
|
|
456
|
+
import numpy as np
|
|
457
|
+
|
|
458
|
+
from plexora.plugins.roi.server import mapping
|
|
459
|
+
|
|
460
|
+
mask = np.ones(len(obs), dtype=bool)
|
|
461
|
+
|
|
462
|
+
subset = dict((dataset.table.source.subset if dataset.table.source else None) or {})
|
|
463
|
+
column = subset.get("column")
|
|
464
|
+
if column:
|
|
465
|
+
if column not in obs.columns:
|
|
466
|
+
raise ValueError(f"subset column {column!r} is no longer in this file's obs")
|
|
467
|
+
mask &= obs[column].astype(str).to_numpy() == str(subset.get("value"))
|
|
468
|
+
|
|
469
|
+
image_id = mapping.current_image_id(dataset)
|
|
470
|
+
image_column = dataset.schema.image_id if dataset.schema else None
|
|
471
|
+
if image_id is not None and image_column and image_column in obs.columns:
|
|
472
|
+
mask &= obs[image_column].astype(str).to_numpy() == str(image_id)
|
|
473
|
+
return mask
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _write_csv_columns(dataset, labels, names, category_column, name_column, replace):
|
|
477
|
+
"""The CSV path: the whole file, because a CSV has no subtree.
|
|
478
|
+
|
|
479
|
+
Written to a temporary file in the same directory and renamed over the
|
|
480
|
+
original, so a reader never sees a half-written table and a failure partway
|
|
481
|
+
through leaves the original intact. That is the same guarantee
|
|
482
|
+
`project.write_config()` gives config.json, and for the same reason: this is
|
|
483
|
+
somebody's measurements and often the only copy.
|
|
484
|
+
"""
|
|
485
|
+
import os
|
|
486
|
+
import tempfile
|
|
487
|
+
|
|
488
|
+
import polars as pl
|
|
489
|
+
|
|
490
|
+
source = dataset.table.source
|
|
491
|
+
if source is None or not source.path:
|
|
492
|
+
raise ValueError("no CSV file is recorded for this project")
|
|
493
|
+
|
|
494
|
+
frame = pl.read_csv(source.path)
|
|
495
|
+
taken = [c for c in (category_column, name_column) if c in frame.columns]
|
|
496
|
+
if taken and not replace:
|
|
497
|
+
raise ColumnExists(sorted(frame.columns),
|
|
498
|
+
_suggest_prefix(category_column, frame.columns))
|
|
499
|
+
if frame.height != len(labels):
|
|
500
|
+
raise ValueError(
|
|
501
|
+
f"this project's table has {len(labels)} cells but the file now has "
|
|
502
|
+
f"{frame.height} rows; reopen the project and try again"
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
frame = frame.with_columns([
|
|
506
|
+
pl.Series(category_column, labels, dtype=pl.Utf8),
|
|
507
|
+
pl.Series(name_column, names, dtype=pl.Utf8),
|
|
508
|
+
])
|
|
509
|
+
|
|
510
|
+
directory = os.path.dirname(os.path.abspath(source.path)) or "."
|
|
511
|
+
handle, temporary = tempfile.mkstemp(suffix=".csv", dir=directory)
|
|
512
|
+
os.close(handle)
|
|
513
|
+
try:
|
|
514
|
+
frame.write_csv(temporary)
|
|
515
|
+
os.replace(temporary, source.path)
|
|
516
|
+
except BaseException:
|
|
517
|
+
with contextlib.suppress(OSError):
|
|
518
|
+
os.unlink(temporary)
|
|
519
|
+
raise
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
"path": source.path,
|
|
523
|
+
"columns": [category_column, name_column],
|
|
524
|
+
"n_cells": len(labels),
|
|
525
|
+
"n_assigned": sum(1 for value in names if value),
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _table_path(dataset):
|
|
530
|
+
"""Where this project's AnnData group actually lives.
|
|
531
|
+
|
|
532
|
+
A SpatialData store is narrowed to the one table this project reads --
|
|
533
|
+
writing at the store root would put obs somewhere no reader looks, and
|
|
534
|
+
`_open_group`'s re-consolidation is only safe on the group it wrote.
|
|
535
|
+
"""
|
|
536
|
+
source = dataset.table.source
|
|
537
|
+
if source is None or not source.path:
|
|
538
|
+
raise ValueError("no data file is recorded for this project")
|
|
539
|
+
if dataset.source_kind == "spatialdata":
|
|
540
|
+
from plexora.server.models.adapters.spatialdata_adapter import table_path
|
|
541
|
+
|
|
542
|
+
return str(table_path(source.path, source.table))
|
|
543
|
+
return source.path
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _suggest_prefix(category_column, taken):
|
|
547
|
+
"""A free destination name, given that `<name>_category` is taken."""
|
|
548
|
+
base = category_column[: -len("_category")]
|
|
549
|
+
return _suggest(base, {str(c) for c in taken})
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _shapely(geometry, sgeom):
|
|
553
|
+
"""A stored geometry as a shapely object, holes and all."""
|
|
554
|
+
kind = geometry.get("type")
|
|
555
|
+
if kind == "MultiPolygon":
|
|
556
|
+
return sgeom.MultiPolygon([_polygon(part, sgeom) for part in geometry["coordinates"]])
|
|
557
|
+
return _polygon(geometry["coordinates"], sgeom)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _polygon(rings, sgeom):
|
|
561
|
+
outer, *holes = rings
|
|
562
|
+
return sgeom.Polygon(outer, holes)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _suggest(name, taken):
|
|
566
|
+
"""The next free `name_2`, `name_3`, ... """
|
|
567
|
+
base = name.rstrip("0123456789_") or name
|
|
568
|
+
for index in range(2, 1000):
|
|
569
|
+
candidate = f"{base}_{index}"
|
|
570
|
+
if candidate not in taken:
|
|
571
|
+
return candidate
|
|
572
|
+
return f"{base}_{len(taken) + 1}" # pragma: no cover - a thousand collisions
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _spatialdata_store(dataset):
|
|
576
|
+
if dataset.source_kind != "spatialdata":
|
|
577
|
+
raise ValueError("this project's data did not come from a SpatialData store")
|
|
578
|
+
source = dataset.table.source
|
|
579
|
+
if source is None or not source.path:
|
|
580
|
+
raise ValueError("no SpatialData store is recorded for this project")
|
|
581
|
+
if not _is_group(Path(source.path)):
|
|
582
|
+
raise ValueError(f"{source.path!r} is not a zarr store")
|
|
583
|
+
return source.path
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
# -- opening an on-disk AnnData -----------------------------------------
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _is_group(path: Path) -> bool:
|
|
590
|
+
return path.is_dir() and any((path / marker).exists() for marker in _GROUP_MARKERS)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _consolidated_format(path: Path):
|
|
594
|
+
"""Which zarr format's consolidated index this group carries, if any.
|
|
595
|
+
|
|
596
|
+
A consolidated index is a cached copy of every child's metadata kept beside
|
|
597
|
+
the group -- `.zmetadata` in v2, a `consolidated_metadata` key inside
|
|
598
|
+
`zarr.json` in v3. Readers that find one trust it completely and never list
|
|
599
|
+
the directory, which is both why a write is refused and why a write has to
|
|
600
|
+
be followed by a refresh.
|
|
601
|
+
"""
|
|
602
|
+
if (path / ".zmetadata").is_file():
|
|
603
|
+
return 2
|
|
604
|
+
metadata = path / "zarr.json"
|
|
605
|
+
if metadata.is_file():
|
|
606
|
+
try:
|
|
607
|
+
document = json.loads(metadata.read_text(encoding="utf-8"))
|
|
608
|
+
except (OSError, ValueError): # pragma: no cover - unreadable metadata
|
|
609
|
+
return None
|
|
610
|
+
if document.get("consolidated_metadata") is not None:
|
|
611
|
+
return 3
|
|
612
|
+
return None
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
@contextlib.contextmanager
|
|
616
|
+
def _open_group(path: str, writable: bool = False):
|
|
617
|
+
"""The root group of an on-disk AnnData, for either backend.
|
|
618
|
+
|
|
619
|
+
anndata's element codec is storage-agnostic, so the caller works unchanged
|
|
620
|
+
against an h5py group from an .h5ad or a zarr group from a store. A .zarr is
|
|
621
|
+
a directory, which is what tells the two apart.
|
|
622
|
+
|
|
623
|
+
The consolidated-zarr dance is the same one gating's anndata_gates._open_group
|
|
624
|
+
documents at length, and for the same reasons: anndata refuses to write to a
|
|
625
|
+
group whose metadata is consolidated, and skipping the rebuild afterwards
|
|
626
|
+
loses the write silently because every reader consults the stale index. The
|
|
627
|
+
refresh is confined to the group written -- re-consolidating a SpatialData
|
|
628
|
+
ROOT drops every v2 table from a v3 index, and real stores mix the two.
|
|
629
|
+
|
|
630
|
+
Duplicated rather than imported from the gating plugin on purpose: a plugin
|
|
631
|
+
that reaches into another plugin's internals stops working the moment the
|
|
632
|
+
user's build does not ship that plugin.
|
|
633
|
+
"""
|
|
634
|
+
if Path(path).is_dir():
|
|
635
|
+
import zarr
|
|
636
|
+
|
|
637
|
+
# Path (not str) deliberately: zarr v3 parses a string store as a URL,
|
|
638
|
+
# mangling names containing characters like '#'.
|
|
639
|
+
location = Path(path)
|
|
640
|
+
if not writable:
|
|
641
|
+
yield zarr.open_group(location, mode="r")
|
|
642
|
+
return
|
|
643
|
+
|
|
644
|
+
consolidated = _consolidated_format(location)
|
|
645
|
+
yield zarr.open_group(location, mode="a", use_consolidated=False)
|
|
646
|
+
if consolidated is not None:
|
|
647
|
+
zarr.consolidate_metadata(
|
|
648
|
+
zarr.storage.LocalStore(location), zarr_format=consolidated
|
|
649
|
+
)
|
|
650
|
+
return
|
|
651
|
+
|
|
652
|
+
import h5py
|
|
653
|
+
|
|
654
|
+
with h5py.File(path, "r+" if writable else "r") as handle:
|
|
655
|
+
yield handle
|