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,603 @@
1
+ """Creating a project, and describing what was imported.
2
+
3
+ One entry point. The upload page asks for an image, optionally a segmentation
4
+ mask, and optionally a data file, and nothing else -- the format is detected
5
+ rather than picked from a tab, and everything the old two-step config forms
6
+ demanded up front is either worked out from the file or deferred until some
7
+ feature actually needs it (see plexora/api/plugin.py's Requires).
8
+
9
+ The one thing that cannot be deferred is anything ambiguous about the *file*:
10
+ a .zarr store with several tables, or a table spanning several images, cannot
11
+ be read at all until the user picks one. Those two inputs appear on the form
12
+ only when they apply, which is what /inspect_data is for.
13
+
14
+ After import, a CSV goes to the column-classification screen -- marker versus
15
+ metadata is a property of the dataset, so it is established once and stored
16
+ centrally. AnnData and SpatialData skip it: `var` and `obs` already draw that
17
+ line, so asking would be asking the user to confirm what the file says.
18
+ """
19
+
20
+ import shutil
21
+ from dataclasses import replace
22
+ from pathlib import Path
23
+
24
+ from flask import jsonify, redirect, render_template, request
25
+
26
+ from plexora import app, get_config, get_config_names, paths
27
+ from plexora.datasource import (
28
+ _dedupe_dataset_name,
29
+ _derive_dataset_name_from_path,
30
+ _segmentation_channel_name,
31
+ _segmentation_config_fields,
32
+ _segmentation_spec,
33
+ register_anndata_datasource,
34
+ register_datasource,
35
+ register_image_datasource,
36
+ )
37
+ from plexora.server.models import data_model
38
+ from plexora.server.models.adapters import (
39
+ SUPPORTED_DATA_DESCRIPTION,
40
+ detect_data_type,
41
+ )
42
+ from plexora.server.models.adapters import inspection as data_inspection
43
+ from plexora.server.models.adapters.spatialdata_adapter import list_spatialdata_tables
44
+ from plexora.server.models.project import (
45
+ IMPORT_ROLES,
46
+ ROLE_LABELS,
47
+ ColumnGroups,
48
+ ColumnRoles,
49
+ DataSpec,
50
+ Project,
51
+ )
52
+ from plexora.server.routes.page_routes import template_data
53
+
54
+
55
+ def _base_url():
56
+ return app.config.get('PLEXORA_BASE_URL', '')
57
+
58
+
59
+ def trim_filepath_quotes(path):
60
+ """Drag-and-drop from a file manager often brings the quotes with it, and
61
+ on Windows a copied path is quoted as a matter of course."""
62
+ if not path:
63
+ return path
64
+ path = str(path).strip()
65
+ if len(path) >= 2 and path[0] == path[-1] and path[0] in ('"', "'"):
66
+ path = path[1:-1]
67
+ return path.strip()
68
+
69
+
70
+ def _resolved(path):
71
+ return Path(trim_filepath_quotes(path)).expanduser() if path else None
72
+
73
+
74
+ # --------------------------------------------------------------------------
75
+ # Inspecting a data file before it is imported
76
+ # --------------------------------------------------------------------------
77
+
78
+ @app.route('/inspect_data', methods=['POST'])
79
+ def inspect_data():
80
+ """What the upload form needs to know about the path the user just typed.
81
+
82
+ Called as the Data field changes, and again once a table inside a
83
+ multi-table store has been picked. Answers three questions at once -- is
84
+ this readable, what format is it, and is there anything about it that
85
+ cannot be worked out -- so the form reveals a table, matrix or subset
86
+ picker only when the file genuinely forces the choice. One request rather
87
+ than one per format is what lets the page have a single Data input.
88
+
89
+ `table` names which table inside a .zarr store to look at. It is what makes
90
+ the second call possible: everything after the table choice -- which matrix
91
+ holds the intensities, whether the table spans several images -- is a
92
+ question ABOUT a table, and a store with several has no answer to any of it
93
+ until one is named.
94
+ """
95
+ payload = request.get_json(silent=True) or {}
96
+ path = _resolved(payload.get('path'))
97
+ chosen_table = (payload.get('table') or '').strip() or None
98
+ if not path:
99
+ return jsonify(ok=False, error="No path given"), 400
100
+
101
+ try:
102
+ data_type = detect_data_type(path)
103
+ except ValueError as exc:
104
+ return jsonify(ok=False, error=str(exc)), 200
105
+
106
+ result = {"ok": True, "data_type": data_type, "tables": [], "ambiguous": [],
107
+ "layers": []}
108
+
109
+ if data_type == "csv":
110
+ # Nothing about a CSV is ambiguous at this stage: it has one table and
111
+ # its columns get confirmed on the classification screen anyway.
112
+ return jsonify(result)
113
+
114
+ if data_type == "spatialdata":
115
+ try:
116
+ tables = list_spatialdata_tables(path)
117
+ except Exception as exc: # a malformed store, not a bug
118
+ return jsonify(ok=False, error=f"Could not read the store: {exc}"), 200
119
+ if not tables:
120
+ return jsonify(ok=False, error="This .zarr store holds no tables."), 200
121
+ result["tables"] = tables
122
+ names = [t["name"] for t in tables]
123
+ if chosen_table in names:
124
+ table = chosen_table
125
+ elif len(tables) > 1:
126
+ # Nothing else is answerable yet. The form asks again with the
127
+ # table once the user picks one -- which it must, because
128
+ # otherwise the matrix and subset questions are never put, and an
129
+ # import that should have asked "raw counts or log values?"
130
+ # silently reads X.
131
+ return jsonify(result)
132
+ else:
133
+ table = names[0]
134
+ else:
135
+ table = None
136
+
137
+ try:
138
+ inspection = _inspect(path, data_type, table)
139
+ except Exception as exc:
140
+ return jsonify(ok=False, error=f"Could not read the file: {exc}"), 200
141
+
142
+ proposal = data_inspection.propose_read_spec(inspection)
143
+ result["table"] = table
144
+ # A file with extra expression matrices does not say which one holds the
145
+ # values to threshold on -- raw counts and a log-transformed copy live side
146
+ # by side, and picking X for the user silently decides what every marker
147
+ # histogram in the app is a histogram of.
148
+ result["layers"] = list(inspection.get("layers") or [])
149
+ result["ambiguous"] = [
150
+ {"column": column,
151
+ "values": next((c.get("values") or [] for c in inspection["obs_columns"]
152
+ if c.get("name") == column), [])}
153
+ for column in proposal["ambiguous"]
154
+ ]
155
+ return jsonify(result)
156
+
157
+
158
+ @app.route('/list_spatialdata_tables', methods=['POST'])
159
+ def list_spatialdata_tables_route():
160
+ """The tables inside a .zarr store, for the picker that appears when there
161
+ is more than one."""
162
+ payload = request.get_json(silent=True) or {}
163
+ path = _resolved(payload.get('path'))
164
+ if not path or not path.is_dir():
165
+ return jsonify(ok=False, error="Not a .zarr store"), 200
166
+ try:
167
+ return jsonify(ok=True, tables=list_spatialdata_tables(path))
168
+ except Exception as exc:
169
+ return jsonify(ok=False, error=str(exc)), 200
170
+
171
+
172
+ def _inspect(path, data_type, table=None):
173
+ if data_type == "csv":
174
+ return data_inspection.inspect_csv(path)
175
+ if data_type == "spatialdata":
176
+ return data_inspection.inspect_spatialdata_table(path, table)
177
+ return data_inspection.inspect_anndata(path)
178
+
179
+
180
+ # --------------------------------------------------------------------------
181
+ # Import
182
+ # --------------------------------------------------------------------------
183
+
184
+ def _features_layer(value):
185
+ """Which matrix the marker intensities come from, as the requirements modal
186
+ and the edit page send it -- never the import form, which does not ask.
187
+
188
+ `"X"` is the main matrix and `"layer:<name>"` names one of `adata.layers`.
189
+ Prefixed rather than sent bare so that a layer called "X" -- which anndata
190
+ permits -- cannot be confused with the main matrix. Returns the layer name,
191
+ or None for X and for a caller that did not name one.
192
+ """
193
+ value = (value or "").strip()
194
+ return value.split(":", 1)[1] or None if value.startswith("layer:") else None
195
+
196
+
197
+ def _fail(message, form=None):
198
+ """Re-render the upload page with an error and whatever the user typed, so
199
+ a rejected import does not throw the paths away."""
200
+ return render_template(
201
+ "upload.html",
202
+ data=template_data(error=message, **(form or {})),
203
+ ), 400
204
+
205
+
206
+ @app.route('/import', methods=['POST'])
207
+ def import_project():
208
+ """Create a project from an image, an optional mask and optional data."""
209
+ form = request.form
210
+ name = (form.get('name') or '').strip()
211
+ image_path = _resolved(form.get('image_file'))
212
+ mask_path = _resolved(form.get('label_file'))
213
+ data_file = _resolved(form.get('data_file'))
214
+ table = (form.get('data_table') or '').strip() or None
215
+ subset_column = (form.get('subset_column') or '').strip() or None
216
+ subset_value = (form.get('subset_value') or '').strip() or None
217
+ keep = {"form_name": name, "form_image": form.get('image_file'),
218
+ "form_mask": form.get('label_file'), "form_data": form.get('data_file')}
219
+
220
+ if not image_path or not image_path.exists():
221
+ return _fail("Provide a valid path to the image file.", keep)
222
+ if not name:
223
+ name = _dedupe_dataset_name(_derive_dataset_name_from_path(image_path),
224
+ get_config_names())
225
+ if name in get_config():
226
+ return _fail(f"A project named {name!r} already exists.", keep)
227
+ if mask_path and not mask_path.exists():
228
+ return _fail("Provide a valid path to the segmentation mask.", keep)
229
+
230
+ if not data_file:
231
+ # Image only, plus a mask if one was given. This is a complete project:
232
+ # everything else is something a feature will ask for later.
233
+ try:
234
+ _register_image_only(name, image_path, mask_path)
235
+ except Exception as exc:
236
+ return _fail(f"Could not register the image: {exc}", keep)
237
+ return redirect(f"{_base_url()}/{name}")
238
+
239
+ if not data_file.exists():
240
+ return _fail(f"No such data file: {data_file}", keep)
241
+ try:
242
+ data_type = detect_data_type(data_file)
243
+ except ValueError as exc:
244
+ return _fail(str(exc), keep)
245
+
246
+ try:
247
+ if data_type == "csv":
248
+ _register_csv(name, image_path, mask_path, data_file)
249
+ # The one screen that survives from the old two-step import, and
250
+ # the only one: which columns are markers is a fact about the data
251
+ # that every plugin then reads, so it is worth one confirmation.
252
+ return redirect(f"{_base_url()}/project/{name}/columns")
253
+ # No features_layer: an import lands on X, and which matrix to read is
254
+ # asked by the first plugin that reads intensities (Requires(features=
255
+ # True)), where it belongs. The layer names are recorded here either
256
+ # way, so the modal can offer them without reopening the file.
257
+ _register_anndata(name, image_path, mask_path, data_file, data_type,
258
+ table, subset_column, subset_value)
259
+ except ValueError as exc:
260
+ return _fail(str(exc), keep)
261
+ except Exception as exc:
262
+ return _fail(f"Could not import {data_file.name}: {exc}", keep)
263
+
264
+ return redirect(f"{_base_url()}/{name}")
265
+
266
+
267
+ def _register_image_only(name, image_path, mask_path):
268
+ register_image_datasource(name=name, image=image_path)
269
+ if mask_path:
270
+ attach_segmentation(name, mask_path)
271
+
272
+
273
+ def _register_csv(name, image_path, mask_path, csv_path):
274
+ """Register a CSV project, copying the table into the project directory."""
275
+ local_csv = _copy_into_project(name, csv_path)
276
+ inspection = data_inspection.inspect_csv(local_csv)
277
+ roles = inspection["roles"]
278
+ register_datasource(
279
+ name=name,
280
+ image=image_path,
281
+ features=local_csv,
282
+ # The predictor's guesses, which the classification screen confirms
283
+ # next. register_datasource validates that they exist.
284
+ x=roles.get("x"),
285
+ y=roles.get("y"),
286
+ id_column=roles.get("cell_id"),
287
+ celltype_column=roles.get("celltype"),
288
+ segmentation=mask_path,
289
+ segmentation_async=bool(mask_path),
290
+ )
291
+
292
+
293
+ def _register_anndata(name, image_path, mask_path, features_path, data_type,
294
+ table, subset_column, subset_value):
295
+ """Register an .h5ad or one table of a .zarr store, reading `X`.
296
+
297
+ Which matrix to read is deliberately not a parameter. It only matters to a
298
+ plugin that reads marker intensities, so it is that plugin's requirement
299
+ (`Requires(features=True)`) and the modal collects it -- along with whether
300
+ to log-transform -- the first time such a tool opens. Importing is not the
301
+ moment to answer a question about thresholding, and this path asks only what
302
+ registering the project cannot proceed without. The layer names are recorded
303
+ from the inspection below either way, so the modal can offer them without
304
+ reopening the file.
305
+ """
306
+ if data_type == "spatialdata" and not table:
307
+ tables = list_spatialdata_tables(features_path)
308
+ if len(tables) != 1:
309
+ raise ValueError(
310
+ "This .zarr store holds several tables -- choose which one to load."
311
+ )
312
+ table = tables[0]["name"]
313
+
314
+ inspection = _inspect(features_path, data_type, table)
315
+ proposal = data_inspection.propose_read_spec(inspection)
316
+ if proposal["ambiguous"] and not subset_column:
317
+ raise ValueError(
318
+ f"{features_path.name} spans several images "
319
+ f"(column {proposal['ambiguous'][0]!r}) -- choose which one to load."
320
+ )
321
+
322
+ coordinates = proposal["coordinates"]
323
+ register_anndata_datasource(
324
+ name=name,
325
+ image=image_path,
326
+ features=features_path,
327
+ table=table,
328
+ segmentation=mask_path,
329
+ segmentation_async=bool(mask_path),
330
+ coordinate_source=coordinates.get("source"),
331
+ obsm_key=coordinates.get("obsm_key"),
332
+ x=coordinates.get("x_column"),
333
+ y=coordinates.get("y_column"),
334
+ feature_source="X",
335
+ subset_by=subset_column,
336
+ subset_value=subset_value,
337
+ )
338
+
339
+
340
+ def attach_segmentation(name, mask_path, mode=None):
341
+ """Point a project at a segmentation mask and start its conversion.
342
+
343
+ Shared by import, the edit page and the requirements modal, because a mask
344
+ arrives by all three routes and the work is identical: fingerprint the
345
+ source, record it, then let the background job derive the pyramid and patch
346
+ the project when it lands.
347
+ """
348
+ from plexora.server.utils import segmentation_pyramid
349
+
350
+ dataset_dir = paths.derived_root(name)
351
+ dataset_dir.mkdir(parents=True, exist_ok=True)
352
+ fields, pending = _segmentation_config_fields(
353
+ Path(mask_path) if mask_path else None, dataset_dir,
354
+ segmentation_async=bool(mask_path), segmentation_mode=mode,
355
+ )
356
+
357
+ def _apply(project):
358
+ image = project.image
359
+ channels = list(image.channels)
360
+ has_area = any(c.get("fullname") == "Area" for c in channels)
361
+ if mask_path and not has_area:
362
+ # The viewer expects the mask layer first in imageData.
363
+ label_name = _segmentation_channel_name(mask_path)
364
+ channels.insert(0, {"name": "Area", "fullname": "Area",
365
+ "src": f"/generated/data/{name}/{label_name}/"})
366
+ elif not mask_path and has_area:
367
+ channels = [c for c in channels if c.get("fullname") != "Area"]
368
+ return project.patch(
369
+ image=replace(image, channels=tuple(channels)),
370
+ segmentation=_segmentation_spec(fields),
371
+ )
372
+
373
+ updated = Project.mutate(name, _apply)
374
+ if updated is not None and pending:
375
+ data_model.start_segmentation_job(
376
+ name, pending, dataset_dir, fields["segmentationMode"]
377
+ )
378
+ return updated
379
+
380
+
381
+ def replace_project_data(name, data_path_str, payload=None):
382
+ """Attach a feature table to a project, or swap the one it has.
383
+
384
+ Used by the edit page and by the requirements modal, so a project that
385
+ started as an image only becomes a full one by the same route an existing
386
+ project changes its data file by. Re-detects the format rather than
387
+ assuming the new file matches the old: swapping a CSV for an .h5ad is an
388
+ ordinary thing to want, and it is what the old edit path silently
389
+ corrupted.
390
+
391
+ Roles that still name a real column survive the swap; the rest are cleared,
392
+ because a role pointing at a column that no longer exists is worse than an
393
+ unanswered one -- whatever needs it will ask again.
394
+ """
395
+ payload = payload or {}
396
+ project = Project.find(name)
397
+ if project is None:
398
+ raise ValueError(f"Unknown project: {name!r}")
399
+
400
+ if not data_path_str:
401
+ # Clearing the data file. The image and mask stay; every table-derived
402
+ # fact goes, since none of it describes anything any more.
403
+ return Project.mutate(
404
+ name, lambda p: p.patch(dataset=None).forget_table_answers())
405
+
406
+ source = Path(data_path_str).expanduser()
407
+ if not source.exists():
408
+ raise ValueError(f"No such data file: {source}")
409
+ data_type = detect_data_type(source)
410
+
411
+ table = (payload.get("table") or "").strip() or None
412
+ if data_type == "spatialdata" and not table:
413
+ tables = list_spatialdata_tables(source)
414
+ if len(tables) != 1:
415
+ raise ValueError(
416
+ "This .zarr store holds several tables -- choose which one to load."
417
+ )
418
+ table = tables[0]["name"]
419
+
420
+ inspection = _inspect(source, data_type, table)
421
+ # Both branches produce markers/metadata/roles: inspect_csv classifies the
422
+ # header directly, propose_read_spec folds classify_from_inspection into the
423
+ # read spec. Read the classification off `proposal` in both -- the raw
424
+ # AnnData inspection has var_names/obs_columns and no such keys.
425
+ proposal = (inspection if data_type == "csv"
426
+ else data_inspection.propose_read_spec(inspection))
427
+
428
+ if data_type == "csv":
429
+ source = _copy_into_project(name, source)
430
+ spec_kwargs = {"coordinates": {}, "features": {}, "obs_id_field": None,
431
+ "obs_columns": (), "layers": ()}
432
+ else:
433
+ subset_column = (payload.get("subset_column") or "").strip() or None
434
+ if proposal["ambiguous"] and not subset_column:
435
+ raise ValueError(
436
+ f"{source.name} spans several images "
437
+ f"(column {proposal['ambiguous'][0]!r}) -- choose which one to load."
438
+ )
439
+ layer = _features_layer(payload.get("features_layer"))
440
+ if layer and layer not in (inspection.get("layers") or []):
441
+ raise ValueError(f"{source.name} has no layer named {layer!r}.")
442
+ spec_kwargs = {
443
+ "coordinates": proposal["coordinates"],
444
+ "features": ({"source": "layer", "layer": layer} if layer
445
+ else proposal["features"]),
446
+ "obs_id_field": None,
447
+ "subset": ({"column": subset_column,
448
+ "value": (payload.get("subset_value") or "").strip()}
449
+ if subset_column else {}),
450
+ # The file's own annotation columns, which is what the role
451
+ # questions are asked about for these formats -- unlike `metadata`
452
+ # below, which is what the adapter's table ends up holding.
453
+ "obs_columns": tuple(
454
+ str(c["name"]) for c in (inspection.get("obs_columns") or ())
455
+ if c.get("name")
456
+ ),
457
+ "layers": tuple(str(name) for name in (inspection.get("layers") or ())),
458
+ }
459
+
460
+ known = set(proposal["markers"]) | set(proposal["metadata"])
461
+ if data_type != "csv":
462
+ # The adapter synthesizes these regardless of what the file calls them.
463
+ known |= {"id", "X", "Y"}
464
+
465
+ def _apply(current):
466
+ kept = {role: column for role, column in current.roles.to_dict().items()
467
+ if column in known}
468
+ roles = ColumnRoles(**{**proposal["roles"], **kept})
469
+ if data_type != "csv":
470
+ roles = replace(roles, x="X", y="Y", cell_id="id")
471
+ # Whatever the user confirmed about the old table described columns
472
+ # that may not exist in this one, so those answers are dropped and the
473
+ # fresh predictions go back in front of them. The mask and the cell
474
+ # layer are unaffected -- neither is a fact about the table.
475
+ current = current.forget_table_answers()
476
+ return current.patch(dataset=DataSpec(
477
+ type=data_type,
478
+ src=str(source),
479
+ table=table,
480
+ roles=roles,
481
+ columns=ColumnGroups(markers=tuple(proposal["markers"]),
482
+ metadata=tuple(proposal["metadata"])),
483
+ **spec_kwargs,
484
+ ))
485
+
486
+ return Project.mutate(name, _apply)
487
+
488
+
489
+ def _copy_into_project(name, csv_path):
490
+ """A quantification CSV is small next to the image, and a project that
491
+ keeps working after the user tidies their downloads folder is worth the
492
+ disk. AnnData and SpatialData are referenced in place -- those are not
493
+ small."""
494
+ dataset_dir = paths.project_state_dir(name)
495
+ dataset_dir.mkdir(parents=True, exist_ok=True)
496
+ local = dataset_dir / csv_path.name
497
+ if csv_path.resolve() != local.resolve():
498
+ shutil.copy2(csv_path, local)
499
+ return local
500
+
501
+
502
+ # --------------------------------------------------------------------------
503
+ # Column classification -- marker versus metadata
504
+ # --------------------------------------------------------------------------
505
+
506
+ @app.route('/project/<string:name>/columns', methods=['GET'])
507
+ def project_columns_page(name):
508
+ """Confirm which columns are markers and which are measurements.
509
+
510
+ Only reachable for CSV: the other formats already carry the distinction in
511
+ their own structure. The prediction is made server-side by the same
512
+ classifier every other path uses, so what the user is correcting is the
513
+ same guess the rest of the app would have made.
514
+ """
515
+ project = Project.find(name)
516
+ if project is None or not project.has_table:
517
+ return redirect(f"{_base_url()}/open_project")
518
+
519
+ columns = project.columns
520
+ if not columns.all:
521
+ inspection = _inspect(Path(project.dataset.src), project.dataset.type,
522
+ project.dataset.table)
523
+ markers, metadata = inspection["markers"], inspection["metadata"]
524
+ else:
525
+ markers, metadata = list(columns.markers), list(columns.metadata)
526
+
527
+ return render_template("project_columns.html", data=template_data(
528
+ datasetName=name,
529
+ markers=markers,
530
+ metadata=metadata,
531
+ roles=project.roles.to_dict(),
532
+ # The labels come from the server so the wording is identical here, in
533
+ # the requirements modal, and on the edit page. Narrowed to the roles
534
+ # this screen is a checkpoint for: the classifier renders one select
535
+ # per label it is handed, and a cell-type column is not something core
536
+ # needs to read the table -- see IMPORT_ROLES.
537
+ roleLabels={role: ROLE_LABELS[role] for role in IMPORT_ROLES},
538
+ segmentation_pending=project.segmentation.pending,
539
+ ))
540
+
541
+
542
+ @app.route('/project/<string:name>/columns', methods=['POST'])
543
+ def save_project_columns(name):
544
+ """Store the confirmed split centrally, where every plugin reads it."""
545
+ payload = request.get_json(silent=True) or {}
546
+ updated = Project.mutate(name, lambda p: _apply_columns(p, payload))
547
+ if updated is None:
548
+ return jsonify(success=False, error="Unknown project"), 404
549
+
550
+ data_model.load_datasource(name, reload=True)
551
+ return jsonify(success=True, segmentation_pending=updated.segmentation.pending)
552
+
553
+
554
+ def _apply_columns(project, payload):
555
+ project = project.with_columns(payload.get("markers") or [],
556
+ payload.get("metadata") or [])
557
+ # Narrowed to what the screen actually puts on it (see IMPORT_ROLES). A
558
+ # role it does not draw a select for is a role the user was never shown,
559
+ # and both of the writes below would be wrong for one: applying it stores
560
+ # an answer nobody gave, and confirming it retires the question for good.
561
+ roles = {role: column for role, column in (payload.get("roles") or {}).items()
562
+ if role in IMPORT_ROLES}
563
+ project = project.with_roles(roles)
564
+ # This screen IS the confirmation for everything it shows, so a plugin
565
+ # opened afterwards must not put the same split and the same role selects
566
+ # in front of the user a second time. Only the roles that came back with a
567
+ # column are confirmed: a select left on "Choose a column..." was not
568
+ # answered, and something may still need it.
569
+ return project.with_confirmed(
570
+ ["markers"] + [f"role:{role}" for role, column in roles.items() if column])
571
+
572
+
573
+ # --------------------------------------------------------------------------
574
+ # Small checks the upload form makes as the user types
575
+ # --------------------------------------------------------------------------
576
+
577
+ @app.route('/check_file_existence', methods=['POST'])
578
+ def check_file_existence():
579
+ payload = request.get_json(silent=True) or {}
580
+ path = _resolved(payload.get('path'))
581
+ return jsonify(exists=bool(path and path.is_file()))
582
+
583
+
584
+ @app.route('/check_path_existence', methods=['POST'])
585
+ def check_path_existence():
586
+ """Exists as either a file or a directory -- a .zarr store is a directory,
587
+ and the single Data input accepts both."""
588
+ payload = request.get_json(silent=True) or {}
589
+ path = _resolved(payload.get('path'))
590
+ return jsonify(exists=bool(path and path.exists()))
591
+
592
+
593
+ @app.route('/dataset_existence', methods=['POST'])
594
+ def check_dataset_exists():
595
+ payload = request.get_json(silent=True) or {}
596
+ return jsonify(exists=(payload.get('datasetName') in get_config()))
597
+
598
+
599
+ @app.route('/supported_data_formats')
600
+ def supported_data_formats():
601
+ """So the form's help text and the server's rejection message cannot
602
+ disagree about what the Data input takes."""
603
+ return jsonify(description=SUPPORTED_DATA_DESCRIPTION)