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,267 @@
1
+ """Annotations as a file somebody else can open.
2
+
3
+ GeoJSON, because polygons are polygons: Shapely, GeoPandas, QGIS, SpatialData's
4
+ own shapes reader and every JavaScript geometry library already read it, and the
5
+ alternative is inventing a polygon format that only Plexora can open.
6
+
7
+ The one honest caveat, stated in the file itself rather than assumed: GeoJSON is
8
+ a GEOGRAPHIC format. Its positions are longitude/latitude on the WGS-84 datum
9
+ unless a document says otherwise, and these are image pixels with y increasing
10
+ downward -- which is not a coordinate reference system at all. So the export
11
+ carries an explicit `coordinate_space` and never claims a CRS. A reader that
12
+ treats these as degrees gets nonsense either way; one that reads the metadata
13
+ gets the truth.
14
+
15
+ Plexora-specific material lives under a `plexora` foreign member. Foreign
16
+ members are the part of the GeoJSON spec meant for exactly this: a strict reader
17
+ ignores it and still gets valid geometry, and Plexora gets its categories back.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import uuid
23
+ from datetime import datetime, timezone
24
+
25
+ from plexora.plugins.roi.server import schema
26
+ from plexora.plugins.roi.server.geometry import validate_geometry
27
+
28
+ MIME_TYPE = "application/geo+json"
29
+
30
+ #: Features in one imported document. An import is parsed, validated and applied
31
+ #: in one request, so this is the ceiling on how long that request can take.
32
+ MAX_IMPORT_FEATURES = 50_000
33
+
34
+ #: Where an imported feature carrying no category information at all is filed.
35
+ #: It has to go somewhere -- dropping the shape because some intermediate tool
36
+ #: stripped its properties would lose real annotation -- and since a project no
37
+ #: longer has a reserved catch-all, one named for how these arrived is at least
38
+ #: honest about what it holds. Created only if such a feature actually turns up.
39
+ IMPORTED_LABEL = "Imported"
40
+
41
+
42
+ def export_document(state, datasource, plugin_version, image_key=schema.DEFAULT_IMAGE,
43
+ image_id=None):
44
+ """A FeatureCollection holding everything needed to reconstruct the project.
45
+
46
+ Category metadata is written twice on purpose: once in the foreign member
47
+ (so a Plexora import restores the categories themselves -- colours, order,
48
+ visibility) and once flattened onto each feature's properties (so a reader
49
+ that ignores foreign members still knows what each region is). The
50
+ duplication is a few bytes and the alternative is an export that is either
51
+ lossy for Plexora or opaque to everyone else.
52
+
53
+ `image_id` is the value this project's cells carry in their image-id column,
54
+ and it is written for the same reason and in both of the same places. An
55
+ AnnData can hold cells from a dozen slides; a file of polygons that does not
56
+ say which one it was drawn on is a file whose regions can be applied to the
57
+ wrong slide without anything looking wrong. It is passed in rather than read
58
+ from `state` because it is a fact about the project record, which can change
59
+ after these shapes were drawn -- resolving it at export time is what keeps a
60
+ stored copy from going quietly stale. None is a real answer: a single-image
61
+ project has no such column, and the key is then absent rather than null.
62
+ """
63
+ entry = state["images"].get(image_key) or schema.empty_image()
64
+ categories = {c["id"]: c for c in state["categories"]}
65
+
66
+ features = []
67
+ for feature in entry["features"]:
68
+ category = categories.get(feature["category_id"]) or schema.placeholder_category()
69
+ features.append({
70
+ "type": "Feature",
71
+ "id": feature["id"],
72
+ "geometry": feature["geometry"],
73
+ "properties": {
74
+ "name": feature.get("name") or "",
75
+ "category_id": feature["category_id"],
76
+ "category": category["label"],
77
+ "category_color": category["color"],
78
+ "locked": feature.get("locked", False),
79
+ "created_at": feature.get("created_at"),
80
+ "updated_at": feature.get("updated_at"),
81
+ "source_roi_id": feature.get("source_roi_id"),
82
+ # Per feature as well as per document, so concatenating two
83
+ # projects' exports into one collection -- which is what anyone
84
+ # comparing slides does -- keeps each shape bound to its image.
85
+ **({"image_id": image_id} if image_id is not None else {}),
86
+ **({"notes": feature["notes"]} if feature.get("notes") else {}),
87
+ },
88
+ })
89
+
90
+ return {
91
+ "type": "FeatureCollection",
92
+ "plexora": {
93
+ "schema_version": schema.SCHEMA_VERSION,
94
+ "plugin_version": plugin_version,
95
+ "datasource": datasource,
96
+ **({"image_id": image_id} if image_id is not None else {}),
97
+ "exported_at": _now(),
98
+ "coordinate_space": entry["coordinate_space"],
99
+ "categories": state["categories"],
100
+ },
101
+ "features": features,
102
+ }
103
+
104
+
105
+ def validate_document(document, image_size=None):
106
+ """Check an incoming document, returning what is wrong with it.
107
+
108
+ Returns (errors, warnings). Errors mean it cannot be imported at all;
109
+ warnings mean it can, but the user should be asked first -- which today is
110
+ exactly one thing, and the important one.
111
+ """
112
+ errors, warnings = [], {}
113
+
114
+ if not isinstance(document, dict):
115
+ return ["that file does not contain a GeoJSON object"], warnings
116
+ if document.get("type") != "FeatureCollection":
117
+ errors.append("expected a GeoJSON FeatureCollection")
118
+
119
+ meta = document.get("plexora")
120
+ if not isinstance(meta, dict):
121
+ # v1 imports Plexora's own exports only. A bare GeoJSON from elsewhere
122
+ # is readable geometry in an unknown coordinate space -- pixels of some
123
+ # image, microns, or degrees -- and importing it would mean guessing
124
+ # which. That guess is the one this whole module exists to avoid.
125
+ errors.append(
126
+ "this file was not exported by Plexora (no 'plexora' metadata), so "
127
+ "there is no way to tell what its coordinates mean"
128
+ )
129
+ else:
130
+ version = meta.get("schema_version")
131
+ if not isinstance(version, int) or isinstance(version, bool):
132
+ errors.append("the file's Plexora metadata has no schema version")
133
+ elif version > schema.SCHEMA_VERSION:
134
+ errors.append(
135
+ f"the file was written by a newer version of Plexora "
136
+ f"(schema {version}, this build reads {schema.SCHEMA_VERSION})"
137
+ )
138
+
139
+ features = document.get("features")
140
+ if not isinstance(features, list):
141
+ errors.append("the file has no features list")
142
+ elif len(features) > MAX_IMPORT_FEATURES:
143
+ errors.append(f"the file holds more than {MAX_IMPORT_FEATURES} regions")
144
+
145
+ if errors:
146
+ return errors, warnings
147
+
148
+ stored = (meta.get("coordinate_space") or {}) if isinstance(meta, dict) else {}
149
+ stored_size = (stored.get("width"), stored.get("height"))
150
+ if image_size and all(image_size) and all(stored_size) and tuple(image_size) != stored_size:
151
+ # Not an error: importing anyway is a legitimate thing to want when the
152
+ # two images really are the same field of view at different scales. It
153
+ # is just never the thing to do by default, because the geometry will
154
+ # land somewhere plausible and wrong.
155
+ warnings["dimension_mismatch"] = {
156
+ "found": list(stored_size),
157
+ "expected": list(image_size),
158
+ }
159
+
160
+ return errors, warnings
161
+
162
+
163
+ def import_features(state, document, image_key=schema.DEFAULT_IMAGE):
164
+ """Add a document's regions to `state`, returning (new_state, report).
165
+
166
+ Nothing is ever overwritten. Every imported region is given a fresh id with
167
+ its original kept in `source_roi_id`, because an id collision here is not a
168
+ conflict to resolve -- the two regions are simply different regions that
169
+ happen to have been numbered the same in two projects, and picking one would
170
+ destroy the other. Importing the same file twice therefore duplicates its
171
+ regions, which is visible and undoable; the alternative silently is not.
172
+
173
+ Categories are matched first by id (a Plexora export carries the same ids)
174
+ and then by label, so importing Tumor into a project that already has one
175
+ lands in the existing category rather than creating "Tumor" twice.
176
+ """
177
+ by_id = {c["id"]: c for c in state["categories"]}
178
+ by_label = {c["label"].casefold(): c for c in state["categories"]}
179
+
180
+ new_categories, new_features = [], []
181
+ remap = {}
182
+
183
+ meta = document.get("plexora") or {}
184
+ for raw in meta.get("categories") or []:
185
+ if not isinstance(raw, dict):
186
+ continue
187
+ category = schema.normalize_category(raw)
188
+ match = by_id.get(category["id"]) or by_label.get(category["label"].casefold())
189
+ if match is not None:
190
+ remap[category["id"]] = match["id"]
191
+ continue
192
+ new_categories.append(category)
193
+ by_id[category["id"]] = category
194
+ by_label[category["label"].casefold()] = category
195
+ remap[category["id"]] = category["id"]
196
+
197
+ for raw in document.get("features") or []:
198
+ if not isinstance(raw, dict):
199
+ raise ValueError("every entry in 'features' must be an object")
200
+ properties = raw.get("properties") if isinstance(raw.get("properties"), dict) else {}
201
+
202
+ category_id = _resolve_category(properties, remap, by_id, by_label, new_categories)
203
+ new_features.append({
204
+ "id": new_id("r"),
205
+ "category_id": category_id,
206
+ "name": schema.clean_text(properties.get("name")),
207
+ "locked": bool(properties.get("locked", False)),
208
+ "geometry": validate_geometry(raw.get("geometry")),
209
+ "flags": schema.normalize_flags(raw.get("flags")),
210
+ # The id it had where it came from. Kept because it is the only
211
+ # thread back to the source project once ids are regenerated, and
212
+ # somebody reconciling two exports will want it.
213
+ "source_roi_id": schema.clean_text(raw.get("id")) or None,
214
+ "notes": schema.clean_text(properties.get("notes"), schema.MAX_NOTE_LENGTH),
215
+ })
216
+
217
+ return {
218
+ "op": "roi.bulk_create",
219
+ "image": image_key,
220
+ "categories": new_categories,
221
+ "features": [f for f in new_features],
222
+ }, {
223
+ "imported": len(new_features),
224
+ "created_categories": len(new_categories),
225
+ }
226
+
227
+
228
+ def _resolve_category(properties, remap, by_id, by_label, new_categories):
229
+ """Which category an imported feature belongs in.
230
+
231
+ Falls back through: the id the export used, then the label it printed, then
232
+ IMPORTED_LABEL. The label fallback is what makes a feature survive a
233
+ document whose foreign member was stripped by some intermediate tool, and
234
+ the last step is what makes it survive losing its properties entirely.
235
+
236
+ Every path ends at a category that exists or is created here, since there is
237
+ no reserved one to point at.
238
+ """
239
+ source_id = properties.get("category_id")
240
+ if isinstance(source_id, str) and source_id in remap:
241
+ return remap[source_id]
242
+ if isinstance(source_id, str) and source_id in by_id:
243
+ return source_id
244
+
245
+ label = schema.clean_text(properties.get("category")) or IMPORTED_LABEL
246
+ match = by_label.get(label.casefold())
247
+ if match is not None:
248
+ return match["id"]
249
+
250
+ category = schema.normalize_category({
251
+ "id": new_id("c"),
252
+ "label": label,
253
+ "color": properties.get("category_color"),
254
+ "sort_order": len(by_id),
255
+ })
256
+ new_categories.append(category)
257
+ by_id[category["id"]] = category
258
+ by_label[label.casefold()] = category
259
+ return category["id"]
260
+
261
+
262
+ def new_id(prefix):
263
+ return f"{prefix}-{uuid.uuid4()}"
264
+
265
+
266
+ def _now():
267
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
@@ -0,0 +1,181 @@
1
+ """What counts as a storable ROI geometry.
2
+
3
+ Every geometry that reaches the store passes through `validate_geometry`,
4
+ whether it came from the drawing tools, an import, or a request somebody wrote
5
+ by hand. The client validates too -- it has to, since it decides what to do
6
+ about a bad shape while the user is still holding the mouse -- but the client's
7
+ answer is advice and this one is the rule.
8
+
9
+ Two things are deliberately NOT done here:
10
+
11
+ **No repair.** A self-intersecting polygon is stored as drawn, flagged rather
12
+ than corrected. `make_valid` and friends change topology, and a bow-tie silently
13
+ becoming two triangles is a different annotation from the one the user drew.
14
+ The flag is what surfaces it; the user decides.
15
+
16
+ **No projection, no scaling, no clamping.** Coordinates are full-resolution
17
+ image pixels and are stored exactly as given. A shape that hangs off the edge of
18
+ the image is a real thing to have drawn (a region continuing past the tissue
19
+ edge), and quietly trimming it would lose that.
20
+
21
+ The one normalization that does happen is closing rings. GeoJSON requires the
22
+ first and last position of a ring to be identical; [A,B,C] and [A,B,C,A] denote
23
+ the same polygon to every geometry library there is, so accepting both and
24
+ storing one is not a repair -- it just means nothing downstream has to handle
25
+ two spellings of the same shape.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import math
31
+
32
+ POLYGON = "Polygon"
33
+ MULTI_POLYGON = "MultiPolygon"
34
+ GEOMETRY_TYPES = (POLYGON, MULTI_POLYGON)
35
+
36
+ #: Vertices in one feature, across every ring. A hand-drawn region is a few
37
+ #: hundred points and a simplified freehand trace a few thousand; an imported
38
+ #: tissue contour can legitimately be tens of thousands. Past this, a request is
39
+ #: a mistake or an attempt to make the server chew on something -- and a feature
40
+ #: that big is unusable in the editor anyway, since it is one undo step and one
41
+ #: Path2D rebuild per drag frame.
42
+ MAX_VERTICES = 100_000
43
+
44
+ #: Rings in one polygon (the outer ring plus interior holes), and polygons in
45
+ #: one MultiPolygon. Both only ever arrive from an import; the editor authors a
46
+ #: single ring.
47
+ MAX_RINGS = 1_000
48
+ MAX_POLYGONS = 1_000
49
+
50
+ #: The smallest closed ring that encloses anything: three distinct corners plus
51
+ #: the repeated first point.
52
+ MIN_RING_POSITIONS = 4
53
+
54
+
55
+ def validate_geometry(geometry, *, max_vertices=MAX_VERTICES):
56
+ """Return a normalized copy of `geometry`, or raise ValueError.
57
+
58
+ Normalized means: type is one of GEOMETRY_TYPES, every position is a pair of
59
+ finite floats, and every ring is explicitly closed.
60
+ """
61
+ if not isinstance(geometry, dict):
62
+ raise ValueError("geometry must be an object")
63
+
64
+ kind = geometry.get("type")
65
+ if kind not in GEOMETRY_TYPES:
66
+ raise ValueError(
67
+ f"unsupported geometry type {kind!r}: expected one of {list(GEOMETRY_TYPES)}"
68
+ )
69
+
70
+ coordinates = geometry.get("coordinates")
71
+ budget = _Budget(max_vertices)
72
+
73
+ if kind == POLYGON:
74
+ normalized = _polygon(coordinates, budget)
75
+ else:
76
+ if not isinstance(coordinates, list) or not coordinates:
77
+ raise ValueError("MultiPolygon coordinates must be a non-empty list of polygons")
78
+ if len(coordinates) > MAX_POLYGONS:
79
+ raise ValueError(f"MultiPolygon has more than {MAX_POLYGONS} polygons")
80
+ normalized = [_polygon(part, budget) for part in coordinates]
81
+
82
+ return {"type": kind, "coordinates": normalized}
83
+
84
+
85
+ class _Budget:
86
+ """Vertex allowance for one feature, shared across all its rings.
87
+
88
+ Counted for the whole geometry rather than per ring: a thousand rings of a
89
+ hundred points each is the same amount of work as one ring of a hundred
90
+ thousand, and only a per-feature total sees both.
91
+ """
92
+
93
+ def __init__(self, limit):
94
+ self.limit = limit
95
+ self.used = 0
96
+
97
+ def spend(self, count):
98
+ self.used += count
99
+ if self.used > self.limit:
100
+ raise ValueError(f"geometry has more than {self.limit} vertices")
101
+
102
+
103
+ def _polygon(rings, budget):
104
+ if not isinstance(rings, list) or not rings:
105
+ raise ValueError("polygon coordinates must be a non-empty list of rings")
106
+ if len(rings) > MAX_RINGS:
107
+ raise ValueError(f"polygon has more than {MAX_RINGS} rings")
108
+ return [_ring(ring, budget) for ring in rings]
109
+
110
+
111
+ def _ring(ring, budget):
112
+ if not isinstance(ring, list):
113
+ raise ValueError("a ring must be a list of positions")
114
+ budget.spend(len(ring))
115
+
116
+ points = [_position(p) for p in ring]
117
+ points = close_ring(points)
118
+
119
+ if len(points) < MIN_RING_POSITIONS:
120
+ raise ValueError(
121
+ "a ring needs at least three distinct positions "
122
+ f"(got {len(points) - 1 if points else 0})"
123
+ )
124
+ # Three points that happen to be the same point are four positions once
125
+ # closed, and enclose nothing. Counted on the open ring so the deliberate
126
+ # closing duplicate is not mistaken for a degenerate one.
127
+ if len({(x, y) for x, y in points[:-1]}) < 3:
128
+ raise ValueError("a ring needs at least three distinct positions")
129
+ return points
130
+
131
+
132
+ def _position(position):
133
+ if not isinstance(position, (list, tuple)) or len(position) < 2:
134
+ raise ValueError("a position must be a [x, y] pair")
135
+ x, y = position[0], position[1]
136
+ # bool is an int; a JSON `true` reaching a coordinate is a malformed payload
137
+ # rather than the number 1.
138
+ for value in (x, y):
139
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
140
+ raise ValueError(f"coordinate {value!r} is not a number")
141
+ if not math.isfinite(value):
142
+ # NaN and Infinity survive json.loads by default, so this is the
143
+ # check that keeps them out of the store -- and out of every
144
+ # consumer that would otherwise get them back as invalid JSON.
145
+ raise ValueError("coordinates must be finite numbers")
146
+ return [float(x), float(y)]
147
+
148
+
149
+ def close_ring(points):
150
+ """A ring whose last position repeats its first."""
151
+ if not points:
152
+ return points
153
+ if points[0] != points[-1]:
154
+ return [*points, list(points[0])]
155
+ return points
156
+
157
+
158
+ def geometry_bounds(geometry):
159
+ """(min_x, min_y, max_x, max_y) over every position, or None if empty."""
160
+ xs, ys = [], []
161
+ for ring in iter_rings(geometry):
162
+ for x, y in ring:
163
+ xs.append(x)
164
+ ys.append(y)
165
+ if not xs:
166
+ return None
167
+ return (min(xs), min(ys), max(xs), max(ys))
168
+
169
+
170
+ def iter_rings(geometry):
171
+ """Every ring in a Polygon or MultiPolygon, outer and interior alike."""
172
+ coordinates = (geometry or {}).get("coordinates") or []
173
+ if (geometry or {}).get("type") == MULTI_POLYGON:
174
+ for polygon in coordinates:
175
+ yield from polygon
176
+ else:
177
+ yield from coordinates
178
+
179
+
180
+ def vertex_count(geometry):
181
+ return sum(len(ring) for ring in iter_rings(geometry))
@@ -0,0 +1,169 @@
1
+ """Which cells fall inside which regions.
2
+
3
+ The one place the annotation engine meets the cell table, and it stays a thin
4
+ one on purpose: this module answers "for each point, which ROIs contain it" and
5
+ turns that into two label columns. It reads no files and writes none -- the
6
+ callers in `adapters.py` do that -- so the containment rules can be tested
7
+ against hand-built geometry rather than against an .h5ad.
8
+
9
+ Two rules worth stating, because both are choices and neither is recoverable
10
+ from the code at a glance:
11
+
12
+ **A cell in several ROIs belongs to all of them.** Regions overlap on purpose --
13
+ a tumour nest inside a stromal region is not a mistake to be resolved by picking
14
+ one -- so the labels are joined with `_` rather than one winning. Names are
15
+ joined in document order, which is the order the GeoJSON export writes and the
16
+ panel lists, so two exports of the same project agree. Categories are joined the
17
+ same way but deduplicated: a cell inside two Tumor regions is `Tumor`, not
18
+ `Tumor_Tumor`, because the question the category column answers is "what kind of
19
+ place is this cell in".
20
+
21
+ **A cell in a hole is outside.** Interior rings are honoured, which is the whole
22
+ reason this goes through shapely rather than a hand-rolled ray cast: a donut
23
+ region drawn around a necrotic core means the core is not in it.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ #: Between the ROIs a cell falls in. Underscore rather than a comma or a pipe
29
+ #: because these land in a column that gets read back as a category level, and
30
+ #: a separator that survives a CSV round trip without quoting is worth more
31
+ #: here than one that reads well in a sentence.
32
+ JOINER = "_"
33
+
34
+ #: What a cell in no region gets. An empty string rather than a word like
35
+ #: "None" or "Background": those are labels a user might legitimately give a
36
+ #: category, and a value that cannot be told apart from a real answer is worse
37
+ #: than a blank. Callers turn this into NA where the format has one.
38
+ UNASSIGNED = ""
39
+
40
+
41
+ def assign(features, categories, xs, ys):
42
+ """(category_label, roi_name) for each point, in point order.
43
+
44
+ `features` is the ROI list for one image, in document order; `categories`
45
+ is the project's category list. Points are full-resolution image pixels --
46
+ the same space ROI geometry is stored in, so nothing is transformed here.
47
+
48
+ Returns two lists as long as `xs`. A point in no region gets UNASSIGNED in
49
+ both, never a partial answer in one.
50
+ """
51
+ count = len(xs)
52
+ if len(ys) != count:
53
+ raise ValueError("x and y must be the same length")
54
+ names = [UNASSIGNED] * count
55
+ labels = [UNASSIGNED] * count
56
+ if not count or not features:
57
+ return labels, names
58
+
59
+ import numpy as np
60
+ import shapely
61
+ from shapely import geometry as sgeom
62
+
63
+ by_id = {c["id"]: c for c in categories}
64
+ polygons = [_shapely(feature["geometry"], sgeom) for feature in features]
65
+
66
+ # One vectorised containment query rather than a Python loop over cells: a
67
+ # real slide is 10^5-10^6 cells against 10^1-10^3 regions, and the loop
68
+ # version of this is minutes. `within` is strict about the boundary, which
69
+ # is the same predicate a point-in-polygon test would give and is not worth
70
+ # softening -- a centroid exactly on a hand-drawn edge is a coin flip
71
+ # whatever rule is picked.
72
+ tree = shapely.STRtree(polygons)
73
+ points = shapely.points(np.asarray(xs, dtype="float64"),
74
+ np.asarray(ys, dtype="float64"))
75
+ point_index, polygon_index = tree.query(points, predicate="within")
76
+
77
+ # Sorted by polygon index within each point, so the joined order is
78
+ # document order and not whatever order the index happened to return.
79
+ order = np.lexsort((polygon_index, point_index))
80
+ hits = {}
81
+ for position in order:
82
+ hits.setdefault(int(point_index[position]), []).append(int(polygon_index[position]))
83
+
84
+ for row, matched in hits.items():
85
+ matched_names = []
86
+ matched_labels = []
87
+ for index in matched:
88
+ feature = features[index]
89
+ category = by_id.get(feature.get("category_id")) or {}
90
+ matched_names.append(feature.get("name") or "")
91
+ label = category.get("label") or ""
92
+ # Deduplicated in first-seen order. See the module docstring: the
93
+ # category column says what KIND of place a cell is in, and
94
+ # repeating it once per overlapping region says nothing extra.
95
+ if label not in matched_labels:
96
+ matched_labels.append(label)
97
+ names[row] = JOINER.join(matched_names)
98
+ labels[row] = JOINER.join(matched_labels)
99
+
100
+ return labels, names
101
+
102
+
103
+ def current_image_id(dataset):
104
+ """The image-id value this project's cells carry, or None.
105
+
106
+ None is a real answer and not a failure: "this table covers one image"
107
+ (DataSpec.single_image) and "there is no table at all" both land here, and
108
+ in both cases every row the project can see belongs to this image.
109
+
110
+ Deliberately the same rule gating applies in `resolve_current_image_id` --
111
+ the two plugins have to mean the same thing by "which image is this
112
+ project", or the same .h5ad gets gates filed under one name and ROIs under
113
+ another. It is read off the loaded frame rather than the file because the
114
+ frame is already narrowed by the project's registration subset, which is
115
+ exactly the scoping the question needs.
116
+
117
+ Raises ValueError when the column holds more than one value within this
118
+ project's own rows. Refusing to guess is the entire point of asking which
119
+ column it is: writing ROI columns against the wrong image is not a
120
+ cosmetic error, it annotates somebody else's cells.
121
+ """
122
+ if dataset.schema is None or not dataset.table.available:
123
+ return None
124
+ column = dataset.schema.image_id
125
+ if not column:
126
+ return None
127
+
128
+ # The registration subset first, and without reading anything. For an
129
+ # AnnData or SpatialData project narrowed to one image at import, the value
130
+ # the user picked IS the answer -- and it is the only place the answer
131
+ # reliably survives, because the adapter emits a table of its own columns
132
+ # and need not carry the obs column the subset was taken on.
133
+ source = dataset.table.source
134
+ subset = dict((source.subset if source else None) or {})
135
+ if subset.get("column") == column and subset.get("value") is not None:
136
+ return str(subset["value"])
137
+
138
+ frame = dataset.table.frame()
139
+ if frame is None or column not in frame.columns:
140
+ return None
141
+ values = [value for value in frame[column].unique().to_list() if value is not None]
142
+ if not values:
143
+ return None
144
+ if len(values) > 1:
145
+ raise ValueError(
146
+ f"column {column!r} holds {len(values)} different image ids within "
147
+ f"this project's own cells, so Plexora cannot tell which image "
148
+ f"these regions belong to"
149
+ )
150
+ return str(values[0])
151
+
152
+
153
+ def _shapely(geometry, sgeom):
154
+ """A stored geometry as a shapely object, holes and all.
155
+
156
+ The same conversion `adapters.save_to_spatialdata` does. Kept as its own
157
+ copy rather than imported from there because that module is the file-writing
158
+ one and this module deliberately touches no files -- the tests for each
159
+ should be able to run without the other's dependencies.
160
+ """
161
+ if (geometry or {}).get("type") == "MultiPolygon":
162
+ return sgeom.MultiPolygon(
163
+ [_polygon(part, sgeom) for part in geometry["coordinates"]])
164
+ return _polygon((geometry or {}).get("coordinates") or [], sgeom)
165
+
166
+
167
+ def _polygon(rings, sgeom):
168
+ outer, *holes = rings
169
+ return sgeom.Polygon(outer, holes)