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,167 @@
|
|
|
1
|
+
"""Cell Explorer's HTTP surface.
|
|
2
|
+
|
|
3
|
+
Thin on purpose: parse, hand to the right module, translate the outcome. What a
|
|
4
|
+
variable is lives in `variables`, how values are packed lives in `values`, and
|
|
5
|
+
who may overwrite whom lives in `state`, so there is one place to read each and
|
|
6
|
+
no route can quietly disagree with another.
|
|
7
|
+
|
|
8
|
+
Status codes carry meaning, because the client acts on them differently:
|
|
9
|
+
|
|
10
|
+
400 the request is wrong -- an unknown datasource, a column this project
|
|
11
|
+
does not have. Retrying it unchanged will fail the same way.
|
|
12
|
+
409 the request was fine but somebody else saved first. The client has
|
|
13
|
+
preferences worth keeping, so this opens a prompt rather than
|
|
14
|
+
discarding either side.
|
|
15
|
+
422 the stored settings are from a newer Plexora. Nothing is written and
|
|
16
|
+
the panel says so, rather than overwriting what it cannot read.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from flask import Blueprint, Response, jsonify, request
|
|
20
|
+
|
|
21
|
+
from plexora import api
|
|
22
|
+
from plexora.plugins.cell_explorer.server import values as value_encoder
|
|
23
|
+
from plexora.plugins.cell_explorer.server import variables
|
|
24
|
+
from plexora.plugins.cell_explorer.server.state import (
|
|
25
|
+
CellExplorerRepository,
|
|
26
|
+
ConflictError,
|
|
27
|
+
UnreadableState,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# template_folder/static_folder make this plugin self-contained: Flask's
|
|
31
|
+
# DispatchingJinjaLoader already searches every blueprint's template folder, and
|
|
32
|
+
# the blueprint serves its own assets.
|
|
33
|
+
cell_explorer_bp = Blueprint(
|
|
34
|
+
'cell_explorer', __name__,
|
|
35
|
+
template_folder='../templates',
|
|
36
|
+
static_folder='../static',
|
|
37
|
+
static_url_path='/static',
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _dataset(datasource):
|
|
42
|
+
"""A dataset for a datasource that exists, or a KeyError."""
|
|
43
|
+
if not datasource:
|
|
44
|
+
raise KeyError(datasource)
|
|
45
|
+
return api.dataset(datasource)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@cell_explorer_bp.route('/api/variables', methods=['GET'])
|
|
49
|
+
def get_variables():
|
|
50
|
+
"""Every column that can be coloured by, described well enough to render
|
|
51
|
+
the panel without asking for any values."""
|
|
52
|
+
try:
|
|
53
|
+
dataset = _dataset(request.args.get('datasource'))
|
|
54
|
+
except KeyError:
|
|
55
|
+
return jsonify(success=False, error="Unknown datasource"), 400
|
|
56
|
+
|
|
57
|
+
if not dataset.table.available:
|
|
58
|
+
# Not an error: a project can legitimately be an image and nothing else,
|
|
59
|
+
# and the panel has an empty state that says what to do about it.
|
|
60
|
+
return api.json_response({"success": True, "variables": [],
|
|
61
|
+
"reason": "no_table"})
|
|
62
|
+
|
|
63
|
+
return api.json_response({
|
|
64
|
+
"success": True,
|
|
65
|
+
"variables": variables.describe_all(dataset),
|
|
66
|
+
# What core can draw cells with, so the panel can say "there is nothing
|
|
67
|
+
# to draw these on" before it fetches a single value.
|
|
68
|
+
"can_draw": {
|
|
69
|
+
"segmentation": dataset.segmentation.available,
|
|
70
|
+
"segmentation_pending": dataset.segmentation.pending,
|
|
71
|
+
"centroids": bool(dataset.schema and dataset.schema.x and dataset.schema.y),
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@cell_explorer_bp.route('/api/values', methods=['GET'])
|
|
77
|
+
def get_values():
|
|
78
|
+
"""One column's values as a packed binary buffer.
|
|
79
|
+
|
|
80
|
+
Not JSON: a million cells is ordinary here, and the difference is ~30 MB of
|
|
81
|
+
objects to parse against ~6 MB of typed array to read. See values.py for the
|
|
82
|
+
layouts, which the client mirrors in cellExplorerApi.js.
|
|
83
|
+
"""
|
|
84
|
+
column = request.args.get('column') or ''
|
|
85
|
+
try:
|
|
86
|
+
dataset = _dataset(request.args.get('datasource'))
|
|
87
|
+
except KeyError:
|
|
88
|
+
return jsonify(success=False, error="Unknown datasource"), 400
|
|
89
|
+
|
|
90
|
+
descriptor = variables.find(dataset, column) if column else None
|
|
91
|
+
if descriptor is None:
|
|
92
|
+
return jsonify(success=False,
|
|
93
|
+
error=f"No such metadata column: {column!r}"), 400
|
|
94
|
+
|
|
95
|
+
kind = request.args.get('kind')
|
|
96
|
+
if kind in ("categorical", "continuous") and kind != descriptor["kind"]:
|
|
97
|
+
# The user overrode the inference. Honoured only where the descriptor
|
|
98
|
+
# carries both halves (see variables.describe), so an override cannot
|
|
99
|
+
# ask for an encoding whose dictionary was never computed.
|
|
100
|
+
if not descriptor.get("ambiguous"):
|
|
101
|
+
return jsonify(success=False,
|
|
102
|
+
error=f"{column!r} cannot be read as {kind}"), 400
|
|
103
|
+
descriptor = {**descriptor, "kind": kind}
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
payload, resolved_kind, count = value_encoder.encode(dataset, column, descriptor)
|
|
107
|
+
except (KeyError, ValueError) as exc:
|
|
108
|
+
return jsonify(success=False, error=str(exc)), 400
|
|
109
|
+
|
|
110
|
+
response = Response(payload, mimetype="application/octet-stream")
|
|
111
|
+
response.headers["Content-Encoding"] = "gzip"
|
|
112
|
+
response.headers["X-Cell-Count"] = str(count)
|
|
113
|
+
response.headers["X-Value-Kind"] = resolved_kind
|
|
114
|
+
# So a proxy or a browser cache cannot serve one column's buffer for
|
|
115
|
+
# another's -- the URL differs, but the headers above are what the client
|
|
116
|
+
# reads to decide how to decode it.
|
|
117
|
+
response.headers["Cache-Control"] = "no-store"
|
|
118
|
+
return response
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@cell_explorer_bp.route('/api/state', methods=['GET'])
|
|
122
|
+
def get_state():
|
|
123
|
+
"""The stored display preferences, plus the revision to write against."""
|
|
124
|
+
datasource = request.args.get('datasource')
|
|
125
|
+
try:
|
|
126
|
+
_dataset(datasource)
|
|
127
|
+
except KeyError:
|
|
128
|
+
return jsonify(success=False, error="Unknown datasource"), 400
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
state = CellExplorerRepository(datasource).load()
|
|
132
|
+
except UnreadableState as exc:
|
|
133
|
+
return jsonify(success=False, error=str(exc),
|
|
134
|
+
schema_version=exc.schema_version), 422
|
|
135
|
+
except ValueError as exc:
|
|
136
|
+
return jsonify(success=False, error=str(exc)), 400
|
|
137
|
+
|
|
138
|
+
return api.json_response({"success": True, **state})
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@cell_explorer_bp.route('/api/state', methods=['POST'])
|
|
142
|
+
def post_state():
|
|
143
|
+
"""Store display preferences, if nobody else has written since the caller
|
|
144
|
+
last read."""
|
|
145
|
+
body = request.get_json(silent=True)
|
|
146
|
+
if not isinstance(body, dict):
|
|
147
|
+
return jsonify(success=False, error="expected a JSON object"), 400
|
|
148
|
+
|
|
149
|
+
datasource = body.get('datasource') or request.args.get('datasource')
|
|
150
|
+
try:
|
|
151
|
+
_dataset(datasource)
|
|
152
|
+
except KeyError:
|
|
153
|
+
return jsonify(success=False, error="Unknown datasource"), 400
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
state = CellExplorerRepository(datasource).save(
|
|
157
|
+
body.get('revision'), body.get('settings'))
|
|
158
|
+
except ConflictError as exc:
|
|
159
|
+
return jsonify(success=False, error="conflict",
|
|
160
|
+
revision=exc.current_revision), 409
|
|
161
|
+
except UnreadableState as exc:
|
|
162
|
+
return jsonify(success=False, error=str(exc),
|
|
163
|
+
schema_version=exc.schema_version), 422
|
|
164
|
+
except ValueError as exc:
|
|
165
|
+
return jsonify(success=False, error=str(exc)), 400
|
|
166
|
+
|
|
167
|
+
return api.json_response({"success": True, **state})
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""What Cell Explorer remembers between sessions, and who may overwrite whom.
|
|
2
|
+
|
|
3
|
+
Only display preferences: which column was showing, what colour each category
|
|
4
|
+
got, which were hidden, the palette and range for a numeric one, the opacity.
|
|
5
|
+
Never values. The table is the source of truth, and caching a copy of it here
|
|
6
|
+
would mean a project whose data changed underneath still painted the old
|
|
7
|
+
picture -- silently, and looking entirely plausible.
|
|
8
|
+
|
|
9
|
+
The store writes one opaque blob per plugin per datasource: no partial updates,
|
|
10
|
+
no revisions of its own. So the revision lives INSIDE the document and is
|
|
11
|
+
checked here, which is also why routes never touch the store directly.
|
|
12
|
+
|
|
13
|
+
Why a revision at all for a single-user desktop app: two tabs on the same
|
|
14
|
+
project is not exotic, and neither is a tab left open from yesterday. Both hold
|
|
15
|
+
a full copy of these preferences and both autosave. Last-writer-wins means the
|
|
16
|
+
stale tab quietly reinstates its whole world -- every recoloured category
|
|
17
|
+
reverted, with no error and nothing to notice. The check costs an integer
|
|
18
|
+
comparison.
|
|
19
|
+
|
|
20
|
+
Unknown keys are preserved rather than dropped (see `normalize`). A newer
|
|
21
|
+
Plexora writing a field this one does not model must not have it stripped by the
|
|
22
|
+
first save from an older tab.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import threading
|
|
29
|
+
|
|
30
|
+
from plexora import api
|
|
31
|
+
|
|
32
|
+
PLUGIN_NAME = "cell_explorer"
|
|
33
|
+
|
|
34
|
+
#: Bumped only for a change this version cannot read. A field that is merely
|
|
35
|
+
#: new does not need it -- `normalize` fills defaults and keeps what it does not
|
|
36
|
+
#: recognise, so additive changes are already safe both ways.
|
|
37
|
+
SCHEMA_VERSION = 1
|
|
38
|
+
|
|
39
|
+
#: Matches ImageViewer.DEFAULT_CELL_LAYER_OPACITY. Filled masks hide the tissue
|
|
40
|
+
#: at full strength and outlines read poorly when faint, and one control serves
|
|
41
|
+
#: both.
|
|
42
|
+
DEFAULT_OPACITY = 0.7
|
|
43
|
+
|
|
44
|
+
DEFAULT_PALETTE = "viridis"
|
|
45
|
+
PALETTES = ("viridis", "magma", "cividis", "coolwarm", "custom")
|
|
46
|
+
MODES = ("none", "centroids", "outlines", "filled")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ConflictError(Exception):
|
|
50
|
+
"""Somebody else wrote since the caller last read.
|
|
51
|
+
|
|
52
|
+
Carries the current revision so the client can say what happened rather
|
|
53
|
+
than just failing.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, current_revision):
|
|
57
|
+
super().__init__("Cell Explorer settings changed in another session")
|
|
58
|
+
self.current_revision = current_revision
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class UnreadableState(Exception):
|
|
62
|
+
"""The stored document is from a version this one cannot read.
|
|
63
|
+
|
|
64
|
+
Deliberately not "start fresh". Overwriting a newer tab's settings because
|
|
65
|
+
this build does not understand them destroys work to avoid showing a
|
|
66
|
+
banner.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, schema_version):
|
|
70
|
+
super().__init__(
|
|
71
|
+
f"stored settings use schema version {schema_version}, "
|
|
72
|
+
f"which this version of Plexora cannot read"
|
|
73
|
+
)
|
|
74
|
+
self.schema_version = schema_version
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
#: One lock per datasource, held across the read-modify-write in `save`.
|
|
78
|
+
#: Plexora serves on waitress, which is multi-threaded, so two tabs saving at
|
|
79
|
+
#: the same moment really do land in two threads -- and without this they can
|
|
80
|
+
#: both read revision 41, both write revision 42, and one of them vanishes
|
|
81
|
+
#: without ever being told it conflicted.
|
|
82
|
+
_LOCKS: dict[str, threading.Lock] = {}
|
|
83
|
+
_LOCKS_GUARD = threading.Lock()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _lock_for(datasource):
|
|
87
|
+
with _LOCKS_GUARD:
|
|
88
|
+
lock = _LOCKS.get(datasource)
|
|
89
|
+
if lock is None:
|
|
90
|
+
lock = _LOCKS[datasource] = threading.Lock()
|
|
91
|
+
return lock
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def default_state() -> dict:
|
|
95
|
+
return {
|
|
96
|
+
"schema_version": SCHEMA_VERSION,
|
|
97
|
+
"revision": 0,
|
|
98
|
+
# Which column is showing. None means "decide for me" -- the panel then
|
|
99
|
+
# picks the best candidate rather than opening on nothing.
|
|
100
|
+
"selected": None,
|
|
101
|
+
"display": {"mode": None, "opacity": DEFAULT_OPACITY},
|
|
102
|
+
# column -> "categorical" | "continuous", where the user disagreed with
|
|
103
|
+
# the inference. Per column, because the answer is about that column.
|
|
104
|
+
"overrides": {},
|
|
105
|
+
# column -> {"colors": {category: "#rrggbb"}, "hidden": [category, ...]}
|
|
106
|
+
"categorical": {},
|
|
107
|
+
# column -> {"palette", "custom": {"low", "high"}, "range": {...},
|
|
108
|
+
# "hidden": bool}
|
|
109
|
+
"continuous": {},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class CellExplorerRepository:
|
|
114
|
+
"""One project's display preferences."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, datasource):
|
|
117
|
+
self.datasource = datasource
|
|
118
|
+
self._store = api.store(datasource, PLUGIN_NAME)
|
|
119
|
+
|
|
120
|
+
def load(self) -> dict:
|
|
121
|
+
"""The stored document, or the default for a project nobody has opened.
|
|
122
|
+
|
|
123
|
+
The default is not persisted: opening the panel on a fresh project
|
|
124
|
+
should leave the project exactly as it found it.
|
|
125
|
+
"""
|
|
126
|
+
blob = self._store.get_state()
|
|
127
|
+
if not blob:
|
|
128
|
+
return default_state()
|
|
129
|
+
try:
|
|
130
|
+
raw = json.loads(blob.decode("utf-8"))
|
|
131
|
+
except (UnicodeDecodeError, ValueError) as exc:
|
|
132
|
+
# Deliberately loud. Quietly handing back an empty document presents
|
|
133
|
+
# "your settings are gone" as "this project has no settings", and
|
|
134
|
+
# the next autosave makes it true.
|
|
135
|
+
raise ValueError(f"stored Cell Explorer settings could not be read: {exc}") from exc
|
|
136
|
+
return normalize(raw)
|
|
137
|
+
|
|
138
|
+
def save(self, base_revision, settings) -> dict:
|
|
139
|
+
"""Store the document if the caller was up to date, and bump the revision.
|
|
140
|
+
|
|
141
|
+
Returns the stored document. Raises ConflictError when somebody else
|
|
142
|
+
wrote first (nothing is stored), ValueError when the payload is not a
|
|
143
|
+
document.
|
|
144
|
+
"""
|
|
145
|
+
if not isinstance(base_revision, int) or isinstance(base_revision, bool):
|
|
146
|
+
raise ValueError("base_revision must be an integer")
|
|
147
|
+
if not isinstance(settings, dict):
|
|
148
|
+
raise ValueError("settings must be an object")
|
|
149
|
+
|
|
150
|
+
with _lock_for(self.datasource):
|
|
151
|
+
current = self.load()
|
|
152
|
+
if current["revision"] != base_revision:
|
|
153
|
+
raise ConflictError(current["revision"])
|
|
154
|
+
updated = normalize(settings)
|
|
155
|
+
updated["revision"] = current["revision"] + 1
|
|
156
|
+
self._store.put_state(
|
|
157
|
+
json.dumps(updated, separators=(",", ":")).encode("utf-8"))
|
|
158
|
+
return updated
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def normalize(raw) -> dict:
|
|
162
|
+
"""A stored document, cleaned into the shape the panel expects.
|
|
163
|
+
|
|
164
|
+
Everything is bounds-checked rather than trusted: this blob has been on disk
|
|
165
|
+
across upgrades, and a palette name or an opacity that no longer means
|
|
166
|
+
anything should fall back to a default rather than reach the renderer.
|
|
167
|
+
|
|
168
|
+
Per-column entries are NOT filtered against the current table. A column that
|
|
169
|
+
is temporarily absent -- a data file swapped, a different image loaded --
|
|
170
|
+
comes back, and dropping its colours the one time it was missing loses work
|
|
171
|
+
a user did deliberately. The panel ignores entries it cannot match.
|
|
172
|
+
"""
|
|
173
|
+
if not isinstance(raw, dict):
|
|
174
|
+
raise ValueError("stored settings are not an object")
|
|
175
|
+
|
|
176
|
+
version = raw.get("schema_version", SCHEMA_VERSION)
|
|
177
|
+
if not isinstance(version, int) or version > SCHEMA_VERSION:
|
|
178
|
+
raise UnreadableState(version)
|
|
179
|
+
|
|
180
|
+
state = dict(raw)
|
|
181
|
+
state["schema_version"] = SCHEMA_VERSION
|
|
182
|
+
state["revision"] = _int(raw.get("revision"), 0)
|
|
183
|
+
state["selected"] = _text_or_none(raw.get("selected"))
|
|
184
|
+
state["display"] = _display(raw.get("display"))
|
|
185
|
+
state["overrides"] = {
|
|
186
|
+
str(column): kind
|
|
187
|
+
for column, kind in _mapping(raw.get("overrides")).items()
|
|
188
|
+
if kind in ("categorical", "continuous")
|
|
189
|
+
}
|
|
190
|
+
state["categorical"] = {
|
|
191
|
+
str(column): _categorical_entry(entry)
|
|
192
|
+
for column, entry in _mapping(raw.get("categorical")).items()
|
|
193
|
+
}
|
|
194
|
+
state["continuous"] = {
|
|
195
|
+
str(column): _continuous_entry(entry)
|
|
196
|
+
for column, entry in _mapping(raw.get("continuous")).items()
|
|
197
|
+
}
|
|
198
|
+
return state
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _display(raw) -> dict:
|
|
202
|
+
raw = _mapping(raw)
|
|
203
|
+
mode = raw.get("mode")
|
|
204
|
+
return {
|
|
205
|
+
"mode": mode if mode in MODES else None,
|
|
206
|
+
"opacity": _clamped(raw.get("opacity"), DEFAULT_OPACITY),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _categorical_entry(raw) -> dict:
|
|
211
|
+
raw = _mapping(raw)
|
|
212
|
+
colors = {
|
|
213
|
+
str(category): color
|
|
214
|
+
for category, color in _mapping(raw.get("colors")).items()
|
|
215
|
+
if _is_hex(color)
|
|
216
|
+
}
|
|
217
|
+
hidden = sorted({str(category) for category in raw.get("hidden") or ()
|
|
218
|
+
if isinstance(category, (str, int, float, bool))})
|
|
219
|
+
return {"colors": colors, "hidden": hidden}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _continuous_entry(raw) -> dict:
|
|
223
|
+
raw = _mapping(raw)
|
|
224
|
+
palette = raw.get("palette")
|
|
225
|
+
custom = _mapping(raw.get("custom"))
|
|
226
|
+
range_raw = _mapping(raw.get("range"))
|
|
227
|
+
low = _number_or_none(range_raw.get("min"))
|
|
228
|
+
high = _number_or_none(range_raw.get("max"))
|
|
229
|
+
manual = (range_raw.get("mode") == "manual"
|
|
230
|
+
and low is not None and high is not None and low < high)
|
|
231
|
+
return {
|
|
232
|
+
"palette": palette if palette in PALETTES else DEFAULT_PALETTE,
|
|
233
|
+
"custom": {
|
|
234
|
+
"low": custom.get("low") if _is_hex(custom.get("low")) else None,
|
|
235
|
+
"high": custom.get("high") if _is_hex(custom.get("high")) else None,
|
|
236
|
+
},
|
|
237
|
+
# Whether the overlay is drawn at all. A boolean here and a list of
|
|
238
|
+
# labels on a categorical entry: a ramp has no rows to hide one of, so
|
|
239
|
+
# the whole column is the unit. Listed explicitly because these entries
|
|
240
|
+
# are rebuilt key by key rather than passed through -- an unmodelled
|
|
241
|
+
# field on one of them is dropped by the first save.
|
|
242
|
+
"hidden": bool(raw.get("hidden")),
|
|
243
|
+
# A manual range that no longer makes sense (min >= max after the source
|
|
244
|
+
# changed) reverts to auto rather than being kept and refusing to draw.
|
|
245
|
+
"range": ({"mode": "manual", "min": low, "max": high} if manual
|
|
246
|
+
else {"mode": "auto"}),
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _mapping(value) -> dict:
|
|
251
|
+
return value if isinstance(value, dict) else {}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _int(value, fallback) -> int:
|
|
255
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else fallback
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _text_or_none(value):
|
|
259
|
+
return value if isinstance(value, str) and value else None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _number_or_none(value):
|
|
263
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
264
|
+
return None
|
|
265
|
+
return float(value) if value == value and abs(value) != float("inf") else None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _clamped(value, fallback) -> float:
|
|
269
|
+
number = _number_or_none(value)
|
|
270
|
+
if number is None:
|
|
271
|
+
return fallback
|
|
272
|
+
return max(0.0, min(1.0, number))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _is_hex(value) -> bool:
|
|
276
|
+
"""#rrggbb only. Anything else reaches a canvas fillStyle, where an
|
|
277
|
+
unrecognised string is silently ignored and the cell keeps whatever colour
|
|
278
|
+
was set last -- a wrong picture rather than a missing one."""
|
|
279
|
+
if not isinstance(value, str) or len(value) != 7 or not value.startswith("#"):
|
|
280
|
+
return False
|
|
281
|
+
return all(character in "0123456789abcdefABCDEF" for character in value[1:])
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""One column's values, on the wire.
|
|
2
|
+
|
|
3
|
+
A million cells is an ordinary image here, and the browser needs a value per
|
|
4
|
+
cell to build a colour lookup table. As JSON objects that is roughly 30 MB of
|
|
5
|
+
`{"cell_id": 1, "value": 0.52}` to serialize, send and parse; as a packed
|
|
6
|
+
structured array it is 6 MB, gzipped to less, and arrives as typed arrays the
|
|
7
|
+
LUT builder can read directly with no per-row work at all.
|
|
8
|
+
|
|
9
|
+
Two layouts, one per kind:
|
|
10
|
+
|
|
11
|
+
categorical id: uint32, code: uint16 -- an index into the descriptor's
|
|
12
|
+
category list
|
|
13
|
+
continuous id: uint32, value: float32
|
|
14
|
+
|
|
15
|
+
Categorical is dictionary-encoded rather than sending repeated strings, which is
|
|
16
|
+
where most of the saving is: "Macrophage" is eleven bytes per cell and two as a
|
|
17
|
+
code. The dictionary is the descriptor's `categories`, already fetched, so
|
|
18
|
+
nothing is sent twice.
|
|
19
|
+
|
|
20
|
+
float32 rather than float64 because this drives a colour ramp with 256 stops.
|
|
21
|
+
The stored data is untouched -- this is a display copy.
|
|
22
|
+
|
|
23
|
+
**The ids are the contract.** They are the same values the segmentation mask's
|
|
24
|
+
labels carry and the same ones the centroid cache packs, derived by the same
|
|
25
|
+
rule (`centroid_tiles.build_cache`): the cell-id column, cast to float, rows
|
|
26
|
+
with a non-finite id dropped, the rest to uint32. A different rule here would
|
|
27
|
+
produce a payload that is individually correct and lines up with nothing.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import gzip
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
import polars as pl
|
|
36
|
+
|
|
37
|
+
from plexora.plugins.cell_explorer.server import variables
|
|
38
|
+
|
|
39
|
+
#: Code for "this cell has no value". uint16 max, so it can never collide with a
|
|
40
|
+
#: real category index -- a column with 65535 categories is refused long before
|
|
41
|
+
#: this by the identifier check.
|
|
42
|
+
MISSING_CODE = 0xFFFF
|
|
43
|
+
|
|
44
|
+
CATEGORICAL_DTYPE = np.dtype([("id", "<u4"), ("code", "<u2")])
|
|
45
|
+
CONTINUOUS_DTYPE = np.dtype([("id", "<u4"), ("value", "<f4")])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cell_ids(dataset):
|
|
49
|
+
"""(ids, keep) for the loaded table.
|
|
50
|
+
|
|
51
|
+
`keep` is the row mask, returned alongside rather than applied, because
|
|
52
|
+
every caller has a values array that has to lose exactly the same rows. A
|
|
53
|
+
cell whose id will not survive the round trip to uint32 has no way to be
|
|
54
|
+
matched against a mask label, so it is dropped rather than drawn somewhere
|
|
55
|
+
arbitrary.
|
|
56
|
+
"""
|
|
57
|
+
frame = dataset.table.frame()
|
|
58
|
+
if frame is None:
|
|
59
|
+
return np.empty(0, dtype=np.uint32), np.empty(0, dtype=bool)
|
|
60
|
+
column = (dataset.schema.cell_id if dataset.schema else None) or "id"
|
|
61
|
+
if column not in frame.columns:
|
|
62
|
+
# The role names a column of the SOURCE for the structural formats; the
|
|
63
|
+
# table the adapter emits carries its own positional "id". Falling back
|
|
64
|
+
# keeps a project whose role was answered against obs working.
|
|
65
|
+
column = "id"
|
|
66
|
+
raw = (frame[column].cast(pl.Float64, strict=False)
|
|
67
|
+
.fill_null(float("nan")).to_numpy())
|
|
68
|
+
keep = np.isfinite(raw)
|
|
69
|
+
return raw[keep].astype(np.uint32, copy=False), keep
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def encode(dataset, column: str, descriptor: dict) -> tuple[bytes, str, int]:
|
|
73
|
+
"""(gzipped bytes, kind, cell count) for one column.
|
|
74
|
+
|
|
75
|
+
`descriptor` is passed in rather than looked up so the codes below are
|
|
76
|
+
indexed against the very category list the client was given. Recomputing it
|
|
77
|
+
here would work right up until the two orders diverged, at which point every
|
|
78
|
+
cell is labelled as some other category and nothing reports it.
|
|
79
|
+
"""
|
|
80
|
+
ids, keep = cell_ids(dataset)
|
|
81
|
+
values = np.asarray(dataset.table.metadata_values(column).values)
|
|
82
|
+
if values.size == keep.size:
|
|
83
|
+
values = values[keep]
|
|
84
|
+
elif values.size != ids.size:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"column {column!r} has {values.size} values but the table has "
|
|
87
|
+
f"{keep.size} rows"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
kind = descriptor.get("kind", "categorical")
|
|
91
|
+
if kind == "continuous":
|
|
92
|
+
payload = _continuous(ids, values)
|
|
93
|
+
else:
|
|
94
|
+
payload = _categorical(ids, values, descriptor.get("categories") or [])
|
|
95
|
+
return gzip.compress(payload.tobytes(), 5), kind, int(ids.size)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _continuous(ids, values):
|
|
99
|
+
numeric = _as_float(values)
|
|
100
|
+
# Infinities join NaN as "no value". They are not the top of the scale --
|
|
101
|
+
# clipping them there would paint a handful of cells the extreme colour and
|
|
102
|
+
# say nothing about why.
|
|
103
|
+
numeric = np.where(np.isfinite(numeric), numeric, np.nan)
|
|
104
|
+
record = np.empty(ids.size, dtype=CONTINUOUS_DTYPE)
|
|
105
|
+
record["id"] = ids
|
|
106
|
+
record["value"] = numeric.astype(np.float32, copy=False)
|
|
107
|
+
return record
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _as_float(values):
|
|
111
|
+
if values.dtype.kind in "iuf":
|
|
112
|
+
return values.astype(np.float64, copy=False)
|
|
113
|
+
if values.dtype.kind == "b":
|
|
114
|
+
return values.astype(np.float64)
|
|
115
|
+
# A text column the user overrode to continuous. Anything unparseable
|
|
116
|
+
# becomes NaN, which is the same "no value" every other route takes.
|
|
117
|
+
return np.array([_to_float(value) for value in values], dtype=np.float64)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _to_float(value):
|
|
121
|
+
try:
|
|
122
|
+
return float(value)
|
|
123
|
+
except (TypeError, ValueError):
|
|
124
|
+
return float("nan")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _categorical(ids, values, categories):
|
|
128
|
+
text, missing = variables.as_text(values)
|
|
129
|
+
labels = np.array([entry["value"] for entry in categories], dtype=str)
|
|
130
|
+
|
|
131
|
+
codes = np.full(ids.size, MISSING_CODE, dtype=np.uint16)
|
|
132
|
+
if labels.size and text.size:
|
|
133
|
+
# Vectorized lookup: sort the dictionary, binary-search every value into
|
|
134
|
+
# it, then map back through the sort permutation. A dict comprehension
|
|
135
|
+
# over the values would be a Python loop over millions of rows.
|
|
136
|
+
order = np.argsort(labels)
|
|
137
|
+
ordered = labels[order]
|
|
138
|
+
position = np.clip(np.searchsorted(ordered, text), 0, ordered.size - 1)
|
|
139
|
+
matched = (ordered[position] == text) & ~missing
|
|
140
|
+
codes[matched] = order[position][matched].astype(np.uint16)
|
|
141
|
+
|
|
142
|
+
record = np.empty(ids.size, dtype=CATEGORICAL_DTYPE)
|
|
143
|
+
record["id"] = ids
|
|
144
|
+
record["code"] = codes
|
|
145
|
+
return record
|