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,388 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import polars as pl
5
+
6
+ from .base import MetadataColumn, NormalizedDatasource
7
+
8
+ # Column name used to hold the resolved observation ID (adata.obs_names, or a
9
+ # custom obs column when `obs_id_field` is set) in the materialized table,
10
+ # when the caller doesn't provide an explicit obs_id_field. data_model.py's
11
+ # gating/query code (e.g. get_channel_cells) treats config['featureData'][0]
12
+ # ['idField'] as a literal column name that must exist in the loaded table --
13
+ # unlike CSV, AnnData has no single "the ID column" without this fallback.
14
+ DEFAULT_ID_COLUMN = "obs_id"
15
+
16
+ # These three column names are load-bearing: 'id' is the positional row index
17
+ # every downstream gating/query function keys off, 'X'/'Y' are the resolved
18
+ # coordinate columns. If a real obs column (e.g. a literal "id" column, seen
19
+ # in real exemplar data) were used as obs_id_field/celltype without this
20
+ # guard, it would silently overwrite one of these dict keys when the table is
21
+ # built below -- corrupting the positional id or coordinates rather than
22
+ # raising. See _reject_reserved_collisions().
23
+ _RESERVED_COLUMN_NAMES = {"id", "X", "Y"}
24
+
25
+ # The "does this column identify an image/sample/region" heuristic lives in
26
+ # classify.py, which owns every column-name vocabulary in one place. Re-exported
27
+ # here because this module is where the ambiguity guard below enforces it, and
28
+ # adapters/inspection.py has always imported it from this name.
29
+ from .classify import is_likely_image_identifier_name # noqa: F401
30
+ from .classify import is_numeric_dtype
31
+
32
+
33
+ def describe_obsm(adata) -> list[dict]:
34
+ """Each obsm array as {"name", "shape"}.
35
+
36
+ Lives here rather than in inspection.py because both this adapter and that
37
+ module need it, and inspection already imports from this direction --
38
+ the reverse would be a cycle.
39
+
40
+ Shape is read off the array's own metadata, never by materializing it: a
41
+ backed h5ad and a zarr group both report it without a read, and an
42
+ embedding on a million-cell table is not something to load in order to
43
+ label a dropdown. An entry whose shape cannot be determined still appears,
44
+ without one -- leaving it out would hide a candidate, which is the failure
45
+ this list exists to prevent.
46
+ """
47
+ entries = []
48
+ for name in adata.obsm.keys():
49
+ if not name:
50
+ continue
51
+ entry = {"name": str(name)}
52
+ shape = getattr(adata.obsm[name], "shape", None)
53
+ if shape is not None:
54
+ entry["shape"] = [int(dim) for dim in shape]
55
+ entries.append(entry)
56
+ return entries
57
+
58
+
59
+ def _likely_image_identifier_columns(adata) -> list[str]:
60
+ candidates = []
61
+ for column in adata.obs.columns:
62
+ if not is_likely_image_identifier_name(column):
63
+ continue
64
+ if adata.obs[column].nunique(dropna=True) > 1:
65
+ candidates.append(column)
66
+ return candidates
67
+
68
+
69
+ def _deduplicate_names(names: list[str]) -> list[str]:
70
+ """Real multiplexed-imaging panels commonly re-stain/re-image the same
71
+ marker across cycles (e.g. PTPRC/CD45 twice), producing duplicate
72
+ adata.var_names -- confirmed against real exemplar data. Auto-suffixing
73
+ (matching anndata's own var_names_make_unique() convention) is more
74
+ useful than hard-failing on a very ordinary occurrence.
75
+ """
76
+ seen: dict[str, int] = {}
77
+ result = []
78
+ for name in names:
79
+ if name not in seen:
80
+ seen[name] = 0
81
+ result.append(name)
82
+ else:
83
+ seen[name] += 1
84
+ result.append(f"{name}_{seen[name]}")
85
+ return result
86
+
87
+
88
+ class AnnDataAdapter:
89
+ """Adapter for AnnData (.h5ad)-backed datasources.
90
+
91
+ Takes the project's DataSpec (server/models/project.py). `coordinates`,
92
+ `features` and `subset` are the read spec -- how to get from the file to a
93
+ table -- and are the adapter's own vocabulary; the project record stores
94
+ them without interpreting them. Roles describe the table that comes out.
95
+
96
+ The table this produces always has a positional 'id' column plus 'X'/'Y',
97
+ which is why those three names are reserved below.
98
+ """
99
+
100
+ def __init__(self, spec):
101
+ self.spec = spec
102
+ self.path = spec.src
103
+ self.coordinates = dict(spec.coordinates or {})
104
+ self.features = dict(spec.features or {}) or {'source': 'X'}
105
+ self.subset = dict(spec.subset or {})
106
+ self.celltype_column = spec.roles.celltype
107
+ # The obs column the user named as the image identifier, if they did.
108
+ # An answer beats the name heuristic below: it is the only way to catch
109
+ # a table keyed on a column called "roi" or "core", which the heuristic
110
+ # does not recognise and would wave through.
111
+ self.image_id_column = spec.roles.image_id
112
+ # Which obs column supplies the identifier, or None for the positional
113
+ # row index (the default -- see the uint32-packing note in
114
+ # datasource.py). Deliberately read from the read spec rather than
115
+ # inferred from the cell_id role: an obs column literally named "id"
116
+ # exists in real data, and it must hit the reserved-name guard below
117
+ # rather than being mistaken for "just number the rows".
118
+ self.obs_id_field = spec.obs_id_field
119
+ # Explicit opt-in only -- no heuristic guessing at whether the
120
+ # chosen feature source "looks" already transformed. Same contract and
121
+ # same transform as CsvAdapter, so a threshold means the same thing
122
+ # whichever format the project was imported from.
123
+ self.apply_log_transform = bool(spec.is_transformed)
124
+
125
+ def _read_adata(self):
126
+ """The one format-specific step in load_table() -- everything after
127
+ this point is plain AnnData manipulation and is shared verbatim by
128
+ SpatialDataAdapter, which overrides only this method (see
129
+ adapters/spatialdata_adapter.py)."""
130
+ import anndata as ad
131
+
132
+ return ad.read_h5ad(self.path)
133
+
134
+ def _read_obs(self):
135
+ """adata.obs on its own, as a pandas DataFrame.
136
+
137
+ Deliberately not `self._read_adata().obs`: that materializes X, which is
138
+ the expensive part of the file and the one part a metadata column can
139
+ never need. Reading the obs element alone turns "colour cells by a
140
+ phenotype column" from a full re-import into a directory read.
141
+
142
+ `anndata.io.read_elem` is the public reader for one element of an
143
+ on-disk AnnData; the private path is the fallback for older versions,
144
+ matching what the ROI plugin's exporter does against the same files.
145
+ """
146
+ import h5py
147
+
148
+ try:
149
+ from anndata.io import read_elem # anndata >= 0.10, public API
150
+ except ImportError: # pragma: no cover - older anndata
151
+ from anndata._io.specs import read_elem
152
+
153
+ with h5py.File(self.path, "r") as handle:
154
+ return read_elem(handle["obs"])
155
+
156
+ def _subset_mask(self, obs):
157
+ """The rows load_table() keeps, or None when it keeps every row.
158
+
159
+ The single source of truth for the subset, so anything reading obs
160
+ outside load_table() lands on the same cells. Getting this wrong is not
161
+ a visible error -- the values simply belong to different cells than the
162
+ ones on screen, shifted by however many rows the other image contributed.
163
+ """
164
+ column = self.subset.get('column')
165
+ if not column:
166
+ return None
167
+ if column not in obs.columns:
168
+ raise ValueError(f"Subset column {column!r} not found in adata.obs")
169
+ value = self.subset.get('value')
170
+ mask = obs[column].astype(str).to_numpy() == str(value)
171
+ if not mask.any():
172
+ raise ValueError(f"No observations match {column}={value!r}")
173
+ return mask
174
+
175
+ def read_obs_column(self, name: str) -> MetadataColumn:
176
+ """One obs column, subset the same way the loaded table was.
177
+
178
+ Raises KeyError for a column the file does not have, which is how the
179
+ caller tells "no such column" from "this format has nothing to add"
180
+ (CsvAdapter returns None for the latter).
181
+ """
182
+ obs = self._read_obs()
183
+ if name not in obs.columns:
184
+ raise KeyError(name)
185
+ column = obs[name]
186
+ mask = self._subset_mask(obs)
187
+ if mask is not None:
188
+ column = column[mask]
189
+
190
+ categories = None
191
+ if hasattr(column, "cat"):
192
+ # The file's own level order (see MetadataColumn.categories). Taken
193
+ # before the values are flattened to strings, which is the only
194
+ # moment it still exists.
195
+ categories = tuple(str(level) for level in column.cat.categories)
196
+ column = column.astype(object)
197
+ values = column.to_numpy()
198
+ return MetadataColumn(name=name, values=values, categories=categories)
199
+
200
+ def load_table(self) -> NormalizedDatasource:
201
+ adata = self._read_adata()
202
+
203
+ subset_column = self.subset.get('column')
204
+ if subset_column:
205
+ mask = self._subset_mask(adata.obs)
206
+ adata = adata[mask].copy()
207
+ else:
208
+ named = self.image_id_column
209
+ if named and named in adata.obs.columns:
210
+ # The user told us which column identifies the image, so ask
211
+ # that column rather than guessing which one to ask. This is
212
+ # the check the name heuristic below cannot make: it only fires
213
+ # for conventionally-named columns, so a table keyed on "roi"
214
+ # or "core" loaded whole and drew several images' cells over
215
+ # one image, with nothing said.
216
+ images = adata.obs[named].astype(str).nunique(dropna=True)
217
+ if images > 1:
218
+ raise ValueError(
219
+ f"Column {named!r} covers {images} images, but no "
220
+ "subset was specified -- loading them all would draw "
221
+ "several images' cells over one image. Choose which "
222
+ "image to load."
223
+ )
224
+ else:
225
+ ambiguous = _likely_image_identifier_columns(adata)
226
+ if ambiguous:
227
+ raise ValueError(
228
+ "AnnData object has candidate image/sample identifier "
229
+ f"column(s) {ambiguous} with more than one distinct "
230
+ "value, but no subset was specified -- refusing to "
231
+ "silently load all observations. Set dataSource.subset "
232
+ "(or subset_by/subset_value) to pick one image/sample."
233
+ )
234
+
235
+ n_obs = adata.n_obs
236
+ if n_obs == 0:
237
+ raise ValueError("Resolved AnnData subset has zero observations")
238
+
239
+ x_values, y_values = self._resolve_coordinates(adata)
240
+ if not np.isfinite(x_values).all() or not np.isfinite(y_values).all():
241
+ raise ValueError("Resolved X/Y coordinates contain non-finite values")
242
+
243
+ feature_matrix, feature_names = self._resolve_features(adata)
244
+
245
+ if self.obs_id_field:
246
+ if self.obs_id_field not in adata.obs.columns:
247
+ raise ValueError(f"obs_id_field {self.obs_id_field!r} not found in adata.obs")
248
+ if self.obs_id_field in _RESERVED_COLUMN_NAMES:
249
+ raise ValueError(
250
+ f"obs_id_field {self.obs_id_field!r} collides with a reserved "
251
+ f"column name ({sorted(_RESERVED_COLUMN_NAMES)}) -- choose a "
252
+ "different observation ID column, or leave it unset to use "
253
+ "adata.obs_names."
254
+ )
255
+ column = adata.obs[self.obs_id_field]
256
+ # A numeric obs column stays numeric. The usual reason to name one
257
+ # here is that it holds the segmentation mask's own label values,
258
+ # and the centroid cache packs the cell id into a uint32 -- a
259
+ # stringified integer only survives that round trip by being parsed
260
+ # back out again. adata.obs_names below has no such expectation and
261
+ # is genuinely text, so it keeps the str cast.
262
+ id_values = (column.to_numpy() if is_numeric_dtype(column.dtype)
263
+ else column.astype(str).to_numpy())
264
+ id_field_name = self.obs_id_field
265
+ else:
266
+ id_values = np.asarray([str(v) for v in adata.obs_names])
267
+ id_field_name = DEFAULT_ID_COLUMN
268
+
269
+ feature_names = _deduplicate_names(feature_names)
270
+ reserved = _RESERVED_COLUMN_NAMES | {id_field_name}
271
+ collisions = [name for name in feature_names if name in reserved]
272
+ if collisions:
273
+ raise ValueError(f"Feature name(s) collide with reserved columns: {collisions}")
274
+
275
+ columns = {
276
+ "id": np.arange(n_obs, dtype=np.int64),
277
+ "X": x_values,
278
+ "Y": y_values,
279
+ id_field_name: id_values,
280
+ }
281
+ for i, feature_name in enumerate(feature_names):
282
+ values = feature_matrix[:, i].astype(np.float64)
283
+ values = np.where(np.isneginf(values), 0.0, values)
284
+ if self.apply_log_transform:
285
+ values = np.log1p(values)
286
+ columns[feature_name] = values
287
+
288
+ celltype_column = None
289
+ if self.celltype_column:
290
+ if self.celltype_column not in adata.obs.columns:
291
+ raise ValueError(f"celltype column {self.celltype_column!r} not found in adata.obs")
292
+ if self.celltype_column in reserved:
293
+ raise ValueError(
294
+ f"celltype column {self.celltype_column!r} collides with a "
295
+ f"reserved/ID column name -- choose a different column."
296
+ )
297
+ columns[self.celltype_column] = adata.obs[self.celltype_column].astype(str).to_numpy()
298
+ celltype_column = self.celltype_column
299
+
300
+ table = pl.DataFrame(columns)
301
+ source_obs_ids = [str(v) for v in adata.obs_names]
302
+
303
+ return NormalizedDatasource(
304
+ table=table,
305
+ id_column="id",
306
+ source_obs_ids=source_obs_ids,
307
+ x_column="X",
308
+ y_column="Y",
309
+ feature_columns=list(feature_names),
310
+ celltype_column=celltype_column,
311
+ obs_columns=[str(c) for c in adata.obs.columns],
312
+ # anndata's Layers mapping can report a spurious `None` key
313
+ # (observed with anndata 0.13.2) even when no such layer exists --
314
+ # filtered out here the same way adapters/inspection.py does.
315
+ layers=[str(k) for k in adata.layers.keys() if k],
316
+ # Recorded so the coordinate question has candidates to offer
317
+ # without reopening the file. Same codec as inspection's, so the
318
+ # import path and the edit page describe an array identically.
319
+ obsm=describe_obsm(adata),
320
+ )
321
+
322
+ def _resolve_coordinates(self, adata):
323
+ source = self.coordinates.get('source')
324
+ if source is None:
325
+ if 'spatial' not in adata.obsm:
326
+ raise ValueError(
327
+ "No coordinate source specified and adata.obsm['spatial'] "
328
+ "is absent -- set dataSource.coordinates (coordinate_source"
329
+ "='obsm'/'obs' plus the relevant keys/columns) explicitly."
330
+ )
331
+ source, obsm_key = 'obsm', 'spatial'
332
+ else:
333
+ obsm_key = self.coordinates.get('obsm_key')
334
+
335
+ if source == 'obsm':
336
+ obsm_key = obsm_key or 'spatial'
337
+ if obsm_key not in adata.obsm:
338
+ raise ValueError(f"obsm key {obsm_key!r} not found in adata.obsm")
339
+ xy = np.asarray(adata.obsm[obsm_key])
340
+ if xy.ndim != 2 or xy.shape[1] < 2:
341
+ raise ValueError(f"adata.obsm[{obsm_key!r}] must be 2D with at least 2 columns")
342
+ return xy[:, 0].astype(np.float64), xy[:, 1].astype(np.float64)
343
+
344
+ if source == 'obs':
345
+ x_col = self.coordinates.get('x_column')
346
+ y_col = self.coordinates.get('y_column')
347
+ if not x_col or not y_col:
348
+ raise ValueError(
349
+ "dataSource.coordinates.x_column/y_column are required "
350
+ "when coordinates.source='obs'"
351
+ )
352
+ if x_col not in adata.obs.columns or y_col not in adata.obs.columns:
353
+ raise ValueError(f"Coordinate columns {x_col!r}/{y_col!r} not found in adata.obs")
354
+ x_values = adata.obs[x_col].to_numpy(dtype=np.float64)
355
+ y_values = adata.obs[y_col].to_numpy(dtype=np.float64)
356
+ return x_values, y_values
357
+
358
+ raise ValueError(f"Unknown coordinates.source: {source!r}")
359
+
360
+ def _resolve_features(self, adata):
361
+ source = self.features.get('source', 'X')
362
+ if source == 'X':
363
+ matrix = adata.X
364
+ names = [str(v) for v in adata.var_names]
365
+ elif source == 'layer':
366
+ layer = self.features.get('layer')
367
+ if not layer:
368
+ raise ValueError("dataSource.features.layer is required when features.source='layer'")
369
+ if layer not in adata.layers:
370
+ raise ValueError(f"Layer {layer!r} not found in adata.layers")
371
+ matrix = adata.layers[layer]
372
+ names = [str(v) for v in adata.var_names]
373
+ elif source == 'obs':
374
+ obs_columns = self.features.get('obs_columns') or []
375
+ if not obs_columns:
376
+ raise ValueError("dataSource.features.obs_columns is required when features.source='obs'")
377
+ missing = [c for c in obs_columns if c not in adata.obs.columns]
378
+ if missing:
379
+ raise ValueError(f"obs feature columns not found in adata.obs: {missing}")
380
+ matrix = adata.obs[obs_columns].to_numpy(dtype=np.float64)
381
+ names = list(obs_columns)
382
+ else:
383
+ raise ValueError(f"Unknown features.source: {source!r}")
384
+
385
+ if hasattr(matrix, 'toarray'):
386
+ matrix = matrix.toarray()
387
+ matrix = np.asarray(matrix, dtype=np.float64)
388
+ return matrix, names
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Protocol
5
+
6
+ import numpy as np
7
+ import polars as pl
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class MetadataColumn:
12
+ """One annotation column, aligned row-for-row with the loaded table.
13
+
14
+ Exists because `NormalizedDatasource.table` is deliberately narrow for the
15
+ structural formats: AnnDataAdapter materializes id/X/Y/the id field/the
16
+ markers/the celltype column and nothing else, so an arbitrary `.obs` column
17
+ is named by `obs_columns` but is not IN the table. A tool that colours cells
18
+ by an annotation needs the values, and re-reading the whole file to get one
19
+ column would cost the same as the import did.
20
+
21
+ `categories` is the source's own category order when it declares one -- a
22
+ pandas Categorical in obs. It is carried rather than re-derived because it
23
+ is the one ordering that cannot be recovered from the values: a legend
24
+ sorted alphabetically puts "Stage 10" before "Stage 2", and a file that
25
+ already says what order its levels go in should be believed. None means the
26
+ source said nothing, and the caller is free to sort.
27
+ """
28
+
29
+ name: str
30
+ values: np.ndarray
31
+ categories: tuple[str, ...] | None = None
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class NormalizedDatasource:
36
+ """Source-agnostic view of an observation/cell table.
37
+
38
+ `table` always carries a positional 'id' column plus the resolved
39
+ x/y/feature/celltype columns under the exact names data_model.py's
40
+ downstream gating/query code already expects (the same names stored in
41
+ config[...]['featureData'][0]['xCoordinate']/['yCoordinate']/etc.) --
42
+ adapters differ only in how they produce this table, never in its shape.
43
+ """
44
+
45
+ table: pl.DataFrame
46
+ id_column: str
47
+ source_obs_ids: list[str]
48
+ x_column: str
49
+ y_column: str
50
+ feature_columns: list[str]
51
+ celltype_column: str | None
52
+ obs_metadata: pl.DataFrame | None = None
53
+ # The two below describe what else the SOURCE offers -- not what this table
54
+ # ended up holding. They exist for adapters that build `table` from a read
55
+ # spec rather than reading a file verbatim, and they are what a user later
56
+ # picks from when changing that spec. Both are empty for a CSV, where the
57
+ # table's columns already are the file's columns and there is one matrix.
58
+
59
+ #: The source file's own annotation column names, in its own order. What a
60
+ #: user chooses between when saying which column holds the cell id or the
61
+ #: coordinates -- see Project.role_columns.
62
+ obs_columns: list[str] = field(default_factory=list)
63
+ #: The names of the extra expression matrices the file carries alongside
64
+ #: `X`. What a user chooses between when saying which one holds the marker
65
+ #: intensities -- see Project.feature_source.
66
+ layers: list[str] = field(default_factory=list)
67
+ #: The source file's obsm arrays as {"name", "shape"}. What a user chooses
68
+ #: between when saying where the cell coordinates are -- see
69
+ #: Project.coordinate_options. The shape is carried because it is the only
70
+ #: other thing on offer, and even it cannot separate a position from an
71
+ #: embedding: `spatial` and `X_umap` are routinely both (n, 2) float32.
72
+ obsm: list[dict] = field(default_factory=list)
73
+
74
+
75
+ class DatasourceAdapter(Protocol):
76
+ """One instance per (config entry, load). Stateless with respect to
77
+ data_model.py's module globals -- an adapter only knows how to turn a
78
+ featureData config dict into a NormalizedDatasource. All I/O happens in
79
+ load_table(), called once per load_datasource().
80
+ """
81
+
82
+ def load_table(self) -> NormalizedDatasource: ...
83
+
84
+ def read_obs_column(self, name: str) -> MetadataColumn | None:
85
+ """One annotation column the loaded table does not carry.
86
+
87
+ None means "there is no such thing here" rather than "not found": a CSV
88
+ adapter's table IS the file, so every column is already in `frame()` and
89
+ there is nothing this could add. Adapters that build a table from a read
90
+ spec return the values, subset exactly as `load_table()` subset them, so
91
+ the result lines up row-for-row with the loaded frame.
92
+ """
93
+ ...
@@ -0,0 +1,192 @@
1
+ """Which columns are markers, and which are metadata.
2
+
3
+ This used to be answered in five places that disagreed: three hardcoded
4
+ denylists (`import_routes.listNotMarkers`, `datasource._default_marker_columns`,
5
+ `channelMatch.js markers_notToTransform`) and two independent "numeric column
6
+ that produced a histogram" derivations (`api/dataset.TableHandle.markers`,
7
+ `datasetContext.js`). Adding a morphology column meant remembering all of them.
8
+
9
+ It is one question about the imported data, so it gets one answer, computed
10
+ once at import and stored on the project (`DataSpec.columns`). Everything
11
+ downstream reads the stored answer rather than re-deriving it.
12
+
13
+ The prediction is a starting point, not a verdict -- for CSV the user confirms
14
+ it on the classification screen and drags anything we got wrong. Being roughly
15
+ right matters much more than being subtly clever, so the rules are blunt and
16
+ name-based, and deliberately biased towards calling a column metadata: a
17
+ metadata column wrongly offered as a marker shows up as a nonsense histogram in
18
+ every plugin, while a marker wrongly filed as metadata is one drag away.
19
+
20
+ Stdlib only, on purpose. `anndata_adapter` imports the name heuristics from
21
+ here rather than the other way round, so nothing that merely wants to classify
22
+ column names pays for numpy/polars/anndata.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import re
28
+ from typing import Any, Iterable, Mapping
29
+
30
+ # Conventional names for the column identifying which image/sample/region an
31
+ # observation belongs to. Also used by AnnDataAdapter to decide whether a file
32
+ # might span several images and therefore needs an explicit subset choice --
33
+ # deliberately not "any categorical column", since cell_type/cluster/condition
34
+ # are common in ordinary single-image data and must not trigger that.
35
+ _LIKELY_IMAGE_IDENTIFIER_NAMES = {
36
+ "imageid", "image", "sample", "sampleid", "region", "regionid",
37
+ "roi", "fov", "well", "slide", "core",
38
+ }
39
+
40
+ # Non-marker columns a quantification table conventionally carries. Matched
41
+ # against the normalized name, so "X_centroid", "x centroid" and "XCentroid"
42
+ # are one entry. Grouped by what they are, because that is how they get
43
+ # extended.
44
+ _METADATA_NAMES = {
45
+ # identity
46
+ "id", "cellid", "cellindex", "objectid", "object", "label", "index",
47
+ # position
48
+ "x", "y", "xcentroid", "ycentroid", "centroidx", "centroidy",
49
+ "xposition", "yposition", "xcoordinate", "ycoordinate",
50
+ "columncentroid", "rowcentroid", "column", "row", "globalx", "globaly",
51
+ # morphology
52
+ "area", "convexarea", "filledarea", "bboxarea", "perimeter",
53
+ "majoraxislength", "minoraxislength", "axismajorlength", "axisminorlength",
54
+ "eccentricity", "solidity", "extent", "orientation", "circularity",
55
+ "equivalentdiameter", "feretdiametermax", "eulernumber", "formfactor",
56
+ "elongation", "compactness", "roundness", "aspectratio",
57
+ # annotation
58
+ "phenotype", "celltype", "cluster", "leiden", "louvain", "condition",
59
+ "treatment", "patient", "donor", "batch", "replicate", "timepoint",
60
+ } | _LIKELY_IMAGE_IDENTIFIER_NAMES
61
+
62
+ # Suffixes that mark a column as a derived measurement of something rather
63
+ # than an intensity: "CD3_area", "DNA1_orientation". Checked after the exact
64
+ # names above so a marker literally called "Area" still resolves.
65
+ _METADATA_SUFFIXES = (
66
+ "centroid", "area", "perimeter", "eccentricity", "solidity", "extent",
67
+ "orientation", "axislength", "diameter", "circularity",
68
+ )
69
+
70
+ #: Role -> ordered patterns, most specific first. The first column matching the
71
+ #: earliest pattern wins, so "X_centroid" beats a bare "X" for the x role but a
72
+ #: table with only "X" still resolves.
73
+ _ROLE_PATTERNS = {
74
+ "cell_id": (r"^cellid$", r"^cellindex$", r"^objectid$", r"^cell$", r"^label$", r"^id$"),
75
+ "x": (r"^xcentroid$", r"^centroidx$", r"^xposition$", r"^xcoordinate$",
76
+ r"^columncentroid$", r"^globalx$", r"^x$"),
77
+ "y": (r"^ycentroid$", r"^centroidy$", r"^yposition$", r"^ycoordinate$",
78
+ r"^rowcentroid$", r"^globaly$", r"^y$"),
79
+ "celltype": (r"^phenotype$", r"^celltype$", r"^cluster$", r"^leiden$"),
80
+ "image_id": tuple(rf"^{re.escape(name)}$" for name in sorted(_LIKELY_IMAGE_IDENTIFIER_NAMES)),
81
+ }
82
+
83
+ _NUMERIC_DTYPE = re.compile(r"int|float|double|number", re.IGNORECASE)
84
+
85
+
86
+ def normalize_column_name(name: Any) -> str:
87
+ """Strip separators and case so the name vocabularies above stay short."""
88
+ return re.sub(r"[\s_\-.]+", "", str(name)).lower()
89
+
90
+
91
+ def is_likely_image_identifier_name(column_name: Any) -> bool:
92
+ """Whether a column name looks like it identifies an image/sample/region."""
93
+ return normalize_column_name(column_name) in _LIKELY_IMAGE_IDENTIFIER_NAMES
94
+
95
+
96
+ def is_numeric_dtype(dtype: Any) -> bool:
97
+ """Whether a reported dtype string is numeric.
98
+
99
+ Takes the string rather than the dtype object so this works for polars,
100
+ numpy and pandas alike, and so a caller with only a JSON payload (the
101
+ client sends dtypes as strings) gets the same answer.
102
+ """
103
+ return bool(_NUMERIC_DTYPE.search(str(dtype or "")))
104
+
105
+
106
+ def looks_like_metadata(name: Any, dtype: Any = None) -> bool:
107
+ """Whether one column should start life in the metadata box."""
108
+ normalized = normalize_column_name(name)
109
+ if not normalized:
110
+ return True
111
+ if dtype is not None and not is_numeric_dtype(dtype):
112
+ # A marker is an intensity. Anything non-numeric is an annotation,
113
+ # whatever it is called.
114
+ return True
115
+ if normalized in _METADATA_NAMES:
116
+ return True
117
+ return any(normalized.endswith(suffix) for suffix in _METADATA_SUFFIXES)
118
+
119
+
120
+ def guess_roles(columns: Iterable[Any]) -> dict:
121
+ """Best guess at which column fills each role.
122
+
123
+ Returns only the roles it is confident about; an unresolved role is simply
124
+ absent, which the project record stores as None and something may later ask
125
+ the user to fill in.
126
+ """
127
+ names = [str(c) for c in columns]
128
+ by_normalized = {}
129
+ for name in names:
130
+ by_normalized.setdefault(normalize_column_name(name), name)
131
+
132
+ roles = {}
133
+ taken = set()
134
+ for role, patterns in _ROLE_PATTERNS.items():
135
+ for pattern in patterns:
136
+ match = next(
137
+ (original for normalized, original in by_normalized.items()
138
+ if original not in taken and re.match(pattern, normalized)),
139
+ None,
140
+ )
141
+ if match is not None:
142
+ roles[role] = match
143
+ taken.add(match)
144
+ break
145
+ return roles
146
+
147
+
148
+ def classify_columns(columns: Iterable[Mapping[str, Any] | str]) -> dict:
149
+ """Split columns into markers and metadata, and guess the column roles.
150
+
151
+ `columns` is either bare names or `{"name": ..., "dtype": ...}` mappings;
152
+ supplying dtypes makes the split markedly better, since every non-numeric
153
+ column is metadata regardless of its name.
154
+
155
+ Returns `{"markers": [...], "metadata": [...], "roles": {...}}` with the
156
+ input order preserved inside each group -- a table's own column order is
157
+ usually meaningful to whoever produced it, and reordering makes the
158
+ classification screen harder to check.
159
+ """
160
+ names, dtypes = [], {}
161
+ for column in columns or ():
162
+ if isinstance(column, Mapping):
163
+ name = column.get("name")
164
+ if name is None:
165
+ continue
166
+ names.append(str(name))
167
+ dtypes[str(name)] = column.get("dtype")
168
+ else:
169
+ names.append(str(column))
170
+ dtypes[str(column)] = None
171
+
172
+ metadata = [n for n in names if looks_like_metadata(n, dtypes.get(n))]
173
+ metadata_set = set(metadata)
174
+ markers = [n for n in names if n not in metadata_set]
175
+ return {"markers": markers, "metadata": metadata, "roles": guess_roles(names)}
176
+
177
+
178
+ def classify_from_inspection(inspection: Mapping[str, Any]) -> dict:
179
+ """The split for an AnnData or SpatialData table, taken from its structure.
180
+
181
+ `var_names` are markers and `obs` columns are metadata by construction --
182
+ the file already draws the line this screen exists to draw for CSV, so the
183
+ user is never asked to confirm it. Roles are still guessed from the obs
184
+ names, since which obs column holds the cell id is not structural.
185
+ """
186
+ obs_columns = inspection.get("obs_columns") or []
187
+ obs_names = [c.get("name") for c in obs_columns if isinstance(c, Mapping) and c.get("name")]
188
+ return {
189
+ "markers": [str(v) for v in (inspection.get("var_names") or ())],
190
+ "metadata": [str(n) for n in obs_names],
191
+ "roles": guess_roles(obs_names),
192
+ }