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,267 @@
|
|
|
1
|
+
"""Annotations as a file somebody else can open.
|
|
2
|
+
|
|
3
|
+
GeoJSON, because polygons are polygons: Shapely, GeoPandas, QGIS, SpatialData's
|
|
4
|
+
own shapes reader and every JavaScript geometry library already read it, and the
|
|
5
|
+
alternative is inventing a polygon format that only Plexora can open.
|
|
6
|
+
|
|
7
|
+
The one honest caveat, stated in the file itself rather than assumed: GeoJSON is
|
|
8
|
+
a GEOGRAPHIC format. Its positions are longitude/latitude on the WGS-84 datum
|
|
9
|
+
unless a document says otherwise, and these are image pixels with y increasing
|
|
10
|
+
downward -- which is not a coordinate reference system at all. So the export
|
|
11
|
+
carries an explicit `coordinate_space` and never claims a CRS. A reader that
|
|
12
|
+
treats these as degrees gets nonsense either way; one that reads the metadata
|
|
13
|
+
gets the truth.
|
|
14
|
+
|
|
15
|
+
Plexora-specific material lives under a `plexora` foreign member. Foreign
|
|
16
|
+
members are the part of the GeoJSON spec meant for exactly this: a strict reader
|
|
17
|
+
ignores it and still gets valid geometry, and Plexora gets its categories back.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import uuid
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
|
|
25
|
+
from plexora.plugins.roi.server import schema
|
|
26
|
+
from plexora.plugins.roi.server.geometry import validate_geometry
|
|
27
|
+
|
|
28
|
+
MIME_TYPE = "application/geo+json"
|
|
29
|
+
|
|
30
|
+
#: Features in one imported document. An import is parsed, validated and applied
|
|
31
|
+
#: in one request, so this is the ceiling on how long that request can take.
|
|
32
|
+
MAX_IMPORT_FEATURES = 50_000
|
|
33
|
+
|
|
34
|
+
#: Where an imported feature carrying no category information at all is filed.
|
|
35
|
+
#: It has to go somewhere -- dropping the shape because some intermediate tool
|
|
36
|
+
#: stripped its properties would lose real annotation -- and since a project no
|
|
37
|
+
#: longer has a reserved catch-all, one named for how these arrived is at least
|
|
38
|
+
#: honest about what it holds. Created only if such a feature actually turns up.
|
|
39
|
+
IMPORTED_LABEL = "Imported"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def export_document(state, datasource, plugin_version, image_key=schema.DEFAULT_IMAGE,
|
|
43
|
+
image_id=None):
|
|
44
|
+
"""A FeatureCollection holding everything needed to reconstruct the project.
|
|
45
|
+
|
|
46
|
+
Category metadata is written twice on purpose: once in the foreign member
|
|
47
|
+
(so a Plexora import restores the categories themselves -- colours, order,
|
|
48
|
+
visibility) and once flattened onto each feature's properties (so a reader
|
|
49
|
+
that ignores foreign members still knows what each region is). The
|
|
50
|
+
duplication is a few bytes and the alternative is an export that is either
|
|
51
|
+
lossy for Plexora or opaque to everyone else.
|
|
52
|
+
|
|
53
|
+
`image_id` is the value this project's cells carry in their image-id column,
|
|
54
|
+
and it is written for the same reason and in both of the same places. An
|
|
55
|
+
AnnData can hold cells from a dozen slides; a file of polygons that does not
|
|
56
|
+
say which one it was drawn on is a file whose regions can be applied to the
|
|
57
|
+
wrong slide without anything looking wrong. It is passed in rather than read
|
|
58
|
+
from `state` because it is a fact about the project record, which can change
|
|
59
|
+
after these shapes were drawn -- resolving it at export time is what keeps a
|
|
60
|
+
stored copy from going quietly stale. None is a real answer: a single-image
|
|
61
|
+
project has no such column, and the key is then absent rather than null.
|
|
62
|
+
"""
|
|
63
|
+
entry = state["images"].get(image_key) or schema.empty_image()
|
|
64
|
+
categories = {c["id"]: c for c in state["categories"]}
|
|
65
|
+
|
|
66
|
+
features = []
|
|
67
|
+
for feature in entry["features"]:
|
|
68
|
+
category = categories.get(feature["category_id"]) or schema.placeholder_category()
|
|
69
|
+
features.append({
|
|
70
|
+
"type": "Feature",
|
|
71
|
+
"id": feature["id"],
|
|
72
|
+
"geometry": feature["geometry"],
|
|
73
|
+
"properties": {
|
|
74
|
+
"name": feature.get("name") or "",
|
|
75
|
+
"category_id": feature["category_id"],
|
|
76
|
+
"category": category["label"],
|
|
77
|
+
"category_color": category["color"],
|
|
78
|
+
"locked": feature.get("locked", False),
|
|
79
|
+
"created_at": feature.get("created_at"),
|
|
80
|
+
"updated_at": feature.get("updated_at"),
|
|
81
|
+
"source_roi_id": feature.get("source_roi_id"),
|
|
82
|
+
# Per feature as well as per document, so concatenating two
|
|
83
|
+
# projects' exports into one collection -- which is what anyone
|
|
84
|
+
# comparing slides does -- keeps each shape bound to its image.
|
|
85
|
+
**({"image_id": image_id} if image_id is not None else {}),
|
|
86
|
+
**({"notes": feature["notes"]} if feature.get("notes") else {}),
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
"type": "FeatureCollection",
|
|
92
|
+
"plexora": {
|
|
93
|
+
"schema_version": schema.SCHEMA_VERSION,
|
|
94
|
+
"plugin_version": plugin_version,
|
|
95
|
+
"datasource": datasource,
|
|
96
|
+
**({"image_id": image_id} if image_id is not None else {}),
|
|
97
|
+
"exported_at": _now(),
|
|
98
|
+
"coordinate_space": entry["coordinate_space"],
|
|
99
|
+
"categories": state["categories"],
|
|
100
|
+
},
|
|
101
|
+
"features": features,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def validate_document(document, image_size=None):
|
|
106
|
+
"""Check an incoming document, returning what is wrong with it.
|
|
107
|
+
|
|
108
|
+
Returns (errors, warnings). Errors mean it cannot be imported at all;
|
|
109
|
+
warnings mean it can, but the user should be asked first -- which today is
|
|
110
|
+
exactly one thing, and the important one.
|
|
111
|
+
"""
|
|
112
|
+
errors, warnings = [], {}
|
|
113
|
+
|
|
114
|
+
if not isinstance(document, dict):
|
|
115
|
+
return ["that file does not contain a GeoJSON object"], warnings
|
|
116
|
+
if document.get("type") != "FeatureCollection":
|
|
117
|
+
errors.append("expected a GeoJSON FeatureCollection")
|
|
118
|
+
|
|
119
|
+
meta = document.get("plexora")
|
|
120
|
+
if not isinstance(meta, dict):
|
|
121
|
+
# v1 imports Plexora's own exports only. A bare GeoJSON from elsewhere
|
|
122
|
+
# is readable geometry in an unknown coordinate space -- pixels of some
|
|
123
|
+
# image, microns, or degrees -- and importing it would mean guessing
|
|
124
|
+
# which. That guess is the one this whole module exists to avoid.
|
|
125
|
+
errors.append(
|
|
126
|
+
"this file was not exported by Plexora (no 'plexora' metadata), so "
|
|
127
|
+
"there is no way to tell what its coordinates mean"
|
|
128
|
+
)
|
|
129
|
+
else:
|
|
130
|
+
version = meta.get("schema_version")
|
|
131
|
+
if not isinstance(version, int) or isinstance(version, bool):
|
|
132
|
+
errors.append("the file's Plexora metadata has no schema version")
|
|
133
|
+
elif version > schema.SCHEMA_VERSION:
|
|
134
|
+
errors.append(
|
|
135
|
+
f"the file was written by a newer version of Plexora "
|
|
136
|
+
f"(schema {version}, this build reads {schema.SCHEMA_VERSION})"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
features = document.get("features")
|
|
140
|
+
if not isinstance(features, list):
|
|
141
|
+
errors.append("the file has no features list")
|
|
142
|
+
elif len(features) > MAX_IMPORT_FEATURES:
|
|
143
|
+
errors.append(f"the file holds more than {MAX_IMPORT_FEATURES} regions")
|
|
144
|
+
|
|
145
|
+
if errors:
|
|
146
|
+
return errors, warnings
|
|
147
|
+
|
|
148
|
+
stored = (meta.get("coordinate_space") or {}) if isinstance(meta, dict) else {}
|
|
149
|
+
stored_size = (stored.get("width"), stored.get("height"))
|
|
150
|
+
if image_size and all(image_size) and all(stored_size) and tuple(image_size) != stored_size:
|
|
151
|
+
# Not an error: importing anyway is a legitimate thing to want when the
|
|
152
|
+
# two images really are the same field of view at different scales. It
|
|
153
|
+
# is just never the thing to do by default, because the geometry will
|
|
154
|
+
# land somewhere plausible and wrong.
|
|
155
|
+
warnings["dimension_mismatch"] = {
|
|
156
|
+
"found": list(stored_size),
|
|
157
|
+
"expected": list(image_size),
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return errors, warnings
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def import_features(state, document, image_key=schema.DEFAULT_IMAGE):
|
|
164
|
+
"""Add a document's regions to `state`, returning (new_state, report).
|
|
165
|
+
|
|
166
|
+
Nothing is ever overwritten. Every imported region is given a fresh id with
|
|
167
|
+
its original kept in `source_roi_id`, because an id collision here is not a
|
|
168
|
+
conflict to resolve -- the two regions are simply different regions that
|
|
169
|
+
happen to have been numbered the same in two projects, and picking one would
|
|
170
|
+
destroy the other. Importing the same file twice therefore duplicates its
|
|
171
|
+
regions, which is visible and undoable; the alternative silently is not.
|
|
172
|
+
|
|
173
|
+
Categories are matched first by id (a Plexora export carries the same ids)
|
|
174
|
+
and then by label, so importing Tumor into a project that already has one
|
|
175
|
+
lands in the existing category rather than creating "Tumor" twice.
|
|
176
|
+
"""
|
|
177
|
+
by_id = {c["id"]: c for c in state["categories"]}
|
|
178
|
+
by_label = {c["label"].casefold(): c for c in state["categories"]}
|
|
179
|
+
|
|
180
|
+
new_categories, new_features = [], []
|
|
181
|
+
remap = {}
|
|
182
|
+
|
|
183
|
+
meta = document.get("plexora") or {}
|
|
184
|
+
for raw in meta.get("categories") or []:
|
|
185
|
+
if not isinstance(raw, dict):
|
|
186
|
+
continue
|
|
187
|
+
category = schema.normalize_category(raw)
|
|
188
|
+
match = by_id.get(category["id"]) or by_label.get(category["label"].casefold())
|
|
189
|
+
if match is not None:
|
|
190
|
+
remap[category["id"]] = match["id"]
|
|
191
|
+
continue
|
|
192
|
+
new_categories.append(category)
|
|
193
|
+
by_id[category["id"]] = category
|
|
194
|
+
by_label[category["label"].casefold()] = category
|
|
195
|
+
remap[category["id"]] = category["id"]
|
|
196
|
+
|
|
197
|
+
for raw in document.get("features") or []:
|
|
198
|
+
if not isinstance(raw, dict):
|
|
199
|
+
raise ValueError("every entry in 'features' must be an object")
|
|
200
|
+
properties = raw.get("properties") if isinstance(raw.get("properties"), dict) else {}
|
|
201
|
+
|
|
202
|
+
category_id = _resolve_category(properties, remap, by_id, by_label, new_categories)
|
|
203
|
+
new_features.append({
|
|
204
|
+
"id": new_id("r"),
|
|
205
|
+
"category_id": category_id,
|
|
206
|
+
"name": schema.clean_text(properties.get("name")),
|
|
207
|
+
"locked": bool(properties.get("locked", False)),
|
|
208
|
+
"geometry": validate_geometry(raw.get("geometry")),
|
|
209
|
+
"flags": schema.normalize_flags(raw.get("flags")),
|
|
210
|
+
# The id it had where it came from. Kept because it is the only
|
|
211
|
+
# thread back to the source project once ids are regenerated, and
|
|
212
|
+
# somebody reconciling two exports will want it.
|
|
213
|
+
"source_roi_id": schema.clean_text(raw.get("id")) or None,
|
|
214
|
+
"notes": schema.clean_text(properties.get("notes"), schema.MAX_NOTE_LENGTH),
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
"op": "roi.bulk_create",
|
|
219
|
+
"image": image_key,
|
|
220
|
+
"categories": new_categories,
|
|
221
|
+
"features": [f for f in new_features],
|
|
222
|
+
}, {
|
|
223
|
+
"imported": len(new_features),
|
|
224
|
+
"created_categories": len(new_categories),
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _resolve_category(properties, remap, by_id, by_label, new_categories):
|
|
229
|
+
"""Which category an imported feature belongs in.
|
|
230
|
+
|
|
231
|
+
Falls back through: the id the export used, then the label it printed, then
|
|
232
|
+
IMPORTED_LABEL. The label fallback is what makes a feature survive a
|
|
233
|
+
document whose foreign member was stripped by some intermediate tool, and
|
|
234
|
+
the last step is what makes it survive losing its properties entirely.
|
|
235
|
+
|
|
236
|
+
Every path ends at a category that exists or is created here, since there is
|
|
237
|
+
no reserved one to point at.
|
|
238
|
+
"""
|
|
239
|
+
source_id = properties.get("category_id")
|
|
240
|
+
if isinstance(source_id, str) and source_id in remap:
|
|
241
|
+
return remap[source_id]
|
|
242
|
+
if isinstance(source_id, str) and source_id in by_id:
|
|
243
|
+
return source_id
|
|
244
|
+
|
|
245
|
+
label = schema.clean_text(properties.get("category")) or IMPORTED_LABEL
|
|
246
|
+
match = by_label.get(label.casefold())
|
|
247
|
+
if match is not None:
|
|
248
|
+
return match["id"]
|
|
249
|
+
|
|
250
|
+
category = schema.normalize_category({
|
|
251
|
+
"id": new_id("c"),
|
|
252
|
+
"label": label,
|
|
253
|
+
"color": properties.get("category_color"),
|
|
254
|
+
"sort_order": len(by_id),
|
|
255
|
+
})
|
|
256
|
+
new_categories.append(category)
|
|
257
|
+
by_id[category["id"]] = category
|
|
258
|
+
by_label[label.casefold()] = category
|
|
259
|
+
return category["id"]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def new_id(prefix):
|
|
263
|
+
return f"{prefix}-{uuid.uuid4()}"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _now():
|
|
267
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""What counts as a storable ROI geometry.
|
|
2
|
+
|
|
3
|
+
Every geometry that reaches the store passes through `validate_geometry`,
|
|
4
|
+
whether it came from the drawing tools, an import, or a request somebody wrote
|
|
5
|
+
by hand. The client validates too -- it has to, since it decides what to do
|
|
6
|
+
about a bad shape while the user is still holding the mouse -- but the client's
|
|
7
|
+
answer is advice and this one is the rule.
|
|
8
|
+
|
|
9
|
+
Two things are deliberately NOT done here:
|
|
10
|
+
|
|
11
|
+
**No repair.** A self-intersecting polygon is stored as drawn, flagged rather
|
|
12
|
+
than corrected. `make_valid` and friends change topology, and a bow-tie silently
|
|
13
|
+
becoming two triangles is a different annotation from the one the user drew.
|
|
14
|
+
The flag is what surfaces it; the user decides.
|
|
15
|
+
|
|
16
|
+
**No projection, no scaling, no clamping.** Coordinates are full-resolution
|
|
17
|
+
image pixels and are stored exactly as given. A shape that hangs off the edge of
|
|
18
|
+
the image is a real thing to have drawn (a region continuing past the tissue
|
|
19
|
+
edge), and quietly trimming it would lose that.
|
|
20
|
+
|
|
21
|
+
The one normalization that does happen is closing rings. GeoJSON requires the
|
|
22
|
+
first and last position of a ring to be identical; [A,B,C] and [A,B,C,A] denote
|
|
23
|
+
the same polygon to every geometry library there is, so accepting both and
|
|
24
|
+
storing one is not a repair -- it just means nothing downstream has to handle
|
|
25
|
+
two spellings of the same shape.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import math
|
|
31
|
+
|
|
32
|
+
POLYGON = "Polygon"
|
|
33
|
+
MULTI_POLYGON = "MultiPolygon"
|
|
34
|
+
GEOMETRY_TYPES = (POLYGON, MULTI_POLYGON)
|
|
35
|
+
|
|
36
|
+
#: Vertices in one feature, across every ring. A hand-drawn region is a few
|
|
37
|
+
#: hundred points and a simplified freehand trace a few thousand; an imported
|
|
38
|
+
#: tissue contour can legitimately be tens of thousands. Past this, a request is
|
|
39
|
+
#: a mistake or an attempt to make the server chew on something -- and a feature
|
|
40
|
+
#: that big is unusable in the editor anyway, since it is one undo step and one
|
|
41
|
+
#: Path2D rebuild per drag frame.
|
|
42
|
+
MAX_VERTICES = 100_000
|
|
43
|
+
|
|
44
|
+
#: Rings in one polygon (the outer ring plus interior holes), and polygons in
|
|
45
|
+
#: one MultiPolygon. Both only ever arrive from an import; the editor authors a
|
|
46
|
+
#: single ring.
|
|
47
|
+
MAX_RINGS = 1_000
|
|
48
|
+
MAX_POLYGONS = 1_000
|
|
49
|
+
|
|
50
|
+
#: The smallest closed ring that encloses anything: three distinct corners plus
|
|
51
|
+
#: the repeated first point.
|
|
52
|
+
MIN_RING_POSITIONS = 4
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def validate_geometry(geometry, *, max_vertices=MAX_VERTICES):
|
|
56
|
+
"""Return a normalized copy of `geometry`, or raise ValueError.
|
|
57
|
+
|
|
58
|
+
Normalized means: type is one of GEOMETRY_TYPES, every position is a pair of
|
|
59
|
+
finite floats, and every ring is explicitly closed.
|
|
60
|
+
"""
|
|
61
|
+
if not isinstance(geometry, dict):
|
|
62
|
+
raise ValueError("geometry must be an object")
|
|
63
|
+
|
|
64
|
+
kind = geometry.get("type")
|
|
65
|
+
if kind not in GEOMETRY_TYPES:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"unsupported geometry type {kind!r}: expected one of {list(GEOMETRY_TYPES)}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
coordinates = geometry.get("coordinates")
|
|
71
|
+
budget = _Budget(max_vertices)
|
|
72
|
+
|
|
73
|
+
if kind == POLYGON:
|
|
74
|
+
normalized = _polygon(coordinates, budget)
|
|
75
|
+
else:
|
|
76
|
+
if not isinstance(coordinates, list) or not coordinates:
|
|
77
|
+
raise ValueError("MultiPolygon coordinates must be a non-empty list of polygons")
|
|
78
|
+
if len(coordinates) > MAX_POLYGONS:
|
|
79
|
+
raise ValueError(f"MultiPolygon has more than {MAX_POLYGONS} polygons")
|
|
80
|
+
normalized = [_polygon(part, budget) for part in coordinates]
|
|
81
|
+
|
|
82
|
+
return {"type": kind, "coordinates": normalized}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _Budget:
|
|
86
|
+
"""Vertex allowance for one feature, shared across all its rings.
|
|
87
|
+
|
|
88
|
+
Counted for the whole geometry rather than per ring: a thousand rings of a
|
|
89
|
+
hundred points each is the same amount of work as one ring of a hundred
|
|
90
|
+
thousand, and only a per-feature total sees both.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(self, limit):
|
|
94
|
+
self.limit = limit
|
|
95
|
+
self.used = 0
|
|
96
|
+
|
|
97
|
+
def spend(self, count):
|
|
98
|
+
self.used += count
|
|
99
|
+
if self.used > self.limit:
|
|
100
|
+
raise ValueError(f"geometry has more than {self.limit} vertices")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _polygon(rings, budget):
|
|
104
|
+
if not isinstance(rings, list) or not rings:
|
|
105
|
+
raise ValueError("polygon coordinates must be a non-empty list of rings")
|
|
106
|
+
if len(rings) > MAX_RINGS:
|
|
107
|
+
raise ValueError(f"polygon has more than {MAX_RINGS} rings")
|
|
108
|
+
return [_ring(ring, budget) for ring in rings]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _ring(ring, budget):
|
|
112
|
+
if not isinstance(ring, list):
|
|
113
|
+
raise ValueError("a ring must be a list of positions")
|
|
114
|
+
budget.spend(len(ring))
|
|
115
|
+
|
|
116
|
+
points = [_position(p) for p in ring]
|
|
117
|
+
points = close_ring(points)
|
|
118
|
+
|
|
119
|
+
if len(points) < MIN_RING_POSITIONS:
|
|
120
|
+
raise ValueError(
|
|
121
|
+
"a ring needs at least three distinct positions "
|
|
122
|
+
f"(got {len(points) - 1 if points else 0})"
|
|
123
|
+
)
|
|
124
|
+
# Three points that happen to be the same point are four positions once
|
|
125
|
+
# closed, and enclose nothing. Counted on the open ring so the deliberate
|
|
126
|
+
# closing duplicate is not mistaken for a degenerate one.
|
|
127
|
+
if len({(x, y) for x, y in points[:-1]}) < 3:
|
|
128
|
+
raise ValueError("a ring needs at least three distinct positions")
|
|
129
|
+
return points
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _position(position):
|
|
133
|
+
if not isinstance(position, (list, tuple)) or len(position) < 2:
|
|
134
|
+
raise ValueError("a position must be a [x, y] pair")
|
|
135
|
+
x, y = position[0], position[1]
|
|
136
|
+
# bool is an int; a JSON `true` reaching a coordinate is a malformed payload
|
|
137
|
+
# rather than the number 1.
|
|
138
|
+
for value in (x, y):
|
|
139
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
140
|
+
raise ValueError(f"coordinate {value!r} is not a number")
|
|
141
|
+
if not math.isfinite(value):
|
|
142
|
+
# NaN and Infinity survive json.loads by default, so this is the
|
|
143
|
+
# check that keeps them out of the store -- and out of every
|
|
144
|
+
# consumer that would otherwise get them back as invalid JSON.
|
|
145
|
+
raise ValueError("coordinates must be finite numbers")
|
|
146
|
+
return [float(x), float(y)]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def close_ring(points):
|
|
150
|
+
"""A ring whose last position repeats its first."""
|
|
151
|
+
if not points:
|
|
152
|
+
return points
|
|
153
|
+
if points[0] != points[-1]:
|
|
154
|
+
return [*points, list(points[0])]
|
|
155
|
+
return points
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def geometry_bounds(geometry):
|
|
159
|
+
"""(min_x, min_y, max_x, max_y) over every position, or None if empty."""
|
|
160
|
+
xs, ys = [], []
|
|
161
|
+
for ring in iter_rings(geometry):
|
|
162
|
+
for x, y in ring:
|
|
163
|
+
xs.append(x)
|
|
164
|
+
ys.append(y)
|
|
165
|
+
if not xs:
|
|
166
|
+
return None
|
|
167
|
+
return (min(xs), min(ys), max(xs), max(ys))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def iter_rings(geometry):
|
|
171
|
+
"""Every ring in a Polygon or MultiPolygon, outer and interior alike."""
|
|
172
|
+
coordinates = (geometry or {}).get("coordinates") or []
|
|
173
|
+
if (geometry or {}).get("type") == MULTI_POLYGON:
|
|
174
|
+
for polygon in coordinates:
|
|
175
|
+
yield from polygon
|
|
176
|
+
else:
|
|
177
|
+
yield from coordinates
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def vertex_count(geometry):
|
|
181
|
+
return sum(len(ring) for ring in iter_rings(geometry))
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Which cells fall inside which regions.
|
|
2
|
+
|
|
3
|
+
The one place the annotation engine meets the cell table, and it stays a thin
|
|
4
|
+
one on purpose: this module answers "for each point, which ROIs contain it" and
|
|
5
|
+
turns that into two label columns. It reads no files and writes none -- the
|
|
6
|
+
callers in `adapters.py` do that -- so the containment rules can be tested
|
|
7
|
+
against hand-built geometry rather than against an .h5ad.
|
|
8
|
+
|
|
9
|
+
Two rules worth stating, because both are choices and neither is recoverable
|
|
10
|
+
from the code at a glance:
|
|
11
|
+
|
|
12
|
+
**A cell in several ROIs belongs to all of them.** Regions overlap on purpose --
|
|
13
|
+
a tumour nest inside a stromal region is not a mistake to be resolved by picking
|
|
14
|
+
one -- so the labels are joined with `_` rather than one winning. Names are
|
|
15
|
+
joined in document order, which is the order the GeoJSON export writes and the
|
|
16
|
+
panel lists, so two exports of the same project agree. Categories are joined the
|
|
17
|
+
same way but deduplicated: a cell inside two Tumor regions is `Tumor`, not
|
|
18
|
+
`Tumor_Tumor`, because the question the category column answers is "what kind of
|
|
19
|
+
place is this cell in".
|
|
20
|
+
|
|
21
|
+
**A cell in a hole is outside.** Interior rings are honoured, which is the whole
|
|
22
|
+
reason this goes through shapely rather than a hand-rolled ray cast: a donut
|
|
23
|
+
region drawn around a necrotic core means the core is not in it.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
#: Between the ROIs a cell falls in. Underscore rather than a comma or a pipe
|
|
29
|
+
#: because these land in a column that gets read back as a category level, and
|
|
30
|
+
#: a separator that survives a CSV round trip without quoting is worth more
|
|
31
|
+
#: here than one that reads well in a sentence.
|
|
32
|
+
JOINER = "_"
|
|
33
|
+
|
|
34
|
+
#: What a cell in no region gets. An empty string rather than a word like
|
|
35
|
+
#: "None" or "Background": those are labels a user might legitimately give a
|
|
36
|
+
#: category, and a value that cannot be told apart from a real answer is worse
|
|
37
|
+
#: than a blank. Callers turn this into NA where the format has one.
|
|
38
|
+
UNASSIGNED = ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def assign(features, categories, xs, ys):
|
|
42
|
+
"""(category_label, roi_name) for each point, in point order.
|
|
43
|
+
|
|
44
|
+
`features` is the ROI list for one image, in document order; `categories`
|
|
45
|
+
is the project's category list. Points are full-resolution image pixels --
|
|
46
|
+
the same space ROI geometry is stored in, so nothing is transformed here.
|
|
47
|
+
|
|
48
|
+
Returns two lists as long as `xs`. A point in no region gets UNASSIGNED in
|
|
49
|
+
both, never a partial answer in one.
|
|
50
|
+
"""
|
|
51
|
+
count = len(xs)
|
|
52
|
+
if len(ys) != count:
|
|
53
|
+
raise ValueError("x and y must be the same length")
|
|
54
|
+
names = [UNASSIGNED] * count
|
|
55
|
+
labels = [UNASSIGNED] * count
|
|
56
|
+
if not count or not features:
|
|
57
|
+
return labels, names
|
|
58
|
+
|
|
59
|
+
import numpy as np
|
|
60
|
+
import shapely
|
|
61
|
+
from shapely import geometry as sgeom
|
|
62
|
+
|
|
63
|
+
by_id = {c["id"]: c for c in categories}
|
|
64
|
+
polygons = [_shapely(feature["geometry"], sgeom) for feature in features]
|
|
65
|
+
|
|
66
|
+
# One vectorised containment query rather than a Python loop over cells: a
|
|
67
|
+
# real slide is 10^5-10^6 cells against 10^1-10^3 regions, and the loop
|
|
68
|
+
# version of this is minutes. `within` is strict about the boundary, which
|
|
69
|
+
# is the same predicate a point-in-polygon test would give and is not worth
|
|
70
|
+
# softening -- a centroid exactly on a hand-drawn edge is a coin flip
|
|
71
|
+
# whatever rule is picked.
|
|
72
|
+
tree = shapely.STRtree(polygons)
|
|
73
|
+
points = shapely.points(np.asarray(xs, dtype="float64"),
|
|
74
|
+
np.asarray(ys, dtype="float64"))
|
|
75
|
+
point_index, polygon_index = tree.query(points, predicate="within")
|
|
76
|
+
|
|
77
|
+
# Sorted by polygon index within each point, so the joined order is
|
|
78
|
+
# document order and not whatever order the index happened to return.
|
|
79
|
+
order = np.lexsort((polygon_index, point_index))
|
|
80
|
+
hits = {}
|
|
81
|
+
for position in order:
|
|
82
|
+
hits.setdefault(int(point_index[position]), []).append(int(polygon_index[position]))
|
|
83
|
+
|
|
84
|
+
for row, matched in hits.items():
|
|
85
|
+
matched_names = []
|
|
86
|
+
matched_labels = []
|
|
87
|
+
for index in matched:
|
|
88
|
+
feature = features[index]
|
|
89
|
+
category = by_id.get(feature.get("category_id")) or {}
|
|
90
|
+
matched_names.append(feature.get("name") or "")
|
|
91
|
+
label = category.get("label") or ""
|
|
92
|
+
# Deduplicated in first-seen order. See the module docstring: the
|
|
93
|
+
# category column says what KIND of place a cell is in, and
|
|
94
|
+
# repeating it once per overlapping region says nothing extra.
|
|
95
|
+
if label not in matched_labels:
|
|
96
|
+
matched_labels.append(label)
|
|
97
|
+
names[row] = JOINER.join(matched_names)
|
|
98
|
+
labels[row] = JOINER.join(matched_labels)
|
|
99
|
+
|
|
100
|
+
return labels, names
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def current_image_id(dataset):
|
|
104
|
+
"""The image-id value this project's cells carry, or None.
|
|
105
|
+
|
|
106
|
+
None is a real answer and not a failure: "this table covers one image"
|
|
107
|
+
(DataSpec.single_image) and "there is no table at all" both land here, and
|
|
108
|
+
in both cases every row the project can see belongs to this image.
|
|
109
|
+
|
|
110
|
+
Deliberately the same rule gating applies in `resolve_current_image_id` --
|
|
111
|
+
the two plugins have to mean the same thing by "which image is this
|
|
112
|
+
project", or the same .h5ad gets gates filed under one name and ROIs under
|
|
113
|
+
another. It is read off the loaded frame rather than the file because the
|
|
114
|
+
frame is already narrowed by the project's registration subset, which is
|
|
115
|
+
exactly the scoping the question needs.
|
|
116
|
+
|
|
117
|
+
Raises ValueError when the column holds more than one value within this
|
|
118
|
+
project's own rows. Refusing to guess is the entire point of asking which
|
|
119
|
+
column it is: writing ROI columns against the wrong image is not a
|
|
120
|
+
cosmetic error, it annotates somebody else's cells.
|
|
121
|
+
"""
|
|
122
|
+
if dataset.schema is None or not dataset.table.available:
|
|
123
|
+
return None
|
|
124
|
+
column = dataset.schema.image_id
|
|
125
|
+
if not column:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
# The registration subset first, and without reading anything. For an
|
|
129
|
+
# AnnData or SpatialData project narrowed to one image at import, the value
|
|
130
|
+
# the user picked IS the answer -- and it is the only place the answer
|
|
131
|
+
# reliably survives, because the adapter emits a table of its own columns
|
|
132
|
+
# and need not carry the obs column the subset was taken on.
|
|
133
|
+
source = dataset.table.source
|
|
134
|
+
subset = dict((source.subset if source else None) or {})
|
|
135
|
+
if subset.get("column") == column and subset.get("value") is not None:
|
|
136
|
+
return str(subset["value"])
|
|
137
|
+
|
|
138
|
+
frame = dataset.table.frame()
|
|
139
|
+
if frame is None or column not in frame.columns:
|
|
140
|
+
return None
|
|
141
|
+
values = [value for value in frame[column].unique().to_list() if value is not None]
|
|
142
|
+
if not values:
|
|
143
|
+
return None
|
|
144
|
+
if len(values) > 1:
|
|
145
|
+
raise ValueError(
|
|
146
|
+
f"column {column!r} holds {len(values)} different image ids within "
|
|
147
|
+
f"this project's own cells, so Plexora cannot tell which image "
|
|
148
|
+
f"these regions belong to"
|
|
149
|
+
)
|
|
150
|
+
return str(values[0])
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _shapely(geometry, sgeom):
|
|
154
|
+
"""A stored geometry as a shapely object, holes and all.
|
|
155
|
+
|
|
156
|
+
The same conversion `adapters.save_to_spatialdata` does. Kept as its own
|
|
157
|
+
copy rather than imported from there because that module is the file-writing
|
|
158
|
+
one and this module deliberately touches no files -- the tests for each
|
|
159
|
+
should be able to run without the other's dependencies.
|
|
160
|
+
"""
|
|
161
|
+
if (geometry or {}).get("type") == "MultiPolygon":
|
|
162
|
+
return sgeom.MultiPolygon(
|
|
163
|
+
[_polygon(part, sgeom) for part in geometry["coordinates"]])
|
|
164
|
+
return _polygon((geometry or {}).get("coordinates") or [], sgeom)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _polygon(rings, sgeom):
|
|
168
|
+
outer, *holes = rings
|
|
169
|
+
return sgeom.Polygon(outer, holes)
|