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,320 @@
1
+ """Gating's query and persistence logic.
2
+
3
+ Reaches the host only through `plexora.api`. Everything it needs -- the
4
+ feature table, the role->column map, per-plugin storage -- arrives as handles,
5
+ so this module names no core internals and holds no core state.
6
+ """
7
+
8
+ import pickle
9
+
10
+ import numpy as np
11
+ import polars as pl
12
+ from scipy.stats import norm
13
+ from sklearn.mixture import GaussianMixture
14
+
15
+ from plexora import api
16
+ from plexora.plugins.gating.server.database import LEGACY_STATE_TABLE
17
+
18
+ #: Identifies this plugin's storage namespace. Must stay 'gating' -- it is what
19
+ #: `plugin_gating_state` is keyed on, and changing it would strand saved gates.
20
+ PLUGIN_NAME = "gating"
21
+
22
+
23
+ def _store(datasource_name):
24
+ """Gating's slice of this datasource's database.
25
+
26
+ `legacy_state_table` points at the un-namespaced `gatinglist` table earlier
27
+ builds wrote directly. Reads fall back to it when the namespaced table is
28
+ empty, so upgrading the host does not lose a user's saved gates; writes
29
+ always go to the namespaced table, so the old one is left frozen rather
30
+ than kept in sync.
31
+ """
32
+ return api.store(datasource_name, PLUGIN_NAME, legacy_state_table=LEGACY_STATE_TABLE)
33
+
34
+
35
+ def get_gated_cells(datasource_name, gates, start_keys):
36
+ table = api.dataset(datasource_name).table
37
+ if not gates:
38
+ return []
39
+ id_key = start_keys[0]
40
+ values = table.frame()[id_key].to_numpy()[table.range_mask(gates)].tolist()
41
+ return [{id_key: v} for v in values]
42
+
43
+
44
+ def download_gating_csv(datasource_name, gates, channels, selection_ids, encoding):
45
+ dataset = api.dataset(datasource_name)
46
+ df = dataset.table.frame()
47
+
48
+ csv = df
49
+ # The role, not a literal column name -- schema.cell_id is whatever the
50
+ # project recorded. No fallback: this plugin declares cell_id in its
51
+ # Requires, so core collects it before the tool can open at all, and a
52
+ # literal default here would only mask a project that slipped through.
53
+ idField = dataset.schema.cell_id
54
+
55
+ if selection_ids:
56
+ datasource_filter = df.filter(pl.col(idField).is_in(selection_ids))
57
+ else:
58
+ datasource_filter = df
59
+
60
+ expr = None
61
+ for key, value in gates.items():
62
+ cond = (pl.col(key) > value[0]) & (pl.col(key) < value[1])
63
+ expr = cond if expr is None else (expr & cond)
64
+ if expr is not None:
65
+ ids = datasource_filter.filter(expr)['id'].to_numpy()
66
+ else:
67
+ # No gates set: no filter, nothing gated in. (pandas' .query('')
68
+ # used to raise ValueError here -- fixed rather than preserved.)
69
+ ids = np.array([], dtype=np.int64)
70
+
71
+ if 'Area' in channels:
72
+ del channels['Area']
73
+ is_in_ids = pl.col('id').is_in(ids)
74
+ for channel in channels:
75
+ if channel in gates:
76
+ # Cast to the original column's dtype for CSV-text parity with
77
+ # the pandas version: csv.loc[mask, channel] = 1 silently
78
+ # upcast an int literal into what's typically a float64 marker
79
+ # column (rendering "1.0"), whereas a bare Polars int literal
80
+ # would render "1" -- a real text diff in the exported CSV.
81
+ dtype = csv.schema[channel]
82
+ if encoding == 'binary':
83
+ value_expr = pl.when(is_in_ids).then(pl.lit(1)).otherwise(pl.lit(0)).cast(dtype)
84
+ else:
85
+ value_expr = pl.when(is_in_ids).then(pl.col(channel)).otherwise(pl.lit(0).cast(dtype))
86
+ csv = csv.with_columns(value_expr.alias(channel))
87
+ else:
88
+ csv = csv.with_columns(pl.lit(0).alias(channel))
89
+
90
+ return csv
91
+
92
+
93
+ def download_gates(datasource_name, gates, channels):
94
+ rows = []
95
+ for key, value in channels.items():
96
+ rows.append([key, value[0], value[1]])
97
+ csv = pl.DataFrame(rows, schema=['channel', 'gate_start', 'gate_end'], orient='row')
98
+ csv = csv.with_columns(pl.lit(False).alias('gate_active'))
99
+
100
+ schema = csv.schema
101
+ for channel in gates:
102
+ is_channel = pl.col('channel') == channel
103
+ csv = csv.with_columns([
104
+ pl.when(is_channel).then(pl.lit(True)).otherwise(pl.col('gate_active')).alias('gate_active'),
105
+ pl.when(is_channel).then(pl.lit(gates[channel][0]).cast(schema['gate_start']))
106
+ .otherwise(pl.col('gate_start')).alias('gate_start'),
107
+ pl.when(is_channel).then(pl.lit(gates[channel][1]).cast(schema['gate_end']))
108
+ .otherwise(pl.col('gate_end')).alias('gate_end'),
109
+ ])
110
+
111
+ return csv
112
+
113
+
114
+ def save_gating_list(datasource_name, gates, channels):
115
+ rows = []
116
+ for key, value in channels.items():
117
+ rows.append([key, value[0], value[1]])
118
+ csv = pl.DataFrame(rows, schema=['channel', 'gate_start', 'gate_end'], orient='row')
119
+ csv = csv.with_columns(pl.lit(False).alias('gate_active'))
120
+
121
+ schema = csv.schema
122
+ for channel in gates:
123
+ is_channel = pl.col('channel') == channel
124
+ csv = csv.with_columns([
125
+ pl.when(is_channel).then(pl.lit(True)).otherwise(pl.col('gate_active')).alias('gate_active'),
126
+ pl.when(is_channel).then(pl.lit(gates[channel][0]).cast(schema['gate_start']))
127
+ .otherwise(pl.col('gate_start')).alias('gate_start'),
128
+ pl.when(is_channel).then(pl.lit(gates[channel][1]).cast(schema['gate_end']))
129
+ .otherwise(pl.col('gate_end')).alias('gate_end'),
130
+ ])
131
+
132
+ temp = csv.to_dicts()
133
+ f = pickle.dumps(temp, protocol=4)
134
+ _store(datasource_name).put_state(f)
135
+
136
+
137
+ def get_saved_gating_list(datasource_name):
138
+ cells = _store(datasource_name).get_state()
139
+ if cells is None:
140
+ return None
141
+ return pickle.loads(cells)
142
+
143
+
144
+ #: Components in the auto-gate fit.
145
+ #:
146
+ #: Three, not two. A marker's negative population is not a point -- it is a
147
+ #: broad distribution of background and autofluorescence, and on a log scale it
148
+ #: is close to symmetric. A two-component fit given that has an easier job
149
+ #: splitting the *background* down the middle than separating background from
150
+ #: positives, and the resulting gate sits inside the negative population: every
151
+ #: cell above the middle of the background reads as positive.
152
+ #:
153
+ #: With a third component the background can occupy two and the bright tail
154
+ #: gets its own, so the boundary between the top two is a real one. Same count,
155
+ #: and the same reasoning, as core's `get_channel_gmm` for image channels.
156
+ _GATE_COMPONENTS = 3
157
+
158
+ #: EM cost scales with N per iteration, and a 1-D mixture's fitted parameters
159
+ #: barely move between 100k rows and millions. Fixed seed, so a given column
160
+ #: gates the same way twice.
161
+ _GMM_FIT_SAMPLE_CAP = 100_000
162
+
163
+
164
+ def _fit_mixture(values, components):
165
+ """(means, sds, weights), ascending by mean, or None if it cannot fit.
166
+
167
+ A column with fewer distinct values than components -- a flag, a constant,
168
+ a nearly empty channel -- has no mixture to find, and sklearn either raises
169
+ or returns degenerate components. Saying so is better than a gate derived
170
+ from noise.
171
+ """
172
+ values = values[np.isfinite(values)]
173
+ if values.size < components or np.unique(values).size < components:
174
+ return None
175
+
176
+ sample = values
177
+ if sample.shape[0] > _GMM_FIT_SAMPLE_CAP:
178
+ rng = np.random.default_rng(0)
179
+ sample = sample[rng.choice(sample.shape[0], size=_GMM_FIT_SAMPLE_CAP,
180
+ replace=False)]
181
+
182
+ gmm = GaussianMixture(components, max_iter=1000, tol=1e-6, random_state=0)
183
+ gmm.fit(sample.reshape(-1, 1))
184
+ order = np.argsort(gmm.means_[:, 0])
185
+ return (gmm.means_[order, 0],
186
+ np.sqrt(gmm.covariances_[order, 0, 0]),
187
+ gmm.weights_[order])
188
+
189
+
190
+ def _populations(fitted, x):
191
+ """(background, positive) weighted densities at `x`.
192
+
193
+ Two populations out of three components: the brightest one is the positive
194
+ population, and everything below it pooled is the background. Pooling
195
+ rather than taking the second component alone is what makes the pair add up
196
+ to the whole distribution, so the two curves drawn over the histogram cover
197
+ it instead of leaving the largest peak unexplained.
198
+ """
199
+ means, sds, weights = fitted
200
+ positive = norm(means[-1], sds[-1]).pdf(x) * weights[-1]
201
+ background = sum(norm(means[i], sds[i]).pdf(x) * weights[i]
202
+ for i in range(len(means) - 1))
203
+ return background, positive
204
+
205
+
206
+ def _crossover(fitted):
207
+ """Where the positive population overtakes the background.
208
+
209
+ The threshold, rather than `mean(means)` -- which is what this used to take
210
+ and which ignores both how wide each component is and how much of the data
211
+ it holds. A narrow background beside a broad positive tail has its midpoint
212
+ far below the point where the two actually change places, and that
213
+ difference is the gate being wrong by tens of percent of the cells.
214
+ """
215
+ means = fitted[0]
216
+ x = np.linspace(means[0], means[-1], 2000)
217
+ background, positive = _populations(fitted, x)
218
+ above = np.flatnonzero(positive > background)
219
+ # Never overtakes: nothing here is separable, so the top component's own
220
+ # centre is as honest an answer as there is.
221
+ return float(x[above[0]]) if above.size else float(means[-1])
222
+
223
+
224
+ def auto_gate(values, log_transformed, at=None):
225
+ """Where the positive population starts, in the values' own units.
226
+
227
+ Fitted on a log scale whichever scale the table is stored on: marker
228
+ intensities are log-normal, and a mixture of *normals* fitted to raw counts
229
+ is fitting the wrong shape -- the components chase the skew instead of the
230
+ populations. So raw values are log1p'd for the fit and the answer mapped
231
+ back with expm1, while values the project already log1p'd are fitted as
232
+ they stand. The same underlying data then gives the same gate either way,
233
+ which it did not before: turning the log switch on used to move the gate
234
+ from roughly the right place to the middle of the background.
235
+
236
+ That is also why the project's own flag decides this rather than a guess at
237
+ the numbers. Logging twice is its own failure -- it compresses the
238
+ separation until a marker with 3% positives gates at 28% -- so this cannot
239
+ be "always log1p", and nothing in the values themselves tells the two
240
+ apart.
241
+
242
+ `at` asks for the fitted curves as well, evaluated at those points and in
243
+ the values' own units. Returns (gate, background, positive), any of which
244
+ is None when the column has no mixture to find -- a constant, a flag, a
245
+ nearly empty channel. The caller ships nothing rather than a number derived
246
+ from noise.
247
+ """
248
+ values = values[np.isfinite(values)]
249
+ # log1p rather than log: it is the transform Plexora itself applies, expm1
250
+ # inverts it exactly, and it is defined at zero -- which is where a large
251
+ # part of a quantification column sits. Negative values (arcsinh, z-scored)
252
+ # have no log to take, so those are fitted as they stand.
253
+ to_log = not log_transformed and values.size > 0 and values.min() >= 0
254
+ fitted = _fit_mixture(np.log1p(values) if to_log else values, _GATE_COMPONENTS)
255
+ if fitted is None:
256
+ return None, None, None
257
+
258
+ gate = _crossover(fitted)
259
+ gate = float(np.expm1(gate)) if to_log else gate
260
+ if at is None:
261
+ return gate, None, None
262
+
263
+ # Back into the values' own units, which is what the histogram underneath
264
+ # these curves is binned in. Densities do not survive a change of variable
265
+ # unchanged -- dividing by (1 + x) is the log1p Jacobian, and without it
266
+ # the curves would be the right shape at the wrong height.
267
+ background, positive = _populations(fitted, np.log1p(at) if to_log else at)
268
+ if to_log:
269
+ background, positive = background / (1 + at), positive / (1 + at)
270
+ return gate, background, positive
271
+
272
+
273
+ def _curve(x, y):
274
+ """A fitted density as the client plots it. Empty when there was no fit."""
275
+ if y is None:
276
+ return []
277
+ return [{'x': float(x[i]), 'y': float(y[i])} for i in range(len(x))]
278
+
279
+
280
+ def get_gating_gmm(channel_name, datasource_name, selection_ids):
281
+ dataset = api.dataset(datasource_name)
282
+ df = dataset.table.frame()
283
+
284
+ selection_key = tuple(sorted(selection_ids)) if selection_ids else None
285
+ cache_key = (channel_name, selection_key)
286
+
287
+ def _compute():
288
+ packet_gmm = {}
289
+
290
+ idField = dataset.schema.cell_id
291
+ if selection_ids:
292
+ datasource_filter = df.filter(pl.col(idField).is_in(selection_ids))
293
+ else:
294
+ # No selection to filter by (the only case current callers use,
295
+ # since lasso/spatial-selection was removed) -- avoid a full
296
+ # 2M-row copy that's immediately discarded.
297
+ datasource_filter = df
298
+
299
+ column_data = df[channel_name].to_numpy()
300
+ # The histogram the curves below are laid over -- binned on the whole
301
+ # column, in its own units, and deliberately not subsampled.
302
+ bin_edges = np.histogram_bin_edges(column_data[~np.isnan(column_data)], bins=50)
303
+ midpoints = (bin_edges[1:] + bin_edges[:-1]) / 2
304
+
305
+ column_data_filtered = datasource_filter[channel_name].to_numpy()
306
+
307
+ # One fit answers both: where to put the gate, and the two curves that
308
+ # show why it went there. They used to be able to disagree -- the
309
+ # curves were the fit, the gate was a summary of it that ignored their
310
+ # widths -- so the auto button landed somewhere the picture did not
311
+ # explain.
312
+ gate, background, positive = auto_gate(
313
+ column_data_filtered, dataset.table.log_transformed, at=midpoints)
314
+ if gate is not None:
315
+ packet_gmm['gate'] = gate
316
+ packet_gmm['gmm_1'] = _curve(midpoints, background)
317
+ packet_gmm['gmm_2'] = _curve(midpoints, positive)
318
+ return packet_gmm
319
+
320
+ return dataset.cached(cache_key, _compute)
@@ -0,0 +1,235 @@
1
+ import json
2
+
3
+ from flask import Blueprint, Response, abort, jsonify, request, stream_with_context
4
+ import polars as pl
5
+
6
+ from plexora import api
7
+ from plexora.plugins.gating.server import anndata_gates
8
+ from plexora.plugins.gating.server import model as gating_model
9
+ from plexora.plugins.gating.server.model import PLUGIN_NAME
10
+
11
+ # template_folder/static_folder make this plugin self-contained: Flask's
12
+ # DispatchingJinjaLoader already searches every blueprint's template folder, and
13
+ # the blueprint serves its own assets. Both were available all along and unused
14
+ # -- core previously had to know where a module's files lived.
15
+ gating_bp = Blueprint(
16
+ 'gating', __name__,
17
+ template_folder='../templates',
18
+ static_folder='../static',
19
+ static_url_path='/static',
20
+ )
21
+
22
+
23
+ def _files(datasource):
24
+ """This plugin's own file storage for a datasource -- where the uploaded
25
+ gates CSV lands. Scoped per plugin rather than written straight into the
26
+ datasource directory, so two plugins cannot overwrite each other's uploads
27
+ and an uninstall knows what belongs to whom."""
28
+ return api.store(datasource, PLUGIN_NAME).directory()
29
+
30
+
31
+ @gating_bp.route('/get_gated_cell_ids', methods=['GET'])
32
+ def get_gated_cell_ids():
33
+ datasource = request.args.get('datasource')
34
+ filter = json.loads(request.args.get('filter'))
35
+ start_keys = list(request.args.get('start_keys').split(','))
36
+ resp = gating_model.get_gated_cells(datasource, filter, start_keys)
37
+ return api.json_response(resp)
38
+
39
+
40
+ @gating_bp.route('/get_gating_gmm', methods=['POST'])
41
+ def get_gating_gmm():
42
+ post_data = json.loads(request.data)
43
+ channel = post_data['channel']
44
+ datasource = post_data['datasource']
45
+ selection_ids = post_data['selection_ids']
46
+ resp = gating_model.get_gating_gmm(channel, datasource, selection_ids)
47
+ return api.json_response(resp)
48
+
49
+
50
+ @gating_bp.route('/upload_gates', methods=['POST'])
51
+ def upload_gates():
52
+ file = request.files['file']
53
+ if file.filename.endswith('.csv') == False:
54
+ abort(422)
55
+ datasource = request.form['datasource']
56
+ file.save(_files(datasource) / 'uploaded_gates.csv')
57
+ resp = jsonify(success=True)
58
+ return resp
59
+
60
+
61
+ def _stream_csv(df, chunksize=100_000):
62
+ """Yield a large DataFrame as CSV in row chunks instead of materializing
63
+ the full serialized string (and holding it alongside the DataFrame) in
64
+ memory at once, as df.write_csv() would for a multi-million-row gating
65
+ export. Polars has no built-in chunked-string-generator, so this slices
66
+ and writes each chunk by hand."""
67
+ header = True
68
+ for start in range(0, df.height, chunksize):
69
+ yield df.slice(start, chunksize).write_csv(include_header=header)
70
+ header = False
71
+
72
+
73
+ @gating_bp.route('/download_gating_csv', methods=['POST'])
74
+ def download_gating_csv():
75
+ datasource = request.form['datasource']
76
+ filename = request.form['filename']
77
+
78
+ filter = json.loads(request.form['filter'])
79
+ channels = json.loads(request.form['channels'])
80
+ selection_ids = json.loads(request.form['selection_ids'])
81
+ fullCsv = json.loads(request.form['fullCsv'])
82
+ encoding = request.form['encoding']
83
+ if fullCsv:
84
+ csv = gating_model.download_gating_csv(datasource, filter, channels, selection_ids, encoding)
85
+ return Response(
86
+ stream_with_context(_stream_csv(csv)),
87
+ mimetype="text/csv",
88
+ headers={"Content-disposition":
89
+ "attachment; filename=" + filename + ".csv"})
90
+ else:
91
+ csv = gating_model.download_gates(datasource, filter, channels)
92
+ return Response(
93
+ csv.write_csv(),
94
+ mimetype="text/csv",
95
+ headers={"Content-disposition":
96
+ "attachment; filename=" + filename + ".csv"})
97
+
98
+
99
+ @gating_bp.route('/save_gating_list', methods=['POST'])
100
+ def save_gating_list():
101
+ post_data = json.loads(request.data)
102
+
103
+ datasource = post_data['datasource']
104
+ filter = post_data['filter']
105
+ channels = post_data['channels']
106
+
107
+ # DB-only on every save -- the .h5ad file is only ever written to
108
+ # explicitly, via the "Save Gates to AnnData" button (save_gates_to_anndata()
109
+ # below). Writing to the source file on every debounced slider edit was
110
+ # tried and reverted: the user wants edits to stay local/undo-able in the
111
+ # DB until they deliberately commit them to the file.
112
+ gating_model.save_gating_list(datasource, filter, channels)
113
+
114
+ resp = jsonify(success=True)
115
+ return resp
116
+
117
+
118
+ @gating_bp.route('/get_saved_gating_list', methods=['GET'])
119
+ def get_saved_gating_list():
120
+ datasource = request.args.get('datasource')
121
+ resp = gating_model.get_saved_gating_list(datasource)
122
+ return api.json_response(resp)
123
+
124
+
125
+ @gating_bp.route('/save_gates_to_anndata', methods=['POST'])
126
+ def save_gates_to_anndata():
127
+ """Writes only adata.uns[table_name] (lower gate bound per marker, one
128
+ column per image) back into the source .h5ad -- never a full anndata
129
+ rewrite. Gates are derived from the persisted GatingList row (the DB),
130
+ not from anything the client sends -- the DB is the single source of
131
+ truth here, same as CSV download/restore-on-reload.
132
+
133
+ Important: gate_active is NOT the right signal for "was this channel
134
+ customized" -- it only ever reflects whichever single marker is
135
+ currently displayed (gatingList.selections is reset on every marker
136
+ switch, by design, for the live single-marker slider/segmentation-
137
+ outline view). The gate_start/gate_end *values* for every OTHER
138
+ previously-gated channel are still correctly persisted though --
139
+ save_gating_list writes them from gating_channels, which is never
140
+ wiped -- so a channel counts as "has a gate" here by comparing its
141
+ stored gate_start/gate_end against its own true full data range
142
+ (get_datasource_description), exactly like the marker dropdown's
143
+ green-dot indicator does client-side (viewerSidebar.js's
144
+ hasCustomGate), not by trusting gate_active."""
145
+ post_data = json.loads(request.data)
146
+ datasource = post_data['datasource']
147
+ table_name = post_data.get('table_name') or 'gates'
148
+
149
+ try:
150
+ dataset = api.dataset(datasource)
151
+ except KeyError:
152
+ return jsonify(success=False, error="Unknown datasource"), 400
153
+ # A SpatialData datasource's gates go into the uns of the one table it
154
+ # was imported from, using the same codec -- see anndata_gates._open_group.
155
+ if dataset.source_kind not in ('anndata', 'spatialdata'):
156
+ return jsonify(success=False, error="Not an AnnData or SpatialData datasource"), 400
157
+ # The role, from the project record. This used to be a free-text box on
158
+ # the panel defaulting to the literal 'imageid' -- the plugin asking the
159
+ # user for something core already had a place to store. It is declared in
160
+ # Requires as an optional role now, so the host collects it once and every
161
+ # plugin sees the same answer.
162
+ imageid_column = dataset.schema.image_id
163
+ if not imageid_column:
164
+ return jsonify(success=False, error="No image ID column recorded for this project",
165
+ needs="role:image_id"), 400
166
+
167
+ saved_rows = gating_model.get_saved_gating_list(datasource) or []
168
+ description = dataset.table.describe()
169
+ active_gates = {}
170
+ for row in saved_rows:
171
+ channel = row.get('channel')
172
+ if not channel:
173
+ continue
174
+ gate_start = row.get('gate_start')
175
+ gate_end = row.get('gate_end')
176
+ if gate_start is None or gate_end is None:
177
+ continue
178
+ desc = description.get(channel) or {}
179
+ if gate_start == desc.get('min') and gate_end == desc.get('max'):
180
+ continue # still at the full default range -- never customized
181
+ active_gates[channel] = gate_start
182
+
183
+ try:
184
+ result = anndata_gates.save_gates_to_anndata(
185
+ dataset.table.source, datasource, active_gates,
186
+ table_name=table_name, imageid_column=imageid_column)
187
+ except ValueError as exc:
188
+ return jsonify(success=False, error=str(exc)), 400
189
+
190
+ return jsonify(success=True, **result)
191
+
192
+
193
+ @gating_bp.route('/get_gates_from_anndata', methods=['GET'])
194
+ def get_gates_from_anndata():
195
+ """Read-only counterpart of /save_gates_to_anndata -- lets the sidebar
196
+ pick up gates already present in adata.uns[table_name] (e.g. set up
197
+ outside Plexora before import) the first time a datasource with no
198
+ Plexora-side saved gating list is opened."""
199
+ datasource = request.args.get('datasource')
200
+ table_name = request.args.get('table_name') or 'gates'
201
+
202
+ try:
203
+ dataset = api.dataset(datasource)
204
+ except KeyError:
205
+ return jsonify(success=False, error="Unknown datasource"), 400
206
+ # A SpatialData datasource's gates go into the uns of the one table it
207
+ # was imported from, using the same codec -- see anndata_gates._open_group.
208
+ if dataset.source_kind not in ('anndata', 'spatialdata'):
209
+ return jsonify(success=False, error="Not an AnnData or SpatialData datasource"), 400
210
+ imageid_column = dataset.schema.image_id
211
+ if not imageid_column:
212
+ # Read-only path: no image id recorded means no gates to find, which
213
+ # is an ordinary empty result rather than something to demand of the
214
+ # user. The save path asks; this one does not.
215
+ return jsonify(success=True, image_id=datasource, gates={})
216
+
217
+ try:
218
+ result = anndata_gates.load_gates_from_anndata(
219
+ dataset.table.source, datasource,
220
+ table_name=table_name, imageid_column=imageid_column)
221
+ except ValueError as exc:
222
+ return jsonify(success=False, error=str(exc)), 400
223
+
224
+ return jsonify(success=True, **result)
225
+
226
+
227
+ @gating_bp.route('/get_uploaded_gating_csv_values', methods=['GET'])
228
+ def get_gating_csv_values():
229
+ datasource = request.args.get('datasource')
230
+ file_path = _files(datasource) / 'uploaded_gates.csv'
231
+ if file_path.is_file() == False:
232
+ abort(422)
233
+ csv = pl.read_csv(file_path)
234
+ obj = csv.to_dicts()
235
+ return api.json_response(obj)