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,293 @@
|
|
|
1
|
+
"""One edit, applied to the annotation state.
|
|
2
|
+
|
|
3
|
+
Edits arrive as a list of named operations rather than as a replacement
|
|
4
|
+
document. That is not an optimization -- the store writes the whole blob either
|
|
5
|
+
way today -- it is what makes the write meaningful. "Set the state to this"
|
|
6
|
+
cannot tell a deliberate deletion from a client that lost half its state and
|
|
7
|
+
saved anyway, and it cannot be undone, replayed, or reasoned about after the
|
|
8
|
+
fact. "Delete roi_7" can.
|
|
9
|
+
|
|
10
|
+
Every operation is applied to a copy and the batch is atomic: an invalid
|
|
11
|
+
operation anywhere leaves the stored state exactly as it was, rather than half
|
|
12
|
+
of a user's action landing.
|
|
13
|
+
|
|
14
|
+
The rules that live here rather than on the client, because the client's copy is
|
|
15
|
+
advice and this one is the rule:
|
|
16
|
+
|
|
17
|
+
* a locked ROI (or one in a locked category) cannot have its geometry changed or
|
|
18
|
+
be deleted -- but CAN be renamed and recategorized, because the lock is on the
|
|
19
|
+
shape, not on what it means;
|
|
20
|
+
* two categories cannot share a label, since the label is how the user tells
|
|
21
|
+
them apart;
|
|
22
|
+
* every category is deletable and renameable, including the last one -- there is
|
|
23
|
+
no reserved catch-all, so no row of the list behaves differently from its
|
|
24
|
+
neighbours;
|
|
25
|
+
* deleting a category with shapes in it requires saying what happens to them,
|
|
26
|
+
and `reassign` requires naming the category they move to. There is no default
|
|
27
|
+
for either: silently deleting a user's regions because they tidied up a label
|
|
28
|
+
is the one outcome nobody wants, and silently keeping them under a category
|
|
29
|
+
that no longer exists is how you get shapes that cannot be drawn.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import copy
|
|
35
|
+
from datetime import datetime, timezone
|
|
36
|
+
|
|
37
|
+
from plexora.plugins.roi.server import schema
|
|
38
|
+
from plexora.plugins.roi.server.geometry import validate_geometry
|
|
39
|
+
|
|
40
|
+
#: How many operations one request may carry. An import arrives as a single
|
|
41
|
+
#: bulk operation rather than N creates, so a batch this long is a client
|
|
42
|
+
#: misbehaving.
|
|
43
|
+
MAX_OPERATIONS = 5_000
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def apply_operations(state, operations):
|
|
47
|
+
"""Apply every operation in order, returning a new state.
|
|
48
|
+
|
|
49
|
+
Raises ValueError -- with a message meant for the user -- if any of them is
|
|
50
|
+
invalid, having changed nothing.
|
|
51
|
+
"""
|
|
52
|
+
if not isinstance(operations, list):
|
|
53
|
+
raise ValueError("operations must be a list")
|
|
54
|
+
if len(operations) > MAX_OPERATIONS:
|
|
55
|
+
raise ValueError(f"too many operations in one request (max {MAX_OPERATIONS})")
|
|
56
|
+
|
|
57
|
+
working = copy.deepcopy(state)
|
|
58
|
+
for operation in operations:
|
|
59
|
+
if not isinstance(operation, dict):
|
|
60
|
+
raise ValueError("each operation must be an object")
|
|
61
|
+
name = operation.get("op")
|
|
62
|
+
handler = _HANDLERS.get(name)
|
|
63
|
+
if handler is None:
|
|
64
|
+
raise ValueError(f"unknown operation {name!r}")
|
|
65
|
+
handler(working, operation)
|
|
66
|
+
return working
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def now():
|
|
70
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# -- categories ---------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _category_create(state, op):
|
|
77
|
+
raw = op.get("category")
|
|
78
|
+
if not isinstance(raw, dict):
|
|
79
|
+
raise ValueError("category.create needs a category")
|
|
80
|
+
category = schema.normalize_category(raw)
|
|
81
|
+
|
|
82
|
+
_, existing = schema.find(state["categories"], category["id"])
|
|
83
|
+
if existing is not None:
|
|
84
|
+
raise ValueError(f"category {category['id']!r} already exists")
|
|
85
|
+
if len(state["categories"]) >= schema.MAX_CATEGORIES:
|
|
86
|
+
raise ValueError(f"a project may hold at most {schema.MAX_CATEGORIES} categories")
|
|
87
|
+
_require_unique_label(state, category["label"], category["id"])
|
|
88
|
+
|
|
89
|
+
state["categories"].append(category)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _category_update(state, op):
|
|
93
|
+
category = _category(state, op.get("id"))
|
|
94
|
+
changes = op.get("changes")
|
|
95
|
+
if not isinstance(changes, dict):
|
|
96
|
+
raise ValueError("category.update needs changes")
|
|
97
|
+
|
|
98
|
+
if "label" in changes:
|
|
99
|
+
label = schema.clean_text(changes["label"])
|
|
100
|
+
if not label:
|
|
101
|
+
raise ValueError("a category needs a name")
|
|
102
|
+
_require_unique_label(state, label, category["id"])
|
|
103
|
+
category["label"] = label
|
|
104
|
+
if "color" in changes:
|
|
105
|
+
category["color"] = schema.color(changes["color"])
|
|
106
|
+
if "visible" in changes:
|
|
107
|
+
category["visible"] = bool(changes["visible"])
|
|
108
|
+
if "locked" in changes:
|
|
109
|
+
category["locked"] = bool(changes["locked"])
|
|
110
|
+
if "sort_order" in changes:
|
|
111
|
+
category["sort_order"] = schema.as_int(changes["sort_order"], category["sort_order"])
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _category_delete(state, op):
|
|
115
|
+
category = _category(state, op.get("id"))
|
|
116
|
+
|
|
117
|
+
orphans = op.get("orphans")
|
|
118
|
+
if orphans not in ("delete", "reassign"):
|
|
119
|
+
raise ValueError("category.delete needs orphans='delete' or orphans='reassign'")
|
|
120
|
+
|
|
121
|
+
if orphans == "reassign":
|
|
122
|
+
# Named explicitly, because there is no longer a catch-all to default
|
|
123
|
+
# to. A client that wants the shapes kept has to say where they go.
|
|
124
|
+
target_id = op.get("reassign_to")
|
|
125
|
+
if not target_id:
|
|
126
|
+
raise ValueError("category.delete with orphans='reassign' needs reassign_to")
|
|
127
|
+
target = _category(state, target_id)
|
|
128
|
+
if target["id"] == category["id"]:
|
|
129
|
+
raise ValueError("cannot reassign a category's ROIs to itself")
|
|
130
|
+
|
|
131
|
+
for entry in state["images"].values():
|
|
132
|
+
if orphans == "delete":
|
|
133
|
+
entry["features"] = [f for f in entry["features"]
|
|
134
|
+
if f["category_id"] != category["id"]]
|
|
135
|
+
else:
|
|
136
|
+
for feature in entry["features"]:
|
|
137
|
+
if feature["category_id"] == category["id"]:
|
|
138
|
+
feature["category_id"] = target["id"]
|
|
139
|
+
feature["updated_at"] = now()
|
|
140
|
+
|
|
141
|
+
index, _ = schema.find(state["categories"], category["id"])
|
|
142
|
+
state["categories"].pop(index)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# -- ROIs ---------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _roi_create(state, op):
|
|
149
|
+
entry = _image(state, op)
|
|
150
|
+
raw = op.get("feature")
|
|
151
|
+
if not isinstance(raw, dict):
|
|
152
|
+
raise ValueError("roi.create needs a feature")
|
|
153
|
+
|
|
154
|
+
feature = schema.normalize_feature(raw)
|
|
155
|
+
_, existing = schema.find(entry["features"], feature["id"])
|
|
156
|
+
if existing is not None:
|
|
157
|
+
raise ValueError(f"ROI {feature['id']!r} already exists")
|
|
158
|
+
if len(entry["features"]) >= schema.MAX_FEATURES:
|
|
159
|
+
raise ValueError(f"an image may hold at most {schema.MAX_FEATURES} ROIs")
|
|
160
|
+
_category(state, feature["category_id"])
|
|
161
|
+
|
|
162
|
+
stamp = now()
|
|
163
|
+
feature["created_at"] = feature["created_at"] or stamp
|
|
164
|
+
feature["updated_at"] = stamp
|
|
165
|
+
entry["features"].append(feature)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _roi_update_geometry(state, op):
|
|
169
|
+
entry = _image(state, op)
|
|
170
|
+
feature = _feature(entry, op.get("id"))
|
|
171
|
+
_require_unlocked(state, feature, "move or reshape")
|
|
172
|
+
|
|
173
|
+
feature["geometry"] = validate_geometry(op.get("geometry"))
|
|
174
|
+
if "flags" in op:
|
|
175
|
+
feature["flags"] = schema.normalize_flags(op.get("flags"))
|
|
176
|
+
feature["updated_at"] = now()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _roi_update_properties(state, op):
|
|
180
|
+
entry = _image(state, op)
|
|
181
|
+
feature = _feature(entry, op.get("id"))
|
|
182
|
+
changes = op.get("changes")
|
|
183
|
+
if not isinstance(changes, dict):
|
|
184
|
+
raise ValueError("roi.update_properties needs changes")
|
|
185
|
+
|
|
186
|
+
# Deliberately not gated on the lock: a locked shape can still be renamed
|
|
187
|
+
# and reclassified. The lock protects the geometry from an accidental drag,
|
|
188
|
+
# which is a different thing from freezing what the region means.
|
|
189
|
+
if "name" in changes:
|
|
190
|
+
feature["name"] = schema.clean_text(changes["name"])
|
|
191
|
+
if "notes" in changes:
|
|
192
|
+
feature["notes"] = schema.clean_text(changes["notes"], schema.MAX_NOTE_LENGTH)
|
|
193
|
+
if "category_id" in changes:
|
|
194
|
+
category = _category(state, changes["category_id"])
|
|
195
|
+
feature["category_id"] = category["id"]
|
|
196
|
+
if "locked" in changes:
|
|
197
|
+
feature["locked"] = bool(changes["locked"])
|
|
198
|
+
feature["updated_at"] = now()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _roi_delete(state, op):
|
|
202
|
+
entry = _image(state, op)
|
|
203
|
+
feature = _feature(entry, op.get("id"))
|
|
204
|
+
_require_unlocked(state, feature, "delete")
|
|
205
|
+
index, _ = schema.find(entry["features"], feature["id"])
|
|
206
|
+
entry["features"].pop(index)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _roi_bulk_delete(state, op):
|
|
210
|
+
entry = _image(state, op)
|
|
211
|
+
ids = op.get("ids")
|
|
212
|
+
if not isinstance(ids, list):
|
|
213
|
+
raise ValueError("roi.bulk_delete needs ids")
|
|
214
|
+
wanted = set()
|
|
215
|
+
for roi_id in ids:
|
|
216
|
+
feature = _feature(entry, roi_id)
|
|
217
|
+
_require_unlocked(state, feature, "delete")
|
|
218
|
+
wanted.add(feature["id"])
|
|
219
|
+
entry["features"] = [f for f in entry["features"] if f["id"] not in wanted]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _roi_bulk_create(state, op):
|
|
223
|
+
"""Many ROIs, and the categories they need, as one operation.
|
|
224
|
+
|
|
225
|
+
This is what an import is. Written as one operation rather than N so that
|
|
226
|
+
undoing it is one step: a user who imports 432 regions and changes their
|
|
227
|
+
mind wants one Ctrl+Z, not 432.
|
|
228
|
+
"""
|
|
229
|
+
for raw in op.get("categories") or []:
|
|
230
|
+
category = schema.normalize_category(raw)
|
|
231
|
+
_, existing = schema.find(state["categories"], category["id"])
|
|
232
|
+
if existing is None:
|
|
233
|
+
_require_unique_label(state, category["label"], category["id"])
|
|
234
|
+
state["categories"].append(category)
|
|
235
|
+
|
|
236
|
+
for raw in op.get("features") or []:
|
|
237
|
+
_roi_create(state, {**op, "feature": raw})
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# -- lookups and rules --------------------------------------------------
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _image(state, op):
|
|
244
|
+
key = op.get("image") or schema.DEFAULT_IMAGE
|
|
245
|
+
return schema.image_entry(state, key)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _category(state, category_id):
|
|
249
|
+
_, category = schema.find(state["categories"], category_id)
|
|
250
|
+
if category is None:
|
|
251
|
+
raise ValueError(f"unknown category {category_id!r}")
|
|
252
|
+
return category
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _feature(entry, roi_id):
|
|
256
|
+
_, feature = schema.find(entry["features"], roi_id)
|
|
257
|
+
if feature is None:
|
|
258
|
+
raise ValueError(f"unknown ROI {roi_id!r}")
|
|
259
|
+
return feature
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _require_unlocked(state, feature, action):
|
|
263
|
+
if feature["locked"]:
|
|
264
|
+
raise ValueError(f"ROI {feature['id']!r} is locked and cannot be {action}d")
|
|
265
|
+
_, category = schema.find(state["categories"], feature["category_id"])
|
|
266
|
+
if category is not None and category["locked"]:
|
|
267
|
+
raise ValueError(
|
|
268
|
+
f"category {category['label']!r} is locked, so its ROIs cannot be {action}d"
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _require_unique_label(state, label, own_id):
|
|
273
|
+
"""Two categories with the same name are two categories the user cannot
|
|
274
|
+
tell apart -- in the list, in the dropdown, and in every export."""
|
|
275
|
+
folded = label.casefold()
|
|
276
|
+
for category in state["categories"]:
|
|
277
|
+
if category["id"] != own_id and category["label"].casefold() == folded:
|
|
278
|
+
raise ValueError(f"a category named {label!r} already exists")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
_HANDLERS = {
|
|
282
|
+
"category.create": _category_create,
|
|
283
|
+
"category.update": _category_update,
|
|
284
|
+
"category.delete": _category_delete,
|
|
285
|
+
"roi.create": _roi_create,
|
|
286
|
+
"roi.update_geometry": _roi_update_geometry,
|
|
287
|
+
"roi.update_properties": _roi_update_properties,
|
|
288
|
+
"roi.delete": _roi_delete,
|
|
289
|
+
"roi.bulk_delete": _roi_bulk_delete,
|
|
290
|
+
"roi.bulk_create": _roi_bulk_create,
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
OPERATION_NAMES = tuple(sorted(_HANDLERS))
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Where ROI annotations live, and who is allowed to overwrite whom.
|
|
2
|
+
|
|
3
|
+
Everything goes through `plexora.api.store`, whatever the project was imported
|
|
4
|
+
from. That is the decision the rest of the plugin rests on: an image-only
|
|
5
|
+
project, a CSV one and a SpatialData one all read and write the same document,
|
|
6
|
+
so drawing behaves identically and the source format only matters when the user
|
|
7
|
+
asks to export. Storing annotations in whichever object the project happened to
|
|
8
|
+
come with would mean every edit path had to understand every format.
|
|
9
|
+
|
|
10
|
+
The store writes an opaque blob per plugin per datasource -- no partial updates,
|
|
11
|
+
no revisions of its own. So the revision lives INSIDE the document and is
|
|
12
|
+
checked here, and this class exists so that stays true: routes never touch the
|
|
13
|
+
store, and a later move to row-level persistence changes this file only.
|
|
14
|
+
|
|
15
|
+
Why a revision at all, for what is nominally a single-user desktop app: two
|
|
16
|
+
browser tabs on the same project is not exotic, and neither is a tab left open
|
|
17
|
+
from yesterday. Both hold a full copy of the annotations and both autosave.
|
|
18
|
+
Last-writer-wins there means the stale tab's next save silently reinstates its
|
|
19
|
+
whole world -- deleting every region drawn in the other one, with no error and
|
|
20
|
+
nothing to notice. The check costs an integer comparison.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import threading
|
|
27
|
+
|
|
28
|
+
from plexora import api
|
|
29
|
+
from plexora.plugins.roi.server import operations, schema
|
|
30
|
+
|
|
31
|
+
PLUGIN_NAME = "roi"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConflictError(Exception):
|
|
35
|
+
"""Somebody else wrote since the caller last read.
|
|
36
|
+
|
|
37
|
+
Carries the current revision so the client can say what happened rather
|
|
38
|
+
than just failing.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, current_revision):
|
|
42
|
+
super().__init__("ROI annotations changed in another session")
|
|
43
|
+
self.current_revision = current_revision
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ImageMismatch(Exception):
|
|
47
|
+
"""The stored annotations were drawn on a different image than the one this
|
|
48
|
+
project now holds. See `ROIRepository.status`."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, stored, current):
|
|
51
|
+
super().__init__("ROI annotations belong to a different image")
|
|
52
|
+
self.stored = stored
|
|
53
|
+
self.current = current
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
#: One lock per datasource, held across the read-modify-write in `apply`.
|
|
57
|
+
#: Plexora serves on waitress, which is multi-threaded, so two tabs saving at
|
|
58
|
+
#: the same moment really do land in two threads -- and without this they can
|
|
59
|
+
#: both read revision 41, both write revision 42, and one of them vanishes
|
|
60
|
+
#: without ever being told it conflicted. The revision check is only as good as
|
|
61
|
+
#: the atomicity of the section it guards.
|
|
62
|
+
_LOCKS: dict[str, threading.Lock] = {}
|
|
63
|
+
_LOCKS_GUARD = threading.Lock()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _lock_for(datasource):
|
|
67
|
+
with _LOCKS_GUARD:
|
|
68
|
+
lock = _LOCKS.get(datasource)
|
|
69
|
+
if lock is None:
|
|
70
|
+
lock = _LOCKS[datasource] = threading.Lock()
|
|
71
|
+
return lock
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ROIRepository:
|
|
75
|
+
"""One project's annotations."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, datasource):
|
|
78
|
+
self.datasource = datasource
|
|
79
|
+
self._store = api.store(datasource, PLUGIN_NAME)
|
|
80
|
+
|
|
81
|
+
# -- reading --------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def image_size(self):
|
|
84
|
+
"""(width, height) of the image these annotations are drawn on.
|
|
85
|
+
|
|
86
|
+
From the project record via the public dataset handle -- the same
|
|
87
|
+
numbers the viewer lays its tiles out with.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
return api.dataset(self.datasource).image.size
|
|
91
|
+
except KeyError:
|
|
92
|
+
return (None, None)
|
|
93
|
+
|
|
94
|
+
def load(self):
|
|
95
|
+
"""The stored document, or the default one for a project with no ROIs.
|
|
96
|
+
|
|
97
|
+
The default is not persisted: opening the panel on a project nobody has
|
|
98
|
+
annotated should leave the project exactly as it found it.
|
|
99
|
+
"""
|
|
100
|
+
width, height = self.image_size()
|
|
101
|
+
blob = self._store.get_state()
|
|
102
|
+
if not blob:
|
|
103
|
+
return schema.default_state(width, height)
|
|
104
|
+
try:
|
|
105
|
+
raw = json.loads(blob.decode("utf-8"))
|
|
106
|
+
except (UnicodeDecodeError, ValueError) as exc:
|
|
107
|
+
# Deliberately loud. The alternative -- quietly handing back an
|
|
108
|
+
# empty document -- presents "your annotations are gone" as "this
|
|
109
|
+
# project has no annotations", and the next autosave makes it true.
|
|
110
|
+
raise ValueError(f"stored ROI annotations could not be read: {exc}") from exc
|
|
111
|
+
return schema.normalize_state(raw, width, height)
|
|
112
|
+
|
|
113
|
+
def status(self, state=None):
|
|
114
|
+
"""What the client needs before it draws anything.
|
|
115
|
+
|
|
116
|
+
`dimension_mismatch` is the guard against a datasource whose image has
|
|
117
|
+
been swapped underneath it. Regions from the old slide will render
|
|
118
|
+
perfectly happily on the new one, in the wrong places, and look right --
|
|
119
|
+
which is why this is reported rather than shrugged off, and why the
|
|
120
|
+
client draws nothing until it is resolved.
|
|
121
|
+
"""
|
|
122
|
+
state = self.load() if state is None else state
|
|
123
|
+
width, height = self.image_size()
|
|
124
|
+
stored = (state.get("images", {}).get(schema.DEFAULT_IMAGE, {})
|
|
125
|
+
.get("coordinate_space") or {})
|
|
126
|
+
stored_size = (stored.get("width"), stored.get("height"))
|
|
127
|
+
has_features = any(entry.get("features")
|
|
128
|
+
for entry in state.get("images", {}).values())
|
|
129
|
+
mismatch = bool(
|
|
130
|
+
has_features
|
|
131
|
+
and all(stored_size)
|
|
132
|
+
and all((width, height))
|
|
133
|
+
and stored_size != (width, height)
|
|
134
|
+
)
|
|
135
|
+
return {
|
|
136
|
+
"image_size": [width, height],
|
|
137
|
+
"stored_image_size": list(stored_size),
|
|
138
|
+
"dimension_mismatch": mismatch,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
def destination(self, state=None):
|
|
142
|
+
"""Where this project last exported to, or "" if it never has.
|
|
143
|
+
|
|
144
|
+
Not folded into `status()`: that runs on every apply, and this is only
|
|
145
|
+
ever wanted by the one route that also has to list what names are
|
|
146
|
+
already taken in the user's file.
|
|
147
|
+
"""
|
|
148
|
+
state = self.load() if state is None else state
|
|
149
|
+
return state.get("settings", {}).get("destination", "")
|
|
150
|
+
|
|
151
|
+
# -- writing --------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def apply(self, base_revision, ops):
|
|
154
|
+
"""Apply operations if the caller was up to date, and bump the revision.
|
|
155
|
+
|
|
156
|
+
Returns the new revision. Raises ConflictError when somebody else wrote
|
|
157
|
+
first, ValueError when an operation is invalid (nothing is stored in
|
|
158
|
+
either case).
|
|
159
|
+
"""
|
|
160
|
+
if not isinstance(base_revision, int) or isinstance(base_revision, bool):
|
|
161
|
+
raise ValueError("base_revision must be an integer")
|
|
162
|
+
|
|
163
|
+
with _lock_for(self.datasource):
|
|
164
|
+
state = self.load()
|
|
165
|
+
if state["revision"] != base_revision:
|
|
166
|
+
raise ConflictError(state["revision"])
|
|
167
|
+
self._require_matching_image(state)
|
|
168
|
+
|
|
169
|
+
updated = operations.apply_operations(state, ops)
|
|
170
|
+
updated["revision"] = state["revision"] + 1
|
|
171
|
+
self._write(updated)
|
|
172
|
+
return updated["revision"]
|
|
173
|
+
|
|
174
|
+
def replace(self, base_revision, state):
|
|
175
|
+
"""Store a whole document, for the paths that build one rather than edit
|
|
176
|
+
one (import). Same revision check, same lock."""
|
|
177
|
+
with _lock_for(self.datasource):
|
|
178
|
+
current = self.load()
|
|
179
|
+
if current["revision"] != base_revision:
|
|
180
|
+
raise ConflictError(current["revision"])
|
|
181
|
+
state = dict(state)
|
|
182
|
+
state["revision"] = current["revision"] + 1
|
|
183
|
+
self._write(state)
|
|
184
|
+
return state["revision"]
|
|
185
|
+
|
|
186
|
+
def remember_destination(self, name):
|
|
187
|
+
"""Record where this project's annotations were last exported to.
|
|
188
|
+
|
|
189
|
+
Deliberately does NOT bump the revision. The revision answers "have the
|
|
190
|
+
annotations changed under me", and every open tab acts on the answer --
|
|
191
|
+
bumping it here would greet a colleague's second tab with a conflict
|
|
192
|
+
banner because somebody chose a filename. Nothing about the shapes has
|
|
193
|
+
changed, so no client is stale.
|
|
194
|
+
|
|
195
|
+
Still inside the lock: it is a read-modify-write of the same blob an
|
|
196
|
+
`apply` writes, and the two interleaving would lose one of them.
|
|
197
|
+
|
|
198
|
+
Only reachable after a successful native export, which needs regions,
|
|
199
|
+
which needs a stored document -- so this never conjures a blob for a
|
|
200
|
+
project nobody has annotated.
|
|
201
|
+
"""
|
|
202
|
+
with _lock_for(self.datasource):
|
|
203
|
+
state = self.load()
|
|
204
|
+
state.setdefault("settings", schema.default_settings())
|
|
205
|
+
state["settings"]["destination"] = schema.clean_text(name)
|
|
206
|
+
self._write(state)
|
|
207
|
+
return state["settings"]["destination"]
|
|
208
|
+
|
|
209
|
+
def _write(self, state):
|
|
210
|
+
self._store.put_state(json.dumps(state, separators=(",", ":")).encode("utf-8"))
|
|
211
|
+
|
|
212
|
+
def _require_matching_image(self, state):
|
|
213
|
+
report = self.status(state)
|
|
214
|
+
if report["dimension_mismatch"]:
|
|
215
|
+
raise ImageMismatch(report["stored_image_size"], report["image_size"])
|