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,655 @@
1
+ """Writing annotations into the file the project came from.
2
+
3
+ Everything here is explicit -- a button the user presses, never a save -- and
4
+ non-destructive. That is not caution for its own sake: the target is the user's
5
+ own measurements, often the only copy, frequently on a share, and an annotation
6
+ export that rewrites an .h5ad's X or drops a SpatialData table is a data-loss
7
+ bug wearing a feature's clothes.
8
+
9
+ Three rules the implementations below follow:
10
+
11
+ **Write the subtree, never the file.** An .h5ad is opened in-place with h5py and
12
+ only `uns/plexora` is rewritten. A read-and-write-back round trip would rebuild
13
+ X, obs and var from whatever anndata's current version thinks they should look
14
+ like, changing chunking, compression and dtypes of data this plugin has no
15
+ business touching.
16
+
17
+ **Never overwrite something without being told to.** `sdata.shapes["roi"]` may be
18
+ somebody's segmentation boundaries; `uns["plexora"]["rois"]` may be a colleague's
19
+ annotation pass. Both destinations are NAMED by the user, and a name that is
20
+ already taken is refused rather than replaced -- the caller has to say
21
+ `replace`, and for SpatialData it is not offered at all (see
22
+ `save_to_spatialdata`). Refusal happens before anything is unlinked.
23
+
24
+ **Never re-consolidate a store root.** See `_open_group`: a SpatialData root is
25
+ zarr v3 and real stores mix v2 tables into it, so rebuilding the root index
26
+ silently drops them. The refresh here is confined to the group actually written.
27
+
28
+ The live annotation state stays in the plugin store either way. These are
29
+ destinations, not the working copy -- which is what lets the drawing tools
30
+ behave identically whether the project came from a CSV, an .h5ad, or nothing but
31
+ an image.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import contextlib
37
+ import json
38
+ from pathlib import Path
39
+
40
+ from plexora.plugins.roi.server import schema
41
+
42
+ #: Where annotations land inside an AnnData. Under a `plexora` group rather than
43
+ #: at the top of uns so Plexora owns one key in a namespace shared with whatever
44
+ #: else the user's pipeline put there.
45
+ UNS_GROUP = "plexora"
46
+
47
+ #: What the entry under that group is called unless the user names it something
48
+ #: else. One project can hold several: a second pass, a second annotator, a
49
+ #: version kept because the first one was worth keeping.
50
+ DEFAULT_UNS_KEY = "rois"
51
+
52
+ #: Default name for the shapes element. Prefixed, because `shapes["roi"]` is a
53
+ #: name a user's own pipeline could plausibly have used for something else.
54
+ DEFAULT_ELEMENT = "plexora_rois"
55
+
56
+ _GROUP_MARKERS = ("zarr.json", ".zgroup")
57
+
58
+
59
+ class DestinationExists(Exception):
60
+ """The name the user gave is already in use in their file.
61
+
62
+ Carries what is already there and a free name, so the caller can say which
63
+ names are taken rather than only that this one is.
64
+ """
65
+
66
+ def __init__(self, message, existing, suggestion):
67
+ super().__init__(message)
68
+ self.existing = existing
69
+ self.suggestion = suggestion
70
+
71
+
72
+ class ElementExists(DestinationExists):
73
+ """The requested shapes element is already in the store."""
74
+
75
+ def __init__(self, existing, suggestion):
76
+ super().__init__(f"element already exists; try {suggestion!r}", existing, suggestion)
77
+
78
+
79
+ class KeyExists(DestinationExists):
80
+ """The requested uns/plexora key is already in the file."""
81
+
82
+ def __init__(self, existing, suggestion):
83
+ super().__init__(f"key already exists; try {suggestion!r}", existing, suggestion)
84
+
85
+
86
+ class ColumnExists(DestinationExists):
87
+ """One of the two ROI columns is already a column of the cell table.
88
+
89
+ `existing` is every column name rather than just the colliding one: the two
90
+ names are derived from a single prefix rather than chosen separately, so a
91
+ user picking a new prefix is choosing against the whole table.
92
+ """
93
+
94
+ def __init__(self, existing, suggestion):
95
+ super().__init__(
96
+ f"a column of that name already exists; try {suggestion!r}",
97
+ existing, suggestion)
98
+
99
+
100
+ # -- AnnData ------------------------------------------------------------
101
+
102
+
103
+ def save_to_anndata(dataset, state, plugin_version, key=None, replace=False):
104
+ """Write `state` into the source file at uns/plexora/<key>.
105
+
106
+ Stored as a JSON string rather than as a nested structure. AnnData's element
107
+ codec writes mappings recursively, and an ROI document is heterogeneous all
108
+ the way down -- ragged coordinate arrays, nullable strings, per-feature
109
+ dicts with different keys -- which is exactly the shape that produces
110
+ unreadable object arrays or an outright encoder error. One string always
111
+ round-trips, in every language that can open the file, and
112
+ `json.loads(adata.uns["plexora"]["rois"])` is a one-liner on the way out.
113
+
114
+ `key` names the entry, so one file can hold several passes side by side
115
+ without them being each other's history. Writing over one that is already
116
+ there needs `replace`, and the refusal happens before `del uns[...]` -- an
117
+ export that unlinks the group and then declines to write is the one failure
118
+ mode here that destroys something.
119
+ """
120
+ if dataset.source_kind != "anndata":
121
+ raise ValueError("this project's data did not come from an AnnData file")
122
+
123
+ source = dataset.table.source
124
+ if source is None or not source.path:
125
+ raise ValueError("no AnnData file is recorded for this project")
126
+
127
+ key = schema.element_name(key, DEFAULT_UNS_KEY)
128
+ document = _snapshot(state, dataset.name, plugin_version)
129
+ payload = json.dumps(document, separators=(",", ":"))
130
+
131
+ try:
132
+ from anndata.io import read_elem, write_elem # anndata >= 0.10, public API
133
+ except ImportError: # pragma: no cover - older anndata fallback
134
+ from anndata._io.specs import read_elem, write_elem
135
+
136
+ with _open_group(source.path, writable=True) as handle:
137
+ uns = handle.require_group("uns")
138
+
139
+ existing = {}
140
+ if UNS_GROUP in uns:
141
+ existing = read_elem(uns[UNS_GROUP])
142
+ if not isinstance(existing, dict):
143
+ # Refused rather than replaced, and refused before anything is
144
+ # written: whatever is under that key belongs to somebody, and
145
+ # this plugin cannot tell what would be lost.
146
+ raise ValueError(
147
+ f"adata.uns[{UNS_GROUP!r}] already exists and is not a mapping, "
148
+ "so these annotations have nowhere to go without overwriting it"
149
+ )
150
+ if key in existing and not replace:
151
+ raise KeyExists(sorted(existing), _suggest(key, existing))
152
+ del uns[UNS_GROUP]
153
+
154
+ existing[key] = payload
155
+ write_elem(uns, UNS_GROUP, existing)
156
+
157
+ return {
158
+ "path": source.path,
159
+ "name": key,
160
+ "key": f"uns/{UNS_GROUP}/{key}",
161
+ "n_rois": len(document["images"].get(schema.DEFAULT_IMAGE, {}).get("features", [])),
162
+ "n_categories": len(document["categories"]),
163
+ }
164
+
165
+
166
+ def existing_anndata_keys(dataset):
167
+ """Names already used under `uns/plexora` in this project's file.
168
+
169
+ Asked on every panel open so a colliding name is visible before it is
170
+ typed. Read-only and forgiving: a file another process is holding open, or
171
+ one whose `uns/plexora` is not a mapping, produces an empty list rather
172
+ than a panel that will not load. The write path checks again for real, and
173
+ refuses there.
174
+ """
175
+ if dataset.source_kind != "anndata":
176
+ return []
177
+ source = dataset.table.source
178
+ if source is None or not source.path:
179
+ return []
180
+
181
+ try:
182
+ from anndata.io import read_elem
183
+ except ImportError: # pragma: no cover - older anndata fallback
184
+ from anndata._io.specs import read_elem
185
+
186
+ try:
187
+ with _open_group(source.path) as handle:
188
+ uns = handle["uns"] if "uns" in handle else None
189
+ if uns is None or UNS_GROUP not in uns:
190
+ return []
191
+ stored = read_elem(uns[UNS_GROUP])
192
+ except Exception: # pragma: no cover - unreadable file; the panel still works
193
+ return []
194
+ return sorted(stored) if isinstance(stored, dict) else []
195
+
196
+
197
+ def _snapshot(state, datasource, plugin_version):
198
+ """What gets written to a file: the annotations, without the revision.
199
+
200
+ The revision is a fact about this project's store -- who last wrote and
201
+ whether a client is stale -- and means nothing in a file somebody else
202
+ opens. Carrying it would invite a reader to treat the file as authoritative
203
+ about the live state, which it never is.
204
+ """
205
+ return {
206
+ "schema_version": state["schema_version"],
207
+ "plugin_version": plugin_version,
208
+ "datasource": datasource,
209
+ "categories": state["categories"],
210
+ "images": state["images"],
211
+ }
212
+
213
+
214
+ # -- SpatialData --------------------------------------------------------
215
+
216
+
217
+ def existing_shapes(dataset):
218
+ """Names already used by shapes elements in this project's store."""
219
+ store = _spatialdata_store(dataset)
220
+ shapes_dir = Path(store) / "shapes"
221
+ if not shapes_dir.is_dir():
222
+ return []
223
+ return sorted(entry.name for entry in shapes_dir.iterdir() if _is_group(entry))
224
+
225
+
226
+ def save_to_spatialdata(dataset, state, element_name=DEFAULT_ELEMENT,
227
+ image_key=schema.DEFAULT_IMAGE):
228
+ """Write the annotations into the store as a shapes element.
229
+
230
+ Coordinates go in untransformed, under an identity transformation into the
231
+ store's own coordinate system. That is correct here for the same reason the
232
+ importer reads tables as-is: Plexora's viewer lays the image out in its
233
+ pixel grid, and this project's shapes were drawn on that grid, so pixel
234
+ coordinates ARE the image element's coordinates. Attaching a scale nobody
235
+ measured would be the guess.
236
+ """
237
+ store = _spatialdata_store(dataset)
238
+ element_name = schema.element_name(element_name, DEFAULT_ELEMENT)
239
+ taken = existing_shapes(dataset)
240
+ if element_name in taken:
241
+ # Overwriting an element in place is not offered. spatialdata's own
242
+ # element writer refuses it, and the workaround -- delete then rewrite
243
+ # -- has a window in which the user's data is gone and the replacement
244
+ # is not yet there. A second name costs nothing and cannot lose
245
+ # anything.
246
+ raise ElementExists(taken, _suggest(element_name, taken))
247
+
248
+ try:
249
+ import geopandas as gpd
250
+ import shapely.geometry as sgeom
251
+ import spatialdata
252
+ from spatialdata.models import ShapesModel
253
+ from spatialdata.transformations import Identity
254
+ except ImportError as exc: # pragma: no cover - optional at runtime
255
+ raise ValueError(f"SpatialData export needs {exc.name!r} to be installed") from exc
256
+
257
+ entry = state["images"].get(image_key) or schema.empty_image()
258
+ categories = {c["id"]: c for c in state["categories"]}
259
+ if not entry["features"]:
260
+ raise ValueError("there are no ROIs to export")
261
+
262
+ rows, geometries = [], []
263
+ for feature in entry["features"]:
264
+ category = categories.get(feature["category_id"]) or schema.placeholder_category()
265
+ geometries.append(_shapely(feature["geometry"], sgeom))
266
+ rows.append({
267
+ "roi_id": feature["id"],
268
+ "name": feature.get("name") or "",
269
+ "category_id": feature["category_id"],
270
+ "category": category["label"],
271
+ "category_color": category["color"],
272
+ "locked": bool(feature.get("locked", False)),
273
+ })
274
+
275
+ frame = gpd.GeoDataFrame(rows, geometry=geometries)
276
+ element = ShapesModel.parse(frame, transformations={"global": Identity()})
277
+
278
+ # Only the shapes group: reading the tables would materialize every one of
279
+ # them, which on a real store is hundreds of megabytes of embeddings nobody
280
+ # asked for (the same trap the importer documents).
281
+ sdata = spatialdata.read_zarr(store, selection=("shapes",))
282
+ sdata.shapes[element_name] = element
283
+ sdata.write_element(element_name)
284
+
285
+ return {
286
+ "path": str(store),
287
+ "name": element_name,
288
+ "element": f"shapes/{element_name}",
289
+ "n_rois": len(rows),
290
+ "coordinate_system": "global",
291
+ }
292
+
293
+
294
+ # -- ROI columns on the cells -------------------------------------------
295
+
296
+
297
+ def cell_column_names(prefix):
298
+ """The two columns "Map to cells" writes, for one destination name.
299
+
300
+ Derived from the name the user already types when saving, so several
301
+ annotation passes can sit side by side in one table the same way several
302
+ `uns/plexora/<key>` entries can. `rois` -- not DEFAULT_ELEMENT -- is the
303
+ fallback for both formats: a SpatialData project would otherwise get
304
+ `plexora_rois_category`, which is a column name nobody wants to type.
305
+ """
306
+ prefix = schema.element_name(prefix, DEFAULT_UNS_KEY)
307
+ return f"{prefix}_category", f"{prefix}_name"
308
+
309
+
310
+ def write_cell_columns(dataset, labels, names, prefix=None, replace=False):
311
+ """Write two ROI annotation columns onto this project's cells.
312
+
313
+ `labels` and `names` are per-row values for the LOADED table, in its order
314
+ -- what `mapping.assign` returns. Getting them onto the right rows of the
315
+ file underneath is this function's whole job, and it is not a copy: the
316
+ loaded table is frequently a subset of the file's cells, and the file may
317
+ hold several images' worth of them.
318
+
319
+ Two guarantees, in the module's existing spirit:
320
+
321
+ Rows this project cannot see are never touched. A cell belonging to another
322
+ image in the same shared .h5ad keeps whatever it had, or gets a null if the
323
+ column is new -- so the same file can be annotated once per image without
324
+ each pass erasing the last.
325
+
326
+ That is also why blank and null mean different things here, and the
327
+ difference is worth keeping. A cell of THIS image that fell in no region
328
+ gets an empty string: it was tested, and the answer is "none". A cell of
329
+ another image gets a null: it was never tested. Collapsing the two would
330
+ make a half-annotated file indistinguishable from a fully annotated one in
331
+ which nothing overlapped.
332
+
333
+ An existing column is refused, not overwritten, and refused before anything
334
+ is written. `replace` is the user's answer to being asked.
335
+ """
336
+ category_column, name_column = cell_column_names(prefix)
337
+ kind = dataset.source_kind
338
+ if kind == "csv":
339
+ return _write_csv_columns(dataset, labels, names, category_column,
340
+ name_column, replace)
341
+ if kind in ("anndata", "spatialdata"):
342
+ return _write_obs_columns(dataset, labels, names, category_column,
343
+ name_column, replace)
344
+ raise ValueError("this project has no cell-level data to annotate")
345
+
346
+
347
+ def _write_obs_columns(dataset, labels, names, category_column, name_column, replace):
348
+ """The AnnData/SpatialData path: rewrite `obs`, and nothing else.
349
+
350
+ `obs` is read through anndata's element codec, modified in memory and
351
+ written back over itself. That is one subtree: X, var, obsm, uns and every
352
+ other table in a SpatialData store are never opened, let alone rebuilt --
353
+ the property the module docstring insists on, and the reason this is not
354
+ `ad.read_h5ad()` plus `write_h5ad()`.
355
+ """
356
+ try:
357
+ from anndata.io import read_elem, write_elem # anndata >= 0.10, public API
358
+ except ImportError: # pragma: no cover - older anndata fallback
359
+ from anndata._io.specs import read_elem, write_elem
360
+
361
+ path = _table_path(dataset)
362
+ source = dataset.table.source
363
+
364
+ with _open_group(path) as handle:
365
+ obs = read_elem(handle["obs"])
366
+
367
+ taken = [c for c in (category_column, name_column) if c in obs.columns]
368
+ if taken and not replace:
369
+ raise ColumnExists(sorted(obs.columns), _suggest_prefix(category_column, obs.columns))
370
+
371
+ mask = _project_rows(dataset, obs)
372
+ selected = int(mask.sum())
373
+ if selected != len(labels):
374
+ # The file has changed under the loaded table -- rows added, removed or
375
+ # reordered since it was read. Assigning anyway would put every label on
376
+ # the wrong cell, which is invisible in the file and wrong forever.
377
+ raise ValueError(
378
+ f"this project's table has {len(labels)} cells but the file now has "
379
+ f"{selected} matching rows; reopen the project and try again"
380
+ )
381
+
382
+ obs = _assign_column(obs, mask, category_column, labels,
383
+ dataset, source, categorical=True)
384
+ obs = _assign_column(obs, mask, name_column, names,
385
+ dataset, source, categorical=False)
386
+
387
+ with _open_group(path, writable=True) as handle:
388
+ # Unlinked and rewritten rather than patched in place: obs is a group
389
+ # whose column layout lives in its attributes, and a codec that writes
390
+ # the frame as a whole is the only one that keeps those honest.
391
+ del handle["obs"]
392
+ write_elem(handle, "obs", obs)
393
+
394
+ return {
395
+ "path": str(path),
396
+ "columns": [category_column, name_column],
397
+ "n_cells": len(labels),
398
+ "n_assigned": sum(1 for value in names if value),
399
+ }
400
+
401
+
402
+ def _assign_column(obs, mask, column, values, dataset, source, categorical):
403
+ """One column written onto the masked rows, aligned by cell id.
404
+
405
+ Alignment is by identifier wherever the project has one, never by position:
406
+ a project is routinely a subset of its file's obs, and a positional write
407
+ against the wrong offset shifts every label by the size of whatever came
408
+ before it. Row numbers are the fallback precisely because they are the case
409
+ where the user told us there is no identifier to align on.
410
+ """
411
+ import numpy as np
412
+ import pandas as pd
413
+
414
+ id_field = getattr(dataset.project.dataset, "obs_id_field", None)
415
+ frame = dataset.table.frame()
416
+ cell_id = dataset.schema.cell_id if dataset.schema else None
417
+
418
+ series = obs[column] if column in obs.columns else pd.Series(
419
+ [None] * len(obs), index=obs.index, dtype="object")
420
+ series = series.astype("object")
421
+
422
+ positions = np.flatnonzero(np.asarray(mask))
423
+ if id_field and id_field in obs.columns and frame is not None and cell_id in frame.columns:
424
+ wanted = {str(key): value
425
+ for key, value in zip(frame[cell_id].to_list(), values)}
426
+ keys = obs[id_field].astype(str).to_numpy()
427
+ for position in positions:
428
+ if keys[position] in wanted:
429
+ series.iloc[position] = wanted[keys[position]]
430
+ else:
431
+ # No identifier column: the loaded table is the masked rows in file
432
+ # order, which the length check above has already confirmed.
433
+ for offset, position in enumerate(positions):
434
+ series.iloc[position] = values[offset]
435
+
436
+ # Not a plain object column: anndata's codec refuses one holding both
437
+ # strings and None ("Can't implicitly convert non-string objects to
438
+ # strings"), and rows belonging to another image are exactly the Nones. The
439
+ # category column becomes a pandas categorical, which is what obs columns of
440
+ # labels are everywhere else and what makes scanpy plot it without being
441
+ # asked twice; names stay free text as a nullable string array.
442
+ obs[column] = (series.astype("string").astype("category") if categorical
443
+ else series.astype("string"))
444
+ return obs
445
+
446
+
447
+ def _project_rows(dataset, obs):
448
+ """A boolean mask over `obs` selecting the cells this project can see.
449
+
450
+ Two filters, and both matter. The registration subset is how a project was
451
+ narrowed to one image at import. The image-id column is the answer the user
452
+ gave later, through the requirements modal, and it is the one that makes
453
+ running this once per image against one shared file safe -- without it a
454
+ second pass would write over the first image's labels.
455
+ """
456
+ import numpy as np
457
+
458
+ from plexora.plugins.roi.server import mapping
459
+
460
+ mask = np.ones(len(obs), dtype=bool)
461
+
462
+ subset = dict((dataset.table.source.subset if dataset.table.source else None) or {})
463
+ column = subset.get("column")
464
+ if column:
465
+ if column not in obs.columns:
466
+ raise ValueError(f"subset column {column!r} is no longer in this file's obs")
467
+ mask &= obs[column].astype(str).to_numpy() == str(subset.get("value"))
468
+
469
+ image_id = mapping.current_image_id(dataset)
470
+ image_column = dataset.schema.image_id if dataset.schema else None
471
+ if image_id is not None and image_column and image_column in obs.columns:
472
+ mask &= obs[image_column].astype(str).to_numpy() == str(image_id)
473
+ return mask
474
+
475
+
476
+ def _write_csv_columns(dataset, labels, names, category_column, name_column, replace):
477
+ """The CSV path: the whole file, because a CSV has no subtree.
478
+
479
+ Written to a temporary file in the same directory and renamed over the
480
+ original, so a reader never sees a half-written table and a failure partway
481
+ through leaves the original intact. That is the same guarantee
482
+ `project.write_config()` gives config.json, and for the same reason: this is
483
+ somebody's measurements and often the only copy.
484
+ """
485
+ import os
486
+ import tempfile
487
+
488
+ import polars as pl
489
+
490
+ source = dataset.table.source
491
+ if source is None or not source.path:
492
+ raise ValueError("no CSV file is recorded for this project")
493
+
494
+ frame = pl.read_csv(source.path)
495
+ taken = [c for c in (category_column, name_column) if c in frame.columns]
496
+ if taken and not replace:
497
+ raise ColumnExists(sorted(frame.columns),
498
+ _suggest_prefix(category_column, frame.columns))
499
+ if frame.height != len(labels):
500
+ raise ValueError(
501
+ f"this project's table has {len(labels)} cells but the file now has "
502
+ f"{frame.height} rows; reopen the project and try again"
503
+ )
504
+
505
+ frame = frame.with_columns([
506
+ pl.Series(category_column, labels, dtype=pl.Utf8),
507
+ pl.Series(name_column, names, dtype=pl.Utf8),
508
+ ])
509
+
510
+ directory = os.path.dirname(os.path.abspath(source.path)) or "."
511
+ handle, temporary = tempfile.mkstemp(suffix=".csv", dir=directory)
512
+ os.close(handle)
513
+ try:
514
+ frame.write_csv(temporary)
515
+ os.replace(temporary, source.path)
516
+ except BaseException:
517
+ with contextlib.suppress(OSError):
518
+ os.unlink(temporary)
519
+ raise
520
+
521
+ return {
522
+ "path": source.path,
523
+ "columns": [category_column, name_column],
524
+ "n_cells": len(labels),
525
+ "n_assigned": sum(1 for value in names if value),
526
+ }
527
+
528
+
529
+ def _table_path(dataset):
530
+ """Where this project's AnnData group actually lives.
531
+
532
+ A SpatialData store is narrowed to the one table this project reads --
533
+ writing at the store root would put obs somewhere no reader looks, and
534
+ `_open_group`'s re-consolidation is only safe on the group it wrote.
535
+ """
536
+ source = dataset.table.source
537
+ if source is None or not source.path:
538
+ raise ValueError("no data file is recorded for this project")
539
+ if dataset.source_kind == "spatialdata":
540
+ from plexora.server.models.adapters.spatialdata_adapter import table_path
541
+
542
+ return str(table_path(source.path, source.table))
543
+ return source.path
544
+
545
+
546
+ def _suggest_prefix(category_column, taken):
547
+ """A free destination name, given that `<name>_category` is taken."""
548
+ base = category_column[: -len("_category")]
549
+ return _suggest(base, {str(c) for c in taken})
550
+
551
+
552
+ def _shapely(geometry, sgeom):
553
+ """A stored geometry as a shapely object, holes and all."""
554
+ kind = geometry.get("type")
555
+ if kind == "MultiPolygon":
556
+ return sgeom.MultiPolygon([_polygon(part, sgeom) for part in geometry["coordinates"]])
557
+ return _polygon(geometry["coordinates"], sgeom)
558
+
559
+
560
+ def _polygon(rings, sgeom):
561
+ outer, *holes = rings
562
+ return sgeom.Polygon(outer, holes)
563
+
564
+
565
+ def _suggest(name, taken):
566
+ """The next free `name_2`, `name_3`, ... """
567
+ base = name.rstrip("0123456789_") or name
568
+ for index in range(2, 1000):
569
+ candidate = f"{base}_{index}"
570
+ if candidate not in taken:
571
+ return candidate
572
+ return f"{base}_{len(taken) + 1}" # pragma: no cover - a thousand collisions
573
+
574
+
575
+ def _spatialdata_store(dataset):
576
+ if dataset.source_kind != "spatialdata":
577
+ raise ValueError("this project's data did not come from a SpatialData store")
578
+ source = dataset.table.source
579
+ if source is None or not source.path:
580
+ raise ValueError("no SpatialData store is recorded for this project")
581
+ if not _is_group(Path(source.path)):
582
+ raise ValueError(f"{source.path!r} is not a zarr store")
583
+ return source.path
584
+
585
+
586
+ # -- opening an on-disk AnnData -----------------------------------------
587
+
588
+
589
+ def _is_group(path: Path) -> bool:
590
+ return path.is_dir() and any((path / marker).exists() for marker in _GROUP_MARKERS)
591
+
592
+
593
+ def _consolidated_format(path: Path):
594
+ """Which zarr format's consolidated index this group carries, if any.
595
+
596
+ A consolidated index is a cached copy of every child's metadata kept beside
597
+ the group -- `.zmetadata` in v2, a `consolidated_metadata` key inside
598
+ `zarr.json` in v3. Readers that find one trust it completely and never list
599
+ the directory, which is both why a write is refused and why a write has to
600
+ be followed by a refresh.
601
+ """
602
+ if (path / ".zmetadata").is_file():
603
+ return 2
604
+ metadata = path / "zarr.json"
605
+ if metadata.is_file():
606
+ try:
607
+ document = json.loads(metadata.read_text(encoding="utf-8"))
608
+ except (OSError, ValueError): # pragma: no cover - unreadable metadata
609
+ return None
610
+ if document.get("consolidated_metadata") is not None:
611
+ return 3
612
+ return None
613
+
614
+
615
+ @contextlib.contextmanager
616
+ def _open_group(path: str, writable: bool = False):
617
+ """The root group of an on-disk AnnData, for either backend.
618
+
619
+ anndata's element codec is storage-agnostic, so the caller works unchanged
620
+ against an h5py group from an .h5ad or a zarr group from a store. A .zarr is
621
+ a directory, which is what tells the two apart.
622
+
623
+ The consolidated-zarr dance is the same one gating's anndata_gates._open_group
624
+ documents at length, and for the same reasons: anndata refuses to write to a
625
+ group whose metadata is consolidated, and skipping the rebuild afterwards
626
+ loses the write silently because every reader consults the stale index. The
627
+ refresh is confined to the group written -- re-consolidating a SpatialData
628
+ ROOT drops every v2 table from a v3 index, and real stores mix the two.
629
+
630
+ Duplicated rather than imported from the gating plugin on purpose: a plugin
631
+ that reaches into another plugin's internals stops working the moment the
632
+ user's build does not ship that plugin.
633
+ """
634
+ if Path(path).is_dir():
635
+ import zarr
636
+
637
+ # Path (not str) deliberately: zarr v3 parses a string store as a URL,
638
+ # mangling names containing characters like '#'.
639
+ location = Path(path)
640
+ if not writable:
641
+ yield zarr.open_group(location, mode="r")
642
+ return
643
+
644
+ consolidated = _consolidated_format(location)
645
+ yield zarr.open_group(location, mode="a", use_consolidated=False)
646
+ if consolidated is not None:
647
+ zarr.consolidate_metadata(
648
+ zarr.storage.LocalStore(location), zarr_format=consolidated
649
+ )
650
+ return
651
+
652
+ import h5py
653
+
654
+ with h5py.File(path, "r+" if writable else "r") as handle:
655
+ yield handle