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,142 @@
1
+ """Raw channel pixels for Quick Edit, read straight off the pyramid.
2
+
3
+ Quick Edit shows a small live view of the image behind a figure panel so the
4
+ user can reframe it and adjust its channels without leaving the canvas. It
5
+ needs actual pixels, and there were two ways to get them.
6
+
7
+ **Not the main viewer's routes.** Those go through `data_model`, which holds ONE
8
+ loaded datasource behind a lock and loads the whole cell table, the segmentation
9
+ mask and the channel metadata with it ("this can take some time" is in its own
10
+ docstring). Quick Edit would pay that per panel, evict whatever the user had
11
+ open, and pay it again when they went back to the viewer -- and a figure
12
+ legitimately spans several images, so a four-panel figure would do it four
13
+ times. Export already refuses to touch that module for exactly this reason, and
14
+ `test_the_export_never_loads_a_datasource` pins it.
15
+
16
+ **So: the same reader export uses.** `render.SourceImage` opens the TIFF
17
+ directly, per request, and this hands back one channel of one region as a plain
18
+ array of numbers.
19
+
20
+ ## One channel at a time, and no compositing
21
+
22
+ The response is a single channel's greyscale, uint16 little-endian, and the
23
+ browser composites. That is the whole performance argument for the feature:
24
+ changing a channel's colour or dragging its contrast slider is then arithmetic
25
+ over bytes already in memory and costs NO network at all. A route that returned
26
+ a composited RGB would make every contrast tweak a round trip, which is exactly
27
+ the interaction Quick Edit exists to make cheap. MiniMap already works this way.
28
+
29
+ Sixteen bits rather than eight because the windows are chosen in raw units: an
30
+ 8-bit response would have to be pre-windowed, and then the slider could only
31
+ move within whatever window the server happened to pick.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import numpy as np
37
+
38
+ from plexora.plugins.figure_builder.server.render import (
39
+ MAX_SOURCE_PIXELS, RenderError, SourceImage, choose_level)
40
+
41
+ #: Biggest region a single request may hand back, per side. The mini viewer is
42
+ #: a few hundred pixels across; this is well past what it asks for and well
43
+ #: short of what would make a response worth megabytes.
44
+ MAX_OUT_PIXELS = 1024
45
+
46
+ #: How many pixels the coarsest level is sampled down to when computing the
47
+ #: intensity summary. Enough for a stable percentile, small enough to be
48
+ #: instant on a whole slide.
49
+ STATS_SAMPLE = 512
50
+
51
+
52
+ def read_region(datasource, channel_key, box, out_size):
53
+ """One channel of one region, resampled to `out_size`.
54
+
55
+ `box` is (x, y, w, h) in FULL-RESOLUTION image pixels -- the same
56
+ coordinate system a panel's `scene.viewport` is in, so a caller never has
57
+ to know which pyramid level was actually read.
58
+
59
+ Returns `(array, clipped_box)`: a 2-D uint16 array of exactly `out_size`,
60
+ and the part of `box` that was inside the image. The clipped box is
61
+ returned rather than silently applied, because a region that ran off the
62
+ edge of the slide has to be drawn in the right PLACE, and a caller given
63
+ only the pixels would centre the wrong thing.
64
+ """
65
+ out_w, out_h = int(out_size[0]), int(out_size[1])
66
+ if out_w < 1 or out_h < 1:
67
+ raise RenderError("a region must be at least one pixel")
68
+ if out_w > MAX_OUT_PIXELS or out_h > MAX_OUT_PIXELS:
69
+ raise RenderError(
70
+ f"a region of {out_w}x{out_h} is past what one read returns "
71
+ f"(max {MAX_OUT_PIXELS} a side)")
72
+
73
+ with SourceImage(datasource) as source:
74
+ index = source.channel_index(channel_key)
75
+ if index is None:
76
+ raise RenderError(f"{channel_key!r} is not a channel of this image")
77
+
78
+ level = choose_level(source, box[2], out_w)
79
+ divisor = 2 ** level
80
+ plane, clipped = source.read(index, level, (
81
+ box[0] / divisor, box[1] / divisor,
82
+ (box[0] + box[2]) / divisor, (box[1] + box[3]) / divisor))
83
+
84
+ return _resample(plane, out_w, out_h), tuple(value * divisor for value in clipped)
85
+
86
+
87
+ def _resample(plane, out_w, out_h):
88
+ """To exactly the requested size, in uint16.
89
+
90
+ Pillow rather than a stride trick: LANCZOS is what the exporter uses, and a
91
+ mini viewer that resampled differently from the export would show the user
92
+ a slightly different picture from the one they are composing.
93
+ """
94
+ from PIL import Image
95
+
96
+ array = np.asarray(plane)
97
+ if array.size == 0:
98
+ return np.zeros((out_h, out_w), dtype=np.uint16)
99
+ # Pillow's "I;16" mode cannot resize, so the arithmetic is done in float32
100
+ # and put back afterwards -- which is also what keeps a 16-bit source from
101
+ # being quantised to 8 bits on the way through.
102
+ image = Image.fromarray(array.astype(np.float32), mode="F")
103
+ if image.size != (out_w, out_h):
104
+ image = image.resize((out_w, out_h), Image.LANCZOS)
105
+ return np.clip(np.asarray(image), 0, 65535).astype(np.uint16)
106
+
107
+
108
+ def channel_stats(datasource, channel_key):
109
+ """What a contrast slider needs to know about a channel.
110
+
111
+ Read from the COARSEST pyramid level: the percentiles of a whole slide are
112
+ the same to within noise at any level, and reading level 0 to draw a slider
113
+ would be a gigabyte for a number the user is about to drag past anyway.
114
+ """
115
+ with SourceImage(datasource) as source:
116
+ index = source.channel_index(channel_key)
117
+ if index is None:
118
+ raise RenderError(f"{channel_key!r} is not a channel of this image")
119
+
120
+ level = max(0, source.levels - 1)
121
+ array = source.level(level)
122
+ plane = array[index] if array.ndim == 3 else array
123
+ height, width = plane.shape[-2], plane.shape[-1]
124
+ if height * width > MAX_SOURCE_PIXELS:
125
+ raise RenderError("this image has no level small enough to summarise")
126
+ sample = np.asarray(plane)
127
+
128
+ step = max(1, int(max(sample.shape) / STATS_SAMPLE))
129
+ sample = sample[::step, ::step].astype(np.float32)
130
+ if sample.size == 0:
131
+ return {"min": 0, "max": 1, "p01": 0, "p999": 1, "dtype": "uint16"}
132
+
133
+ low, high = np.percentile(sample, [1.0, 99.9])
134
+ return {
135
+ "min": float(sample.min()),
136
+ "max": float(sample.max()),
137
+ # The default window the viewer's auto-level lands on, so a channel
138
+ # switched on in Quick Edit arrives looking the way it would there.
139
+ "p01": float(low),
140
+ "p999": float(high),
141
+ "dtype": str(np.asarray(sample).dtype),
142
+ }
@@ -0,0 +1,174 @@
1
+ """How a figure explains itself.
2
+
3
+ Two outputs from one function, because they are the same facts for two readers:
4
+ `manifest()` is the machine-readable record that ships beside the export, and
5
+ `lines()` is the page appended to a PDF for the human holding the printout.
6
+
7
+ The thing worth getting right is what counts as provenance. Not "this figure was
8
+ made in Plexora" -- that explains nothing. What a reader needs in order to
9
+ believe, or to reproduce, a panel:
10
+
11
+ * which image, and which REGION of it, in the image's own coordinates;
12
+ * how big that region is in microns, or an explicit statement that the scale is
13
+ unknown;
14
+ * which channels, with the display window and colour each was drawn with;
15
+ * which plugin drew which overlay, and at what version;
16
+ * what the export could NOT reproduce, named rather than omitted.
17
+
18
+ That last one is the point of this file existing rather than the export just
19
+ writing a title page. An export that silently leaves out the phenotype colouring
20
+ a figure was made to show is an export that misrepresents the figure, and the
21
+ only defence against that is to say so in writing, on the artefact itself.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from datetime import datetime, timezone
27
+
28
+ from plexora.plugins.figure_builder.server import compose
29
+
30
+
31
+ def manifest(document, results, options):
32
+ """The machine-readable record: `figure-provenance.json`.
33
+
34
+ Deliberately holds the scene snapshots verbatim. A manifest that summarised
35
+ them would be a second description of the same thing, and the summary is the
36
+ one that goes stale.
37
+ """
38
+ return {
39
+ "manifest_version": 1,
40
+ "generated_at": _now(),
41
+ "figure": {
42
+ "figure_id": document["figure_id"],
43
+ "title": document["title"],
44
+ "revision": document["revision"],
45
+ "created_at": document["created_at"],
46
+ "updated_at": document["updated_at"],
47
+ },
48
+ "export": {
49
+ "format": options.get("format"),
50
+ "dpi": options.get("dpi"),
51
+ "pages": [page["page_id"] for page in document["pages"]],
52
+ },
53
+ "sources": [
54
+ {
55
+ "source_id": source["source_id"],
56
+ "kind": source["kind"],
57
+ "datasource": source["datasource"],
58
+ "image": source["image"],
59
+ "pixel_size": source["pixel_size"],
60
+ "fingerprint": source["fingerprint"],
61
+ }
62
+ for source in document["sources"].values()
63
+ ],
64
+ "panels": [
65
+ {
66
+ "panel_id": panel["panel_id"],
67
+ "label": result.get("label"),
68
+ "source_id": panel["source_id"],
69
+ "placement": panel["placement"],
70
+ "scene": panel["scene"],
71
+ "derived_from": panel["derived_from"],
72
+ "render": result,
73
+ }
74
+ for panel, result in results
75
+ ],
76
+ }
77
+
78
+
79
+ def lines(document, results, options):
80
+ """The provenance page, as lines of text.
81
+
82
+ Text rather than a layout because it is appended as a plain page: a table
83
+ that has to fit is a table that eventually does not, and a figure's
84
+ provenance must never be the thing that fails to export.
85
+ """
86
+ out = [
87
+ document["title"],
88
+ "",
89
+ f"Exported {_now()} · revision {document['revision']} · "
90
+ f"{options.get('format', 'pdf').upper()} at {options.get('dpi')} DPI",
91
+ "",
92
+ "SOURCES",
93
+ ]
94
+ for source in document["sources"].values():
95
+ name = source["display_name"] or source["datasource"] or source["source_id"]
96
+ size = source["image"]
97
+ scale = (f"{source['pixel_size']['value']:g} {source['pixel_size']['unit']}/px "
98
+ f"({source['pixel_size']['source']})"
99
+ if source["pixel_size"] else "scale information unavailable")
100
+ out.append(f" {name} — {size['width']} × {size['height']} px — {scale}")
101
+ if not document["sources"]:
102
+ out.append(" (none)")
103
+
104
+ out += ["", "PANELS"]
105
+ for panel, result in results:
106
+ out.extend(_panel_lines(document, panel, result))
107
+
108
+ limitations = _limitations(results)
109
+ if limitations:
110
+ out += ["", "NOT REPRODUCED IN THIS EXPORT"]
111
+ out += [f" {line}" for line in limitations]
112
+ out += [
113
+ "",
114
+ " These are stated rather than omitted. The panels above were",
115
+ " re-rendered from the source image at the resolution requested;",
116
+ " anything listed here was drawn by a tool whose data a figure does",
117
+ " not store, and appears only in the on-screen preview.",
118
+ ]
119
+ return out
120
+
121
+
122
+ def _panel_lines(document, panel, result):
123
+ source = document["sources"].get(panel["source_id"], {})
124
+ viewport = panel["scene"]["viewport"]
125
+ label = result.get("label") or panel["panel_id"]
126
+ name = source.get("display_name") or source.get("datasource") or "unknown source"
127
+
128
+ out = [f" {label} — {name}",
129
+ f" region x {viewport['x']:.1f} y {viewport['y']:.1f} "
130
+ f"w {viewport['w']:.1f} h {viewport['h']:.1f} (full-resolution image pixels)"]
131
+
132
+ pixel_size = source.get("pixel_size")
133
+ if pixel_size:
134
+ across = viewport["w"] * pixel_size["value"]
135
+ down = viewport["h"] * pixel_size["value"]
136
+ out.append(f" field {compose.format_microns(across)} × "
137
+ f"{compose.format_microns(down)}")
138
+ else:
139
+ out.append(" field scale information unavailable")
140
+
141
+ if result.get("effective_dpi"):
142
+ out.append(f" source detail {result['effective_dpi']:g} DPI at this size"
143
+ + (f" (requested {result['requested_dpi']})"
144
+ if result.get("requested_dpi") else ""))
145
+
146
+ for channel in panel["scene"]["channels"]:
147
+ colour = channel["color"]
148
+ out.append(
149
+ f" channel {channel['fullname_at_capture'] or channel['key']}"
150
+ f" window {channel['window'][0]:g}–{channel['window'][1]:g}"
151
+ f" rgb({colour['r']},{colour['g']},{colour['b']})")
152
+
153
+ for name, contribution in panel["scene"]["plugins"].items():
154
+ out.append(f" overlay {name} {contribution['version']}")
155
+ return out
156
+
157
+
158
+ def _limitations(results):
159
+ channels = set()
160
+ overlays = set()
161
+ for _, result in results:
162
+ channels.update(result.get("missing_channels") or [])
163
+ overlays.update(result.get("missing_overlays") or [])
164
+ out = []
165
+ if channels:
166
+ out.append("channels no longer present in the source: " + ", ".join(sorted(channels)))
167
+ if overlays:
168
+ out.append("overlays drawn by a tool rather than by the image: "
169
+ + ", ".join(sorted(overlays)))
170
+ return out
171
+
172
+
173
+ def _now():
174
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
@@ -0,0 +1,278 @@
1
+ """Re-rendering a captured panel from the source pixels.
2
+
3
+ This is the half of the product claim that the preview raster is not. A panel
4
+ captured at 300 screen pixels exports at whatever the page size and the DPI ask
5
+ for -- two thousand, four thousand -- because the scene records the region in
6
+ full-resolution image coordinates and the windows in raw units, and both survive
7
+ the screen they were chosen on.
8
+
9
+ **Nothing here goes through `data_model`.** That module holds ONE loaded
10
+ datasource behind a lock, and it is the one the user is looking at; a render
11
+ that called `load_datasource` would evict their session to draw a figure, and a
12
+ figure spanning four images would evict it four times. Reading the file directly
13
+ is what makes exporting a multi-image figure possible at all.
14
+
15
+ ## The compositing, and why it looks like this
16
+
17
+ It is the numpy transcription of `client/src/shaders/frag.glsl` plus the
18
+ `lighter` blend `viewerManager.js` composites channels with. Per channel:
19
+
20
+ t = clip((raw - lo) / (hi - lo), 0, 1) # the shader's range_clamp
21
+ rgb = colour * t * ALPHA # u_tile_color * pixel_val
22
+ accumulate, then clip # canvas "lighter"
23
+
24
+ Written out rather than approximated because an export that does not match what
25
+ the user was looking at is worse than no export: they chose those windows by
26
+ eye, against that arithmetic.
27
+
28
+ ## What this does NOT re-render
29
+
30
+ Overlays -- coloured cells, ROI outlines -- are not reproduced. They cannot be,
31
+ from what a figure stores: a cell layer's colours are a lookup table over every
32
+ cell in the image, and a figure deliberately holds no derived data of that size.
33
+ Reproducing them would mean re-running the plugin that computed them, server
34
+ side, which is a different feature.
35
+
36
+ So an export reports them, per panel, instead of quietly dropping them. See
37
+ `missing_overlays`. The microscopy image itself is fully re-rendered, which is
38
+ the part that could not be recovered any other way.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import math
44
+
45
+ import numpy as np
46
+
47
+ from plexora import api
48
+
49
+ #: The alpha every channel is drawn with, from frag.glsl's `u8_r_range(0.9)` /
50
+ #: `u16_rg_range(0.9)`. Not a style choice here -- it is what the user was
51
+ #: looking at when they chose their windows.
52
+ CHANNEL_ALPHA = 0.9
53
+
54
+ #: Ceiling on one rendered panel, in pixels per side. A 300 mm page at 1200 DPI
55
+ #: is ~14,000 px; past this a single panel is gigabytes of float32 and the
56
+ #: request is a mistake rather than a figure.
57
+ MAX_PANEL_PIXELS = 16_000
58
+
59
+ #: Most source pixels one panel may read before it is refused. A whole-slide
60
+ #: overview at level 0 is 10^10 pixels; the level chooser normally avoids that,
61
+ #: and this catches the cases where it cannot.
62
+ MAX_SOURCE_PIXELS = 120_000_000
63
+
64
+
65
+ class RenderError(Exception):
66
+ """This panel cannot be rendered, with a reason worth showing the user."""
67
+
68
+
69
+ class SourceImage:
70
+ """One image file, opened once and read from many times.
71
+
72
+ Held open across a whole export rather than reopened per panel: a figure is
73
+ routinely eight panels from one slide, and reopening a pyramidal TIFF eight
74
+ times is eight directory walks for the same answer.
75
+ """
76
+
77
+ def __init__(self, datasource):
78
+ import tifffile
79
+ import zarr
80
+
81
+ dataset = api.dataset(datasource)
82
+ source = dataset.image.source
83
+ if source is None or not source.path:
84
+ raise RenderError(f"{datasource} has no image file on disk")
85
+
86
+ self.datasource = datasource
87
+ self.channels = list(dataset.image.channels)
88
+ self._file = tifffile.TiffFile(source.path, is_ome=False)
89
+ self._zarr = zarr.open(self._file.series[0].aszarr(), mode="r")
90
+ self._is_array = hasattr(self._zarr, "shape")
91
+ self.levels = 1 if self._is_array else len(list(self._zarr))
92
+ width, height = dataset.image.size
93
+ self.width = int(width or 0)
94
+ self.height = int(height or 0)
95
+
96
+ def close(self):
97
+ try:
98
+ self._file.close()
99
+ except Exception:
100
+ pass
101
+
102
+ def __enter__(self):
103
+ return self
104
+
105
+ def __exit__(self, *exc):
106
+ self.close()
107
+
108
+ def level(self, index):
109
+ """One pyramid level as an array-like, level 0 being full resolution."""
110
+ if self._is_array:
111
+ return self._zarr
112
+ return self._zarr[str(index)]
113
+
114
+ def channel_index(self, key):
115
+ """Where a channel sits in the pyramid, from its stable URL key.
116
+
117
+ The pyramid holds only real image channels -- 'Area' is a Plexora-side
118
+ placeholder for a segmentation mask and was never part of the file -- so
119
+ the index is the position among those, which is exactly what
120
+ `ImageHandle.channels` gives.
121
+ """
122
+ for index, channel in enumerate(self.channels):
123
+ src = str(channel.get("src") or "").rstrip("/")
124
+ if src.rsplit("/", 1)[-1] == key:
125
+ return index
126
+ return None
127
+
128
+ def read(self, channel_index, level, box):
129
+ """A rectangle of one channel, at one level, as a 2-D array.
130
+
131
+ `box` is in THAT LEVEL's pixels. Clipped to the array rather than
132
+ refused: a capture that runs a few pixels off the edge of the slide is
133
+ an ordinary thing to have drawn, and the result is the region that
134
+ exists with black where the image does not.
135
+ """
136
+ array = self.level(level)
137
+ plane = array[channel_index] if array.ndim == 3 else array
138
+ height, width = plane.shape[-2], plane.shape[-1]
139
+
140
+ x0 = max(0, min(int(math.floor(box[0])), width))
141
+ y0 = max(0, min(int(math.floor(box[1])), height))
142
+ x1 = max(x0, min(int(math.ceil(box[2])), width))
143
+ y1 = max(y0, min(int(math.ceil(box[3])), height))
144
+ if x1 <= x0 or y1 <= y0:
145
+ return np.zeros((1, 1), dtype=np.float32), (x0, y0, x1, y1)
146
+ if (x1 - x0) * (y1 - y0) > MAX_SOURCE_PIXELS:
147
+ raise RenderError(
148
+ "this panel covers more of the image than one render can read; "
149
+ "export it at a lower DPI"
150
+ )
151
+ return np.asarray(plane[y0:y1, x0:x1]), (x0, y0, x1, y1)
152
+
153
+
154
+ def choose_level(source, viewport_width, target_pixels):
155
+ """The cheapest pyramid level that still has the detail being asked for.
156
+
157
+ The largest level index whose pixels across the region still meet or exceed
158
+ the target, so a 400-pixel-wide panel of a whole slide reads a few hundred
159
+ kilobytes instead of the level-0 gigabyte that would be downsampled away.
160
+ Level 0 when nothing else is big enough, which is also when the warning
161
+ below applies.
162
+ """
163
+ best = 0
164
+ for level in range(max(1, source.levels)):
165
+ if viewport_width / (2 ** level) >= target_pixels:
166
+ best = level
167
+ else:
168
+ break
169
+ return best
170
+
171
+
172
+ def effective_dpi(viewport_width_px, width_mm):
173
+ """How many dots per inch this panel's SOURCE can actually supply.
174
+
175
+ Reported rather than enforced: a panel below the threshold still exports,
176
+ because a reviewer's deadline is a real thing and a slightly soft inset is
177
+ usually the right trade. Silently upscaling and saying nothing is not.
178
+ """
179
+ if width_mm <= 0:
180
+ return 0.0
181
+ return viewport_width_px / (width_mm / 25.4)
182
+
183
+
184
+ def render_panel(source, scene, target_width, target_height):
185
+ """One panel's image, as an (H, W, 3) uint8 array.
186
+
187
+ `target_width`/`target_height` are the pixels the page asks for at the
188
+ chosen DPI. The level is picked from the width; the result is resampled to
189
+ exactly the requested size, so the page composition never has to reason
190
+ about what the pyramid happened to hold.
191
+ """
192
+ from PIL import Image
193
+
194
+ if max(target_width, target_height) > MAX_PANEL_PIXELS:
195
+ raise RenderError(
196
+ f"a panel of {target_width}x{target_height} pixels is past what one "
197
+ f"render can produce (max {MAX_PANEL_PIXELS} a side)")
198
+
199
+ viewport = scene["viewport"]
200
+ level = choose_level(source, viewport["w"], target_width)
201
+ divisor = 2 ** level
202
+ box = (viewport["x"] / divisor, viewport["y"] / divisor,
203
+ (viewport["x"] + viewport["w"]) / divisor,
204
+ (viewport["y"] + viewport["h"]) / divisor)
205
+
206
+ accumulator = None
207
+ rendered = 0
208
+ for channel in scene.get("channels") or []:
209
+ if not channel.get("visible", True):
210
+ continue
211
+ index = source.channel_index(channel["key"])
212
+ if index is None:
213
+ # Reported by the caller through `missing_channels`; nothing is
214
+ # substituted, because a substituted channel produces a panel that
215
+ # looks right and is wrong.
216
+ continue
217
+ plane, _ = source.read(index, level, box)
218
+ if accumulator is None:
219
+ accumulator = np.zeros((plane.shape[0], plane.shape[1], 3), dtype=np.float32)
220
+ elif plane.shape != accumulator.shape[:2]:
221
+ # Levels of a pyramid can be off by a pixel against each other.
222
+ plane = plane[:accumulator.shape[0], :accumulator.shape[1]]
223
+
224
+ low, high = float(channel["window"][0]), float(channel["window"][1])
225
+ span = high - low
226
+ if span <= 0:
227
+ continue
228
+ # The shader's range_clamp, in raw units -- identical arithmetic to
229
+ # dividing both sides by 65535 first, and without the rounding.
230
+ scaled = np.clip((plane.astype(np.float32) - low) / span, 0.0, 1.0)
231
+ colour = channel["color"]
232
+ weight = CHANNEL_ALPHA / 255.0
233
+ for offset, key in enumerate(("r", "g", "b")):
234
+ value = float(colour[key])
235
+ if value:
236
+ accumulator[:scaled.shape[0], :scaled.shape[1], offset] += scaled * (value * weight)
237
+ rendered += 1
238
+
239
+ if accumulator is None:
240
+ # Every channel was gone, or the panel had none. Black, which is what
241
+ # the viewer shows for the same state, rather than an error: the panel's
242
+ # labels and scale bar are still worth exporting.
243
+ accumulator = np.zeros((max(1, target_height), max(1, target_width), 3),
244
+ dtype=np.float32)
245
+
246
+ image = Image.fromarray((np.clip(accumulator, 0.0, 1.0) * 255.0).astype(np.uint8), "RGB")
247
+ if image.size != (target_width, target_height):
248
+ # LANCZOS both ways. Downsampling a bright-on-black fluorescence image
249
+ # with a box filter loses thin structures; upsampling with nearest
250
+ # produces the blocky look that gives "exported from a screenshot" away.
251
+ image = image.resize((max(1, target_width), max(1, target_height)), Image.LANCZOS)
252
+ return image, {"level": level, "channels_rendered": rendered}
253
+
254
+
255
+ def panel_report(source, scene, width_mm, dpi):
256
+ """What is worth telling the user about this panel before they export it.
257
+
258
+ Computed separately from the render so the export dialog can show it without
259
+ reading a single pixel.
260
+ """
261
+ viewport = scene["viewport"]
262
+ missing = [channel.get("fullname_at_capture") or channel["key"]
263
+ for channel in scene.get("channels") or []
264
+ if source.channel_index(channel["key"]) is None]
265
+ overlays = []
266
+ for layer in (scene.get("core_overlays") or {}).get("cell_layers") or []:
267
+ if layer.get("visible") and layer.get("mode") not in (None, "", "none"):
268
+ overlays.append(layer["name"])
269
+ overlays.extend(name for name in (scene.get("plugins") or {}) if name not in overlays)
270
+
271
+ return {
272
+ "effective_dpi": round(effective_dpi(viewport["w"], width_mm), 1),
273
+ "requested_dpi": dpi,
274
+ "missing_channels": missing,
275
+ # Named, not silently dropped: an export that omits the phenotype
276
+ # colouring a figure was made to show has to say so.
277
+ "missing_overlays": sorted(set(overlays)),
278
+ }