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.
Files changed (248) hide show
  1. plexora/__init__.py +129 -0
  2. plexora/__main__.py +15 -0
  3. plexora/_url.py +105 -0
  4. plexora/api/__init__.py +55 -0
  5. plexora/api/dataset.py +398 -0
  6. plexora/api/http.py +19 -0
  7. plexora/api/plugin.py +541 -0
  8. plexora/api/store.py +221 -0
  9. plexora/cli.py +777 -0
  10. plexora/client/dist/354_bundle.js +2 -0
  11. plexora/client/dist/354_bundle.js.LICENSE.txt +20 -0
  12. plexora/client/dist/418_bundle.js +2 -0
  13. plexora/client/dist/418_bundle.js.LICENSE.txt +1 -0
  14. plexora/client/dist/693b935cb1f907814be9c6199a68c217.svg +19 -0
  15. plexora/client/dist/713c134ff47cd328a5b711526f151082.svg +18 -0
  16. plexora/client/dist/770_bundle.js +1 -0
  17. plexora/client/dist/ff743f408972e0e96ca9153ed5ae4c83.svg +23 -0
  18. plexora/client/dist/vendor_bundle.js +2 -0
  19. plexora/client/dist/vendor_bundle.js.LICENSE.txt +308 -0
  20. plexora/client/external/openseadragon-bin-2.4.0/LICENSE.txt +28 -0
  21. plexora/client/external/openseadragon-bin-2.4.0/canvas-overlay-hd.js +140 -0
  22. plexora/client/external/openseadragon-bin-2.4.0/changelog.txt +501 -0
  23. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/.gitattributes +17 -0
  24. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/LICENSE.txt +116 -0
  25. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/Toolbar.png +0 -0
  26. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/fullpage.psd +0 -0
  27. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/home.psd +0 -0
  28. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/next.psd +0 -0
  29. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/previous.psd +0 -0
  30. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateleft.psd +0 -0
  31. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateright.psd +0 -0
  32. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/zoom.psd +0 -0
  33. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/README.md +9 -0
  34. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_grouphover.png +0 -0
  35. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_hover.png +0 -0
  36. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_pressed.png +0 -0
  37. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_rest.png +0 -0
  38. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_grouphover.png +0 -0
  39. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_hover.png +0 -0
  40. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_pressed.png +0 -0
  41. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_rest.png +0 -0
  42. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_grouphover.png +0 -0
  43. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_hover.png +0 -0
  44. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_pressed.png +0 -0
  45. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_rest.png +0 -0
  46. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_grouphover.png +0 -0
  47. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_hover.png +0 -0
  48. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_pressed.png +0 -0
  49. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_rest.png +0 -0
  50. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_grouphover.png +0 -0
  51. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_hover.png +0 -0
  52. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_pressed.png +0 -0
  53. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_rest.png +0 -0
  54. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_grouphover.png +0 -0
  55. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_hover.png +0 -0
  56. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_pressed.png +0 -0
  57. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_rest.png +0 -0
  58. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_grouphover.png +0 -0
  59. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_hover.png +0 -0
  60. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_pressed.png +0 -0
  61. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_rest.png +0 -0
  62. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_grouphover.png +0 -0
  63. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_hover.png +0 -0
  64. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_pressed.png +0 -0
  65. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_rest.png +0 -0
  66. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-scalebar.js +562 -0
  67. plexora/client/src/css/import.css +474 -0
  68. plexora/client/src/css/main.css +903 -0
  69. plexora/client/src/css/openProject.css +363 -0
  70. plexora/client/src/css/quickView.css +166 -0
  71. plexora/client/src/css/tokens.css +51 -0
  72. plexora/client/src/css/viewer.css +1655 -0
  73. plexora/client/src/img/apple-touch-icon.png +0 -0
  74. plexora/client/src/img/favicon.ico +0 -0
  75. plexora/client/src/img/logo.ai +8892 -33
  76. plexora/client/src/img/logo.svg +1 -0
  77. plexora/client/src/img/logo_with_text.ai +9011 -34
  78. plexora/client/src/img/logo_with_text.svg +1 -0
  79. plexora/client/src/js/main.js +704 -0
  80. plexora/client/src/js/pluginRegistry.js +108 -0
  81. plexora/client/src/js/services/appStatus.js +286 -0
  82. plexora/client/src/js/services/browsePicker.js +73 -0
  83. plexora/client/src/js/services/dataLayer.js +400 -0
  84. plexora/client/src/js/services/datasetContext.js +143 -0
  85. plexora/client/src/js/services/glRenderer.js +143 -0
  86. plexora/client/src/js/services/importFormValidation.js +257 -0
  87. plexora/client/src/js/services/numericData.js +100 -0
  88. plexora/client/src/js/services/passVariablesToFrontend.js +20 -0
  89. plexora/client/src/js/services/simpleEventHandler.js +25 -0
  90. plexora/client/src/js/vendor.js +29 -0
  91. plexora/client/src/js/views/channelList.js +663 -0
  92. plexora/client/src/js/views/colorSwatchPicker.js +202 -0
  93. plexora/client/src/js/views/columnClassifier.js +146 -0
  94. plexora/client/src/js/views/coordinateField.js +133 -0
  95. plexora/client/src/js/views/dataSourceField.js +276 -0
  96. plexora/client/src/js/views/imageViewer.js +3306 -0
  97. plexora/client/src/js/views/miniMap.js +787 -0
  98. plexora/client/src/js/views/navbarControls.js +178 -0
  99. plexora/client/src/js/views/openProjectPage.js +239 -0
  100. plexora/client/src/js/views/projectEdit.js +335 -0
  101. plexora/client/src/js/views/quickViewLanding.js +136 -0
  102. plexora/client/src/js/views/rainbow.js +326 -0
  103. plexora/client/src/js/views/requirementsModal.js +572 -0
  104. plexora/client/src/js/views/rgbImageViewer.js +115 -0
  105. plexora/client/src/js/views/searchableSelect.js +411 -0
  106. plexora/client/src/js/views/segmentationProgress.js +122 -0
  107. plexora/client/src/js/views/toolLoader.js +747 -0
  108. plexora/client/src/js/views/viewerControls.js +689 -0
  109. plexora/client/src/js/views/viewerManager.js +434 -0
  110. plexora/client/src/js/views/viewerSidebar.js +1117 -0
  111. plexora/client/src/js/workers/tileDecoder.js +130 -0
  112. plexora/client/src/shaders/frag.glsl +528 -0
  113. plexora/client/src/shaders/vert.glsl +12 -0
  114. plexora/client/templates/base.html +212 -0
  115. plexora/client/templates/index.html +238 -0
  116. plexora/client/templates/open_project.html +96 -0
  117. plexora/client/templates/project_columns.html +86 -0
  118. plexora/client/templates/project_edit.html +196 -0
  119. plexora/client/templates/upload.html +118 -0
  120. plexora/connect.py +528 -0
  121. plexora/datasource.py +782 -0
  122. plexora/jupyter.py +406 -0
  123. plexora/notebook_env.py +232 -0
  124. plexora/paths.py +486 -0
  125. plexora/plugins/__init__.py +12 -0
  126. plexora/plugins/cell_explorer/__init__.py +100 -0
  127. plexora/plugins/cell_explorer/server/__init__.py +11 -0
  128. plexora/plugins/cell_explorer/server/routes.py +167 -0
  129. plexora/plugins/cell_explorer/server/state.py +281 -0
  130. plexora/plugins/cell_explorer/server/values.py +145 -0
  131. plexora/plugins/cell_explorer/server/variables.py +367 -0
  132. plexora/plugins/cell_explorer/static/cellExplorerApi.js +158 -0
  133. plexora/plugins/cell_explorer/static/cellExplorerColors.js +316 -0
  134. plexora/plugins/cell_explorer/static/cellExplorerContinuous.js +403 -0
  135. plexora/plugins/cell_explorer/static/cellExplorerFigureBridge.js +133 -0
  136. plexora/plugins/cell_explorer/static/cellExplorerLegend.js +188 -0
  137. plexora/plugins/cell_explorer/static/cellExplorerRoiBridge.js +822 -0
  138. plexora/plugins/cell_explorer/static/cellExplorerSidebarController.js +826 -0
  139. plexora/plugins/cell_explorer/static/cellExplorerState.js +372 -0
  140. plexora/plugins/cell_explorer/static/cell_explorer.css +752 -0
  141. plexora/plugins/cell_explorer/templates/cell_explorer/panel.html +137 -0
  142. plexora/plugins/figure_builder/__init__.py +125 -0
  143. plexora/plugins/figure_builder/server/__init__.py +7 -0
  144. plexora/plugins/figure_builder/server/compose.py +365 -0
  145. plexora/plugins/figure_builder/server/export.py +784 -0
  146. plexora/plugins/figure_builder/server/export_jobs.py +148 -0
  147. plexora/plugins/figure_builder/server/operations.py +556 -0
  148. plexora/plugins/figure_builder/server/pixels.py +142 -0
  149. plexora/plugins/figure_builder/server/provenance.py +174 -0
  150. plexora/plugins/figure_builder/server/render.py +278 -0
  151. plexora/plugins/figure_builder/server/repository.py +701 -0
  152. plexora/plugins/figure_builder/server/routes.py +594 -0
  153. plexora/plugins/figure_builder/server/schema.py +1023 -0
  154. plexora/plugins/figure_builder/server/sources.py +120 -0
  155. plexora/plugins/figure_builder/server/textmetrics.py +155 -0
  156. plexora/plugins/figure_builder/static/figureActions.js +363 -0
  157. plexora/plugins/figure_builder/static/figureBuilderApi.js +255 -0
  158. plexora/plugins/figure_builder/static/figureCanvas.js +2362 -0
  159. plexora/plugins/figure_builder/static/figureCaptureBoxes.js +369 -0
  160. plexora/plugins/figure_builder/static/figureCaptureDock.js +466 -0
  161. plexora/plugins/figure_builder/static/figureCaptureTool.js +1034 -0
  162. plexora/plugins/figure_builder/static/figureConfirm.js +136 -0
  163. plexora/plugins/figure_builder/static/figureContextBar.js +1193 -0
  164. plexora/plugins/figure_builder/static/figureContextMenu.js +253 -0
  165. plexora/plugins/figure_builder/static/figureDocumentState.js +275 -0
  166. plexora/plugins/figure_builder/static/figureExportUi.js +174 -0
  167. plexora/plugins/figure_builder/static/figureLibrary.js +247 -0
  168. plexora/plugins/figure_builder/static/figureQuickEdit.js +628 -0
  169. plexora/plugins/figure_builder/static/figureRichText.js +634 -0
  170. plexora/plugins/figure_builder/static/figureSceneSnapshot.js +361 -0
  171. plexora/plugins/figure_builder/static/figureSchema.js +218 -0
  172. plexora/plugins/figure_builder/static/figureSidebarController.js +1159 -0
  173. plexora/plugins/figure_builder/static/figureTextEditor.js +503 -0
  174. plexora/plugins/figure_builder/static/figureTextPanel.js +491 -0
  175. plexora/plugins/figure_builder/static/figureViewOptions.js +273 -0
  176. plexora/plugins/figure_builder/static/figureWorkspace.js +2030 -0
  177. plexora/plugins/figure_builder/static/figure_builder.css +3292 -0
  178. plexora/plugins/figure_builder/templates/figure_builder/library.html +74 -0
  179. plexora/plugins/figure_builder/templates/figure_builder/workspace.html +27 -0
  180. plexora/plugins/figure_builder/templates/figure_builder/workspace_body.html +458 -0
  181. plexora/plugins/gating/__init__.py +76 -0
  182. plexora/plugins/gating/server/__init__.py +0 -0
  183. plexora/plugins/gating/server/anndata_gates.py +367 -0
  184. plexora/plugins/gating/server/database.py +16 -0
  185. plexora/plugins/gating/server/model.py +320 -0
  186. plexora/plugins/gating/server/routes.py +235 -0
  187. plexora/plugins/gating/static/csvGatingList.js +947 -0
  188. plexora/plugins/gating/static/gating.css +289 -0
  189. plexora/plugins/gating/static/gatingApi.js +221 -0
  190. plexora/plugins/gating/static/gatingSidebarController.js +513 -0
  191. plexora/plugins/gating/templates/gating/legacy.html +26 -0
  192. plexora/plugins/gating/templates/gating/panel.html +69 -0
  193. plexora/plugins/roi/__init__.py +95 -0
  194. plexora/plugins/roi/server/__init__.py +8 -0
  195. plexora/plugins/roi/server/adapters.py +655 -0
  196. plexora/plugins/roi/server/geojson.py +267 -0
  197. plexora/plugins/roi/server/geometry.py +181 -0
  198. plexora/plugins/roi/server/mapping.py +169 -0
  199. plexora/plugins/roi/server/operations.py +293 -0
  200. plexora/plugins/roi/server/repository.py +215 -0
  201. plexora/plugins/roi/server/routes.py +409 -0
  202. plexora/plugins/roi/server/schema.py +323 -0
  203. plexora/plugins/roi/static/roi.css +583 -0
  204. plexora/plugins/roi/static/roiApi.js +177 -0
  205. plexora/plugins/roi/static/roiFigureBridge.js +107 -0
  206. plexora/plugins/roi/static/roiGeometry.js +350 -0
  207. plexora/plugins/roi/static/roiRenderer.js +278 -0
  208. plexora/plugins/roi/static/roiSidebarController.js +1038 -0
  209. plexora/plugins/roi/static/roiState.js +518 -0
  210. plexora/plugins/roi/static/roiTools.js +993 -0
  211. plexora/plugins/roi/templates/roi/panel.html +240 -0
  212. plexora/proxy.py +57 -0
  213. plexora/server/models/adapters/__init__.py +94 -0
  214. plexora/server/models/adapters/anndata_adapter.py +388 -0
  215. plexora/server/models/adapters/base.py +93 -0
  216. plexora/server/models/adapters/classify.py +192 -0
  217. plexora/server/models/adapters/csv_adapter.py +105 -0
  218. plexora/server/models/adapters/inspection.py +265 -0
  219. plexora/server/models/adapters/spatialdata_adapter.py +248 -0
  220. plexora/server/models/centroid_tiles.py +305 -0
  221. plexora/server/models/data_model.py +1659 -0
  222. plexora/server/models/database_model.py +169 -0
  223. plexora/server/models/project.py +1270 -0
  224. plexora/server/plugins.py +199 -0
  225. plexora/server/routes/browse_routes.py +41 -0
  226. plexora/server/routes/data_routes.py +294 -0
  227. plexora/server/routes/import_routes.py +603 -0
  228. plexora/server/routes/page_routes.py +139 -0
  229. plexora/server/routes/project_routes.py +438 -0
  230. plexora/server/routes/quick_view_routes.py +62 -0
  231. plexora/server/routes/system_routes.py +39 -0
  232. plexora/server/routes/tool_routes.py +418 -0
  233. plexora/server/utils/addHEColumns.py +11 -0
  234. plexora/server/utils/fast_png.py +97 -0
  235. plexora/server/utils/fullConversion.py +50 -0
  236. plexora/server/utils/native_dialog.py +168 -0
  237. plexora/server/utils/pre_normalization.py +47 -0
  238. plexora/server/utils/segmentation_pyramid.py +598 -0
  239. plexora/server/utils/smallestenclosingcircle.py +126 -0
  240. plexora/server/utils/tiffsurgeon.py +372 -0
  241. plexora/server_cli.py +48 -0
  242. plexora/test.py +0 -0
  243. plexora-0.0.1.dist-info/METADATA +341 -0
  244. plexora-0.0.1.dist-info/RECORD +248 -0
  245. plexora-0.0.1.dist-info/WHEEL +5 -0
  246. plexora-0.0.1.dist-info/entry_points.txt +12 -0
  247. plexora-0.0.1.dist-info/licenses/LICENSE +207 -0
  248. 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