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,323 @@
1
+ """The shape of stored ROI state, and what a valid piece of it looks like.
2
+
3
+ One versioned JSON document per datasource holds everything: the categories, the
4
+ features grouped by image, and the revision the conflict check turns on.
5
+
6
+ {
7
+ "schema_version": 1,
8
+ "revision": 42,
9
+ "categories": [ {id, label, color, visible, locked, sort_order}, ... ],
10
+ "images": {
11
+ "default": {
12
+ "coordinate_space": {"type": "image_pixels", "width": W, "height": H},
13
+ "features": [ {id, category_id, name, locked, geometry, ...}, ... ]
14
+ }
15
+ },
16
+ "settings": {"destination": "rois"}
17
+ }
18
+
19
+ Two structural decisions worth stating, because both are cheap now and
20
+ expensive later:
21
+
22
+ **Categories are project-level, features are image-level.** A user who defines
23
+ Tumor/Stroma/Necrosis on one image expects those same choices on the next one;
24
+ what they do not expect is the first image's shapes to follow them there. The
25
+ `images` map is keyed by image identity for that reason, even though a Plexora
26
+ project holds exactly one image today and the only key ever written is
27
+ `"default"`. Every operation carries the key it applies to, so adding a second
28
+ image later adds entries rather than changing the format.
29
+
30
+ **`coordinate_space` is stored, not assumed.** It is what catches the dangerous
31
+ case: the same datasource name now pointing at a different image. Geometry drawn
32
+ on a 92,164 x 76,510 slide looks perfectly plausible on a 101,212 x 84,039 one,
33
+ and nothing about the numbers themselves says otherwise -- so the dimensions
34
+ travel with the annotations and are checked on load.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import re
40
+
41
+ from plexora.plugins.roi.server.geometry import validate_geometry
42
+
43
+ SCHEMA_VERSION = 1
44
+
45
+ #: The image key used while a project holds one image. See the module docstring.
46
+ DEFAULT_IMAGE = "default"
47
+
48
+ #: A project starts with NO categories. The names a pathologist works in are
49
+ #: theirs to choose, and a built-in "Uncategorized" that cannot be renamed or
50
+ #: deleted is a label nobody asked for sitting permanently at the top of their
51
+ #: list -- and one that quietly collects shapes they meant to file properly.
52
+ #: The panel asks for the first category instead: one interaction, once, at the
53
+ #: point where the user already knows what they are about to draw.
54
+ #:
55
+ #: Nothing is special-cased any more, so a document written by an earlier build
56
+ #: keeps whatever "uncategorized" it has and that category behaves like every
57
+ #: other one -- renameable, recolourable, deletable.
58
+ DEFAULT_COLOR = "#8b93a6"
59
+
60
+ #: What a category with a blank name is called, so the list never has a row the
61
+ #: user cannot see or click.
62
+ UNTITLED_LABEL = "Untitled"
63
+
64
+ #: Ids are generated by the client (crypto.randomUUID with a short prefix) so a
65
+ #: shape appears the instant it is drawn rather than after a round trip. They are
66
+ #: validated rather than trusted: they end up in DOM attributes and selectors.
67
+ ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
68
+
69
+ #: Free text the user typed. Length-capped and stripped of control characters
70
+ #: here; rendered with textContent, never innerHTML, on the client.
71
+ MAX_LABEL_LENGTH = 200
72
+ MAX_NOTE_LENGTH = 4_000
73
+ _CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
74
+
75
+ COLOR_PATTERN = re.compile(r"^#[0-9a-fA-F]{6}$")
76
+
77
+ #: Ceilings on one project's annotations. Generous -- these exist to stop a
78
+ #: runaway client or a malformed import, not to tell a pathologist how many
79
+ #: regions their slide has.
80
+ MAX_CATEGORIES = 1_000
81
+ MAX_FEATURES = 100_000
82
+
83
+ FEATURE_FIELDS = (
84
+ "id", "category_id", "name", "locked", "created_at", "updated_at",
85
+ "geometry", "flags", "source_roi_id", "notes",
86
+ )
87
+
88
+
89
+ def default_state(width=None, height=None):
90
+ """The state of a project nobody has annotated yet.
91
+
92
+ Returned rather than written: a project with no ROIs should not acquire a
93
+ stored blob just because somebody opened the panel, and revision 0 is what
94
+ makes the first save from any client a well-formed first write.
95
+
96
+ No categories, deliberately -- see DEFAULT_COLOR above.
97
+ """
98
+ return {
99
+ "schema_version": SCHEMA_VERSION,
100
+ "revision": 0,
101
+ "categories": [],
102
+ "images": {DEFAULT_IMAGE: empty_image(width, height)},
103
+ "settings": default_settings(),
104
+ }
105
+
106
+
107
+ def default_settings():
108
+ """Project-level preferences that are not annotations.
109
+
110
+ One entry so far: `destination`, the name of the place inside the source
111
+ file that this project last exported to -- the key under `uns/plexora` for
112
+ an AnnData, the shapes element for a SpatialData store.
113
+
114
+ Blank means nobody has exported yet, and the panel fills in the default for
115
+ whichever kind of file the project came from. Storing a name here rather
116
+ than a per-kind default means the schema never has to know what the project
117
+ was imported from.
118
+ """
119
+ return {"destination": ""}
120
+
121
+
122
+ def normalize_settings(raw):
123
+ return {"destination": clean_text((raw or {}).get("destination"))}
124
+
125
+
126
+ def element_name(value, default):
127
+ """A name the user typed for a place inside their own file.
128
+
129
+ Both destinations need the same rule and neither can afford a looser one:
130
+ the AnnData key becomes a group under `uns/plexora`, the SpatialData
131
+ element becomes a directory inside the store. A path is not a name.
132
+
133
+ Blank means "whatever you were going to call it" -- somebody pressing save
134
+ with an empty field has not made a mistake -- so it becomes the default
135
+ rather than an error. Stripped first, so that holds for whitespace too.
136
+ """
137
+ value = clean_text(value)
138
+ if not value:
139
+ return default
140
+ if "/" in value or "\\" in value or value.startswith("."):
141
+ raise ValueError(f"invalid name {value!r}: it cannot be a path")
142
+ return value
143
+
144
+
145
+ def placeholder_category():
146
+ """Stand-in for a category a feature names but the document no longer holds.
147
+
148
+ Only ever used on the way OUT -- an export or a native save filling in a
149
+ label and a colour beside each shape -- so that one dangling reference costs
150
+ a blank label rather than a KeyError halfway through writing somebody's
151
+ file. Nothing reads it back in, and it is never added to a project.
152
+ """
153
+ return {
154
+ "id": "",
155
+ "label": "",
156
+ "color": DEFAULT_COLOR,
157
+ "visible": True,
158
+ "locked": False,
159
+ "sort_order": 0,
160
+ }
161
+
162
+
163
+ def empty_image(width=None, height=None):
164
+ return {"coordinate_space": coordinate_space(width, height), "features": []}
165
+
166
+
167
+ def coordinate_space(width=None, height=None):
168
+ """How to read the numbers in a geometry.
169
+
170
+ Spelled out rather than implied because "image pixels" is one of several
171
+ things a viewer could plausibly have stored -- screen pixels, viewport
172
+ fractions, coordinates at whatever pyramid level was on screen -- and a
173
+ consumer reading this file in five years has no way to tell which.
174
+ """
175
+ return {
176
+ "type": "image_pixels",
177
+ "width": int(width) if width else None,
178
+ "height": int(height) if height else None,
179
+ "origin": "top-left",
180
+ "axis_order": "xy",
181
+ }
182
+
183
+
184
+ def normalize_state(raw, width=None, height=None):
185
+ """Coerce a loaded document into the current shape.
186
+
187
+ Tolerant on the way in and strict on the way out: a document written by an
188
+ older build is upgraded, and anything unrecognisable is replaced rather than
189
+ allowed to half-load. Losing annotations to a corrupt blob is bad; drawing a
190
+ project's regions from a half-parsed one is worse, because it looks fine.
191
+ """
192
+ if not isinstance(raw, dict):
193
+ return default_state(width, height)
194
+
195
+ version = raw.get("schema_version")
196
+ if version is not None and not isinstance(version, bool) and isinstance(version, int):
197
+ if version > SCHEMA_VERSION:
198
+ raise ValueError(
199
+ f"these ROI annotations were written by a newer version of Plexora "
200
+ f"(schema {version}, this build reads {SCHEMA_VERSION})"
201
+ )
202
+
203
+ categories = [normalize_category(c) for c in raw.get("categories") or []
204
+ if isinstance(c, dict) and c.get("id")]
205
+
206
+ images = {}
207
+ for key, entry in (raw.get("images") or {}).items():
208
+ if not isinstance(entry, dict):
209
+ continue
210
+ stored = entry.get("coordinate_space") or {}
211
+ images[str(key)] = {
212
+ "coordinate_space": {
213
+ **coordinate_space(stored.get("width"), stored.get("height")),
214
+ **{k: v for k, v in stored.items() if k in ("type", "origin", "axis_order")},
215
+ },
216
+ "features": [normalize_feature(f) for f in entry.get("features") or []
217
+ if isinstance(f, dict) and f.get("id")],
218
+ }
219
+ if DEFAULT_IMAGE not in images:
220
+ images[DEFAULT_IMAGE] = empty_image(width, height)
221
+
222
+ revision = raw.get("revision")
223
+ return {
224
+ "schema_version": SCHEMA_VERSION,
225
+ "revision": int(revision) if isinstance(revision, int) and not isinstance(revision, bool) else 0,
226
+ "categories": categories,
227
+ "images": images,
228
+ "settings": normalize_settings(raw.get("settings")),
229
+ }
230
+
231
+
232
+ def normalize_category(raw):
233
+ """One category, with every field present and of the right type."""
234
+ return {
235
+ "id": validate_id(raw.get("id"), "category id"),
236
+ "label": clean_text(raw.get("label")) or UNTITLED_LABEL,
237
+ "color": color(raw.get("color")),
238
+ "visible": bool(raw.get("visible", True)),
239
+ "locked": bool(raw.get("locked", False)),
240
+ "sort_order": as_int(raw.get("sort_order"), 0),
241
+ }
242
+
243
+
244
+ def normalize_feature(raw):
245
+ """One ROI, with its geometry validated and unknown fields dropped."""
246
+ feature = {
247
+ "id": validate_id(raw.get("id"), "ROI id"),
248
+ # Required, with nothing to fall back to: there is no catch-all category
249
+ # to file a shape under any more, and inventing one here would put it
250
+ # somewhere the user never chose.
251
+ "category_id": validate_id(raw.get("category_id"), "category id"),
252
+ "name": clean_text(raw.get("name")),
253
+ "locked": bool(raw.get("locked", False)),
254
+ "created_at": clean_text(raw.get("created_at")) or None,
255
+ "updated_at": clean_text(raw.get("updated_at")) or None,
256
+ "geometry": validate_geometry(raw.get("geometry")),
257
+ "flags": normalize_flags(raw.get("flags")),
258
+ "source_roi_id": clean_text(raw.get("source_roi_id")) or None,
259
+ }
260
+ notes = clean_text(raw.get("notes"), MAX_NOTE_LENGTH)
261
+ if notes:
262
+ feature["notes"] = notes
263
+ return feature
264
+
265
+
266
+ def normalize_flags(raw):
267
+ """Advisory marks the editor put on a shape.
268
+
269
+ `self_intersecting` is computed on the client, where the shape is being
270
+ drawn, and recorded rather than acted on -- the server neither verifies nor
271
+ repairs it. It exists so the outline can be drawn differently and the user
272
+ can see there is something to look at.
273
+ """
274
+ raw = raw if isinstance(raw, dict) else {}
275
+ return {"self_intersecting": bool(raw.get("self_intersecting", False))}
276
+
277
+
278
+ def validate_id(value, what):
279
+ if not isinstance(value, str) or not ID_PATTERN.match(value):
280
+ raise ValueError(f"invalid {what}: {value!r}")
281
+ return value
282
+
283
+
284
+ def clean_text(value, limit=MAX_LABEL_LENGTH):
285
+ """User-typed text, made safe to store and hand back.
286
+
287
+ Control characters go (they serve no purpose in a label and make a mess of
288
+ every downstream format), and length is capped. Nothing is HTML-escaped
289
+ here: escaping at the store would double-escape on every round trip, and the
290
+ place that has to be careful is the one that puts it in the DOM.
291
+ """
292
+ if value is None:
293
+ return ""
294
+ if not isinstance(value, str):
295
+ value = str(value)
296
+ return _CONTROL_CHARS.sub("", value).strip()[:limit]
297
+
298
+
299
+ def color(value):
300
+ if isinstance(value, str) and COLOR_PATTERN.match(value):
301
+ return value.lower()
302
+ return DEFAULT_COLOR
303
+
304
+
305
+ def as_int(value, fallback):
306
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
307
+ return fallback
308
+ return int(value)
309
+
310
+
311
+ def image_entry(state, image_key):
312
+ """The entry for one image, or a ValueError naming what was asked for."""
313
+ entry = (state.get("images") or {}).get(image_key)
314
+ if entry is None:
315
+ raise ValueError(f"unknown image {image_key!r}")
316
+ return entry
317
+
318
+
319
+ def find(items, item_id):
320
+ for index, item in enumerate(items):
321
+ if item.get("id") == item_id:
322
+ return index, item
323
+ return -1, None