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,323 @@
|
|
|
1
|
+
"""The shape of stored ROI state, and what a valid piece of it looks like.
|
|
2
|
+
|
|
3
|
+
One versioned JSON document per datasource holds everything: the categories, the
|
|
4
|
+
features grouped by image, and the revision the conflict check turns on.
|
|
5
|
+
|
|
6
|
+
{
|
|
7
|
+
"schema_version": 1,
|
|
8
|
+
"revision": 42,
|
|
9
|
+
"categories": [ {id, label, color, visible, locked, sort_order}, ... ],
|
|
10
|
+
"images": {
|
|
11
|
+
"default": {
|
|
12
|
+
"coordinate_space": {"type": "image_pixels", "width": W, "height": H},
|
|
13
|
+
"features": [ {id, category_id, name, locked, geometry, ...}, ... ]
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"settings": {"destination": "rois"}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
Two structural decisions worth stating, because both are cheap now and
|
|
20
|
+
expensive later:
|
|
21
|
+
|
|
22
|
+
**Categories are project-level, features are image-level.** A user who defines
|
|
23
|
+
Tumor/Stroma/Necrosis on one image expects those same choices on the next one;
|
|
24
|
+
what they do not expect is the first image's shapes to follow them there. The
|
|
25
|
+
`images` map is keyed by image identity for that reason, even though a Plexora
|
|
26
|
+
project holds exactly one image today and the only key ever written is
|
|
27
|
+
`"default"`. Every operation carries the key it applies to, so adding a second
|
|
28
|
+
image later adds entries rather than changing the format.
|
|
29
|
+
|
|
30
|
+
**`coordinate_space` is stored, not assumed.** It is what catches the dangerous
|
|
31
|
+
case: the same datasource name now pointing at a different image. Geometry drawn
|
|
32
|
+
on a 92,164 x 76,510 slide looks perfectly plausible on a 101,212 x 84,039 one,
|
|
33
|
+
and nothing about the numbers themselves says otherwise -- so the dimensions
|
|
34
|
+
travel with the annotations and are checked on load.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import re
|
|
40
|
+
|
|
41
|
+
from plexora.plugins.roi.server.geometry import validate_geometry
|
|
42
|
+
|
|
43
|
+
SCHEMA_VERSION = 1
|
|
44
|
+
|
|
45
|
+
#: The image key used while a project holds one image. See the module docstring.
|
|
46
|
+
DEFAULT_IMAGE = "default"
|
|
47
|
+
|
|
48
|
+
#: A project starts with NO categories. The names a pathologist works in are
|
|
49
|
+
#: theirs to choose, and a built-in "Uncategorized" that cannot be renamed or
|
|
50
|
+
#: deleted is a label nobody asked for sitting permanently at the top of their
|
|
51
|
+
#: list -- and one that quietly collects shapes they meant to file properly.
|
|
52
|
+
#: The panel asks for the first category instead: one interaction, once, at the
|
|
53
|
+
#: point where the user already knows what they are about to draw.
|
|
54
|
+
#:
|
|
55
|
+
#: Nothing is special-cased any more, so a document written by an earlier build
|
|
56
|
+
#: keeps whatever "uncategorized" it has and that category behaves like every
|
|
57
|
+
#: other one -- renameable, recolourable, deletable.
|
|
58
|
+
DEFAULT_COLOR = "#8b93a6"
|
|
59
|
+
|
|
60
|
+
#: What a category with a blank name is called, so the list never has a row the
|
|
61
|
+
#: user cannot see or click.
|
|
62
|
+
UNTITLED_LABEL = "Untitled"
|
|
63
|
+
|
|
64
|
+
#: Ids are generated by the client (crypto.randomUUID with a short prefix) so a
|
|
65
|
+
#: shape appears the instant it is drawn rather than after a round trip. They are
|
|
66
|
+
#: validated rather than trusted: they end up in DOM attributes and selectors.
|
|
67
|
+
ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
|
|
68
|
+
|
|
69
|
+
#: Free text the user typed. Length-capped and stripped of control characters
|
|
70
|
+
#: here; rendered with textContent, never innerHTML, on the client.
|
|
71
|
+
MAX_LABEL_LENGTH = 200
|
|
72
|
+
MAX_NOTE_LENGTH = 4_000
|
|
73
|
+
_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
|
74
|
+
|
|
75
|
+
COLOR_PATTERN = re.compile(r"^#[0-9a-fA-F]{6}$")
|
|
76
|
+
|
|
77
|
+
#: Ceilings on one project's annotations. Generous -- these exist to stop a
|
|
78
|
+
#: runaway client or a malformed import, not to tell a pathologist how many
|
|
79
|
+
#: regions their slide has.
|
|
80
|
+
MAX_CATEGORIES = 1_000
|
|
81
|
+
MAX_FEATURES = 100_000
|
|
82
|
+
|
|
83
|
+
FEATURE_FIELDS = (
|
|
84
|
+
"id", "category_id", "name", "locked", "created_at", "updated_at",
|
|
85
|
+
"geometry", "flags", "source_roi_id", "notes",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def default_state(width=None, height=None):
|
|
90
|
+
"""The state of a project nobody has annotated yet.
|
|
91
|
+
|
|
92
|
+
Returned rather than written: a project with no ROIs should not acquire a
|
|
93
|
+
stored blob just because somebody opened the panel, and revision 0 is what
|
|
94
|
+
makes the first save from any client a well-formed first write.
|
|
95
|
+
|
|
96
|
+
No categories, deliberately -- see DEFAULT_COLOR above.
|
|
97
|
+
"""
|
|
98
|
+
return {
|
|
99
|
+
"schema_version": SCHEMA_VERSION,
|
|
100
|
+
"revision": 0,
|
|
101
|
+
"categories": [],
|
|
102
|
+
"images": {DEFAULT_IMAGE: empty_image(width, height)},
|
|
103
|
+
"settings": default_settings(),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def default_settings():
|
|
108
|
+
"""Project-level preferences that are not annotations.
|
|
109
|
+
|
|
110
|
+
One entry so far: `destination`, the name of the place inside the source
|
|
111
|
+
file that this project last exported to -- the key under `uns/plexora` for
|
|
112
|
+
an AnnData, the shapes element for a SpatialData store.
|
|
113
|
+
|
|
114
|
+
Blank means nobody has exported yet, and the panel fills in the default for
|
|
115
|
+
whichever kind of file the project came from. Storing a name here rather
|
|
116
|
+
than a per-kind default means the schema never has to know what the project
|
|
117
|
+
was imported from.
|
|
118
|
+
"""
|
|
119
|
+
return {"destination": ""}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def normalize_settings(raw):
|
|
123
|
+
return {"destination": clean_text((raw or {}).get("destination"))}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def element_name(value, default):
|
|
127
|
+
"""A name the user typed for a place inside their own file.
|
|
128
|
+
|
|
129
|
+
Both destinations need the same rule and neither can afford a looser one:
|
|
130
|
+
the AnnData key becomes a group under `uns/plexora`, the SpatialData
|
|
131
|
+
element becomes a directory inside the store. A path is not a name.
|
|
132
|
+
|
|
133
|
+
Blank means "whatever you were going to call it" -- somebody pressing save
|
|
134
|
+
with an empty field has not made a mistake -- so it becomes the default
|
|
135
|
+
rather than an error. Stripped first, so that holds for whitespace too.
|
|
136
|
+
"""
|
|
137
|
+
value = clean_text(value)
|
|
138
|
+
if not value:
|
|
139
|
+
return default
|
|
140
|
+
if "/" in value or "\\" in value or value.startswith("."):
|
|
141
|
+
raise ValueError(f"invalid name {value!r}: it cannot be a path")
|
|
142
|
+
return value
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def placeholder_category():
|
|
146
|
+
"""Stand-in for a category a feature names but the document no longer holds.
|
|
147
|
+
|
|
148
|
+
Only ever used on the way OUT -- an export or a native save filling in a
|
|
149
|
+
label and a colour beside each shape -- so that one dangling reference costs
|
|
150
|
+
a blank label rather than a KeyError halfway through writing somebody's
|
|
151
|
+
file. Nothing reads it back in, and it is never added to a project.
|
|
152
|
+
"""
|
|
153
|
+
return {
|
|
154
|
+
"id": "",
|
|
155
|
+
"label": "",
|
|
156
|
+
"color": DEFAULT_COLOR,
|
|
157
|
+
"visible": True,
|
|
158
|
+
"locked": False,
|
|
159
|
+
"sort_order": 0,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def empty_image(width=None, height=None):
|
|
164
|
+
return {"coordinate_space": coordinate_space(width, height), "features": []}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def coordinate_space(width=None, height=None):
|
|
168
|
+
"""How to read the numbers in a geometry.
|
|
169
|
+
|
|
170
|
+
Spelled out rather than implied because "image pixels" is one of several
|
|
171
|
+
things a viewer could plausibly have stored -- screen pixels, viewport
|
|
172
|
+
fractions, coordinates at whatever pyramid level was on screen -- and a
|
|
173
|
+
consumer reading this file in five years has no way to tell which.
|
|
174
|
+
"""
|
|
175
|
+
return {
|
|
176
|
+
"type": "image_pixels",
|
|
177
|
+
"width": int(width) if width else None,
|
|
178
|
+
"height": int(height) if height else None,
|
|
179
|
+
"origin": "top-left",
|
|
180
|
+
"axis_order": "xy",
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def normalize_state(raw, width=None, height=None):
|
|
185
|
+
"""Coerce a loaded document into the current shape.
|
|
186
|
+
|
|
187
|
+
Tolerant on the way in and strict on the way out: a document written by an
|
|
188
|
+
older build is upgraded, and anything unrecognisable is replaced rather than
|
|
189
|
+
allowed to half-load. Losing annotations to a corrupt blob is bad; drawing a
|
|
190
|
+
project's regions from a half-parsed one is worse, because it looks fine.
|
|
191
|
+
"""
|
|
192
|
+
if not isinstance(raw, dict):
|
|
193
|
+
return default_state(width, height)
|
|
194
|
+
|
|
195
|
+
version = raw.get("schema_version")
|
|
196
|
+
if version is not None and not isinstance(version, bool) and isinstance(version, int):
|
|
197
|
+
if version > SCHEMA_VERSION:
|
|
198
|
+
raise ValueError(
|
|
199
|
+
f"these ROI annotations were written by a newer version of Plexora "
|
|
200
|
+
f"(schema {version}, this build reads {SCHEMA_VERSION})"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
categories = [normalize_category(c) for c in raw.get("categories") or []
|
|
204
|
+
if isinstance(c, dict) and c.get("id")]
|
|
205
|
+
|
|
206
|
+
images = {}
|
|
207
|
+
for key, entry in (raw.get("images") or {}).items():
|
|
208
|
+
if not isinstance(entry, dict):
|
|
209
|
+
continue
|
|
210
|
+
stored = entry.get("coordinate_space") or {}
|
|
211
|
+
images[str(key)] = {
|
|
212
|
+
"coordinate_space": {
|
|
213
|
+
**coordinate_space(stored.get("width"), stored.get("height")),
|
|
214
|
+
**{k: v for k, v in stored.items() if k in ("type", "origin", "axis_order")},
|
|
215
|
+
},
|
|
216
|
+
"features": [normalize_feature(f) for f in entry.get("features") or []
|
|
217
|
+
if isinstance(f, dict) and f.get("id")],
|
|
218
|
+
}
|
|
219
|
+
if DEFAULT_IMAGE not in images:
|
|
220
|
+
images[DEFAULT_IMAGE] = empty_image(width, height)
|
|
221
|
+
|
|
222
|
+
revision = raw.get("revision")
|
|
223
|
+
return {
|
|
224
|
+
"schema_version": SCHEMA_VERSION,
|
|
225
|
+
"revision": int(revision) if isinstance(revision, int) and not isinstance(revision, bool) else 0,
|
|
226
|
+
"categories": categories,
|
|
227
|
+
"images": images,
|
|
228
|
+
"settings": normalize_settings(raw.get("settings")),
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def normalize_category(raw):
|
|
233
|
+
"""One category, with every field present and of the right type."""
|
|
234
|
+
return {
|
|
235
|
+
"id": validate_id(raw.get("id"), "category id"),
|
|
236
|
+
"label": clean_text(raw.get("label")) or UNTITLED_LABEL,
|
|
237
|
+
"color": color(raw.get("color")),
|
|
238
|
+
"visible": bool(raw.get("visible", True)),
|
|
239
|
+
"locked": bool(raw.get("locked", False)),
|
|
240
|
+
"sort_order": as_int(raw.get("sort_order"), 0),
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def normalize_feature(raw):
|
|
245
|
+
"""One ROI, with its geometry validated and unknown fields dropped."""
|
|
246
|
+
feature = {
|
|
247
|
+
"id": validate_id(raw.get("id"), "ROI id"),
|
|
248
|
+
# Required, with nothing to fall back to: there is no catch-all category
|
|
249
|
+
# to file a shape under any more, and inventing one here would put it
|
|
250
|
+
# somewhere the user never chose.
|
|
251
|
+
"category_id": validate_id(raw.get("category_id"), "category id"),
|
|
252
|
+
"name": clean_text(raw.get("name")),
|
|
253
|
+
"locked": bool(raw.get("locked", False)),
|
|
254
|
+
"created_at": clean_text(raw.get("created_at")) or None,
|
|
255
|
+
"updated_at": clean_text(raw.get("updated_at")) or None,
|
|
256
|
+
"geometry": validate_geometry(raw.get("geometry")),
|
|
257
|
+
"flags": normalize_flags(raw.get("flags")),
|
|
258
|
+
"source_roi_id": clean_text(raw.get("source_roi_id")) or None,
|
|
259
|
+
}
|
|
260
|
+
notes = clean_text(raw.get("notes"), MAX_NOTE_LENGTH)
|
|
261
|
+
if notes:
|
|
262
|
+
feature["notes"] = notes
|
|
263
|
+
return feature
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def normalize_flags(raw):
|
|
267
|
+
"""Advisory marks the editor put on a shape.
|
|
268
|
+
|
|
269
|
+
`self_intersecting` is computed on the client, where the shape is being
|
|
270
|
+
drawn, and recorded rather than acted on -- the server neither verifies nor
|
|
271
|
+
repairs it. It exists so the outline can be drawn differently and the user
|
|
272
|
+
can see there is something to look at.
|
|
273
|
+
"""
|
|
274
|
+
raw = raw if isinstance(raw, dict) else {}
|
|
275
|
+
return {"self_intersecting": bool(raw.get("self_intersecting", False))}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def validate_id(value, what):
|
|
279
|
+
if not isinstance(value, str) or not ID_PATTERN.match(value):
|
|
280
|
+
raise ValueError(f"invalid {what}: {value!r}")
|
|
281
|
+
return value
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def clean_text(value, limit=MAX_LABEL_LENGTH):
|
|
285
|
+
"""User-typed text, made safe to store and hand back.
|
|
286
|
+
|
|
287
|
+
Control characters go (they serve no purpose in a label and make a mess of
|
|
288
|
+
every downstream format), and length is capped. Nothing is HTML-escaped
|
|
289
|
+
here: escaping at the store would double-escape on every round trip, and the
|
|
290
|
+
place that has to be careful is the one that puts it in the DOM.
|
|
291
|
+
"""
|
|
292
|
+
if value is None:
|
|
293
|
+
return ""
|
|
294
|
+
if not isinstance(value, str):
|
|
295
|
+
value = str(value)
|
|
296
|
+
return _CONTROL_CHARS.sub("", value).strip()[:limit]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def color(value):
|
|
300
|
+
if isinstance(value, str) and COLOR_PATTERN.match(value):
|
|
301
|
+
return value.lower()
|
|
302
|
+
return DEFAULT_COLOR
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def as_int(value, fallback):
|
|
306
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
307
|
+
return fallback
|
|
308
|
+
return int(value)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def image_entry(state, image_key):
|
|
312
|
+
"""The entry for one image, or a ValueError naming what was asked for."""
|
|
313
|
+
entry = (state.get("images") or {}).get(image_key)
|
|
314
|
+
if entry is None:
|
|
315
|
+
raise ValueError(f"unknown image {image_key!r}")
|
|
316
|
+
return entry
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def find(items, item_id):
|
|
320
|
+
for index, item in enumerate(items):
|
|
321
|
+
if item.get("id") == item_id:
|
|
322
|
+
return index, item
|
|
323
|
+
return -1, None
|