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
plexora/datasource.py ADDED
@@ -0,0 +1,782 @@
1
+ import datetime
2
+ import json
3
+ import re
4
+ import shutil
5
+ from dataclasses import replace
6
+ from pathlib import Path
7
+
8
+ import polars as pl
9
+
10
+ from plexora.server.models.adapters.classify import classify_columns
11
+ from plexora.server.models.project import (
12
+ ColumnGroups,
13
+ ColumnRoles,
14
+ DataSpec,
15
+ ImageSpec,
16
+ Project,
17
+ SegmentationSpec,
18
+ write_config,
19
+ )
20
+
21
+
22
+ def _now():
23
+ return datetime.datetime.now().isoformat()
24
+
25
+
26
+ def _image_channel_entries(name, channel_info, channel_names, segmentation_path):
27
+ """The `imageData` list: one entry per servable layer, in the order the
28
+ viewer expects -- the 'Area' segmentation placeholder first when there is
29
+ a mask, then one per image channel."""
30
+ entries = []
31
+ if segmentation_path:
32
+ label_name = _segmentation_channel_name(segmentation_path)
33
+ entries.append({
34
+ "name": "Area",
35
+ "fullname": "Area",
36
+ "src": f"/generated/data/{name}/{label_name}/",
37
+ })
38
+ generated = channel_info["channel_names"]
39
+ for idx in range(channel_info["num_channels"]):
40
+ display_name = str(channel_names[idx])
41
+ entries.append({
42
+ "name": display_name,
43
+ "fullname": display_name,
44
+ "src": f"/generated/data/{name}/{generated[idx]}/",
45
+ })
46
+ return entries
47
+
48
+
49
+ def _image_spec(name, image_path, channel_info, channel_names, segmentation_path):
50
+ return ImageSpec(
51
+ src=str(image_path),
52
+ kind="ome_tiff",
53
+ channels=tuple(
54
+ _image_channel_entries(name, channel_info, channel_names, segmentation_path)
55
+ ),
56
+ width=channel_info["width"],
57
+ height=channel_info["height"],
58
+ max_level=channel_info["maxLevel"],
59
+ tile_width=channel_info["tileWidth"],
60
+ tile_height=channel_info["tileHeight"],
61
+ num_channels=channel_info["num_channels"],
62
+ )
63
+
64
+
65
+ def _segmentation_spec(fields):
66
+ """SegmentationSpec from what _segmentation_config_fields() produced."""
67
+ return SegmentationSpec(
68
+ derived=fields.get("segmentation"),
69
+ source=fields.get("segmentationSource"),
70
+ source_key=fields.get("segmentationSourceKey"),
71
+ mode=fields.get("segmentationMode"),
72
+ status=fields.get("segmentation_status", "ready"),
73
+ )
74
+
75
+
76
+ def _copy_if_requested(path, target_dir, copy):
77
+ path = Path(path).expanduser().resolve()
78
+ if not copy:
79
+ return path
80
+ target = target_dir / path.name
81
+ if path != target:
82
+ # A SpatialData store is a .zarr *directory*, not a file -- copy2
83
+ # raises IsADirectoryError on it.
84
+ if path.is_dir():
85
+ shutil.copytree(path, target, dirs_exist_ok=True)
86
+ else:
87
+ shutil.copy2(path, target)
88
+ return target
89
+
90
+
91
+ def _segmentation_channel_name(segmentation_path):
92
+ name = Path(segmentation_path).name
93
+ lowered = name.lower()
94
+ for suffix in (".ome.tiff", ".ome.tif", ".tiff", ".tif", ".png", ".zarr"):
95
+ if lowered.endswith(suffix):
96
+ channel_name = name[: -len(suffix)]
97
+ break
98
+ else:
99
+ channel_name = Path(name).stem
100
+ if re.match(r".*_(\d*)$", channel_name):
101
+ channel_name = f"{channel_name}_segmentation"
102
+ return channel_name
103
+
104
+
105
+ def _segmentation_config_fields(segmentation_path, dataset_dir, segmentation_async,
106
+ segmentation_mode=None):
107
+ """Build the segmentation-related config keys for a registration.
108
+
109
+ Returns (fields, source_needing_generation). A returned source means the
110
+ caller should start a background job for it *after* writing config.json,
111
+ since the job patches that same file when it completes.
112
+
113
+ `segmentationSource`/`segmentationSourceKey` are recorded either way: they
114
+ let a later load confirm the derived mask still matches its source with a
115
+ stat, instead of re-deriving it or re-sampling its pixels.
116
+
117
+ `segmentation_mode` picks what gets stored: "filled" (the default) stores a
118
+ filled label pyramid -- served untouched when the user's mask already is
119
+ one -- and leaves boundary-finding to renderLabelTile. "outlines" bakes the
120
+ boundaries into the file instead; nothing in the UI asks for it any more,
121
+ but it stays supported for callers that pass it explicitly.
122
+ """
123
+ from plexora.server.models import data_model
124
+ from plexora.server.utils import segmentation_pyramid
125
+
126
+ if not segmentation_path:
127
+ return {"segmentation": None, "segmentation_status": "ready"}, None
128
+
129
+ mode = (
130
+ segmentation_pyramid.MODE_OUTLINES
131
+ if segmentation_mode == segmentation_pyramid.MODE_OUTLINES
132
+ else segmentation_pyramid.DEFAULT_MODE
133
+ )
134
+ fields = {
135
+ "segmentationSource": str(segmentation_path),
136
+ "segmentationSourceKey": segmentation_pyramid.source_fingerprint(segmentation_path),
137
+ "segmentationMode": mode,
138
+ }
139
+ if segmentation_async:
140
+ # Converting a large mask takes tens of seconds, far too long to hold a
141
+ # form submission open -- the import page waits on the job's reported
142
+ # progress instead and opens the viewer when it finishes.
143
+ fields["segmentation"] = None
144
+ fields["segmentation_status"] = "pending"
145
+ return fields, segmentation_path
146
+
147
+ label_info = data_model.convertOmeTiff(
148
+ segmentation_path,
149
+ dataDirectory=str(dataset_dir),
150
+ isLabelImg=True,
151
+ segmentation_mode_=mode,
152
+ )
153
+ fields["segmentation"] = label_info["segmentation"]
154
+ fields["segmentation_status"] = "ready"
155
+ return fields, None
156
+
157
+
158
+ def _derive_dataset_name_from_path(path):
159
+ """Server-side mirror of importFormValidation.js's deriveDatasetName() --
160
+ kept in sync deliberately (same suffix vocabulary) so a quick-viewed file
161
+ and a full-wizard import of the same file suggest the same base name."""
162
+ stem = Path(path).name
163
+ return re.sub(
164
+ r"\.(ome\.tiff|ome\.tif|ome\.zarr|tiff|tif|svs|zarr|png|jpg|jpeg|qptiff)$",
165
+ "",
166
+ stem,
167
+ flags=re.IGNORECASE,
168
+ )
169
+
170
+
171
+ def _dedupe_dataset_name(base_name, existing_names):
172
+ """Suffix base_name with _2, _3, ... until it doesn't collide with any of
173
+ existing_names -- callers pass get_config_names() in."""
174
+ existing = set(existing_names)
175
+ if base_name not in existing:
176
+ return base_name
177
+ i = 2
178
+ while f"{base_name}_{i}" in existing:
179
+ i += 1
180
+ return f"{base_name}_{i}"
181
+
182
+
183
+ def _find_existing_datasource_for_image(image_path, config):
184
+ """Return the name of an already-registered datasource pointing at the
185
+ same on-disk image file, or None. Every registration path (quick view,
186
+ the full import wizard, anndata) stamps config[name]['channelFile'] with
187
+ the image path it was given, so resolving both sides (expanduser,
188
+ symlinks, '..', relative vs. absolute) catches the same file being
189
+ quick-viewed twice, or quick-viewed after already being imported."""
190
+ try:
191
+ target = Path(image_path).expanduser().resolve()
192
+ except OSError:
193
+ return None
194
+ for name, entry in (config or {}).items():
195
+ channel_file = (entry or {}).get("channelFile")
196
+ if not channel_file:
197
+ continue
198
+ try:
199
+ if Path(channel_file).expanduser().resolve() == target:
200
+ return name
201
+ except OSError:
202
+ continue
203
+ return None
204
+
205
+
206
+ def _sniff_quick_view_kind(path):
207
+ """Classify a dropped/browsed file as 'ome_tiff' (goes through the full
208
+ multi-channel zarr/tile pipeline) or 'rgb' (flat single-image display,
209
+ no channels) purely by extension, with a PIL-based content sniff on the
210
+ RGB branch as a guard against a mislabeled file. Raises ValueError for
211
+ anything else -- quick view has no format-detection fallback."""
212
+ suffix = Path(path).suffix.lower()
213
+ if suffix in (".tif", ".tiff"):
214
+ return "ome_tiff"
215
+ if suffix in (".png", ".jpg", ".jpeg"):
216
+ from PIL import Image
217
+ with Image.open(path) as img:
218
+ img.verify()
219
+ return "rgb"
220
+ raise ValueError(f"Unsupported file type for quick view: {suffix or path}")
221
+
222
+
223
+ def rename_channels(name, channel_names, data_dir=None):
224
+ """Rename an already-registered datasource's image channels in place --
225
+ used by the viewer's channel-names CSV upload to fix gating/channel
226
+ auto-matching after the fact, without re-registering (and re-running
227
+ image pyramid generation for) the whole datasource. Caller is
228
+ responsible for reloading the runtime datasource afterward (see
229
+ data_model.load_datasource(name, reload=True)) so the cached description
230
+ and in-memory config pick up the change.
231
+ """
232
+ from plexora import paths
233
+
234
+ data_root = Path(data_dir).expanduser().resolve() if data_dir else paths.data_root()
235
+ try:
236
+ project = Project.load(name, data_root)
237
+ except KeyError:
238
+ raise ValueError(f"No datasource named {name!r}.") from None
239
+
240
+ renamable = [c for c in project.image.channels if c.get("name") != "Area"]
241
+ if len(channel_names) != len(renamable):
242
+ raise ValueError(
243
+ f"channel_names has {len(channel_names)} entries but {name!r} has {len(renamable)} channels."
244
+ )
245
+
246
+ # Rebuilt rather than mutated in place: ImageSpec is frozen, and editing
247
+ # the channel dicts it holds would also edit whatever the caller passed in.
248
+ new_names = list(str(n) for n in channel_names)
249
+ channels = []
250
+ for channel in project.image.channels:
251
+ channel = dict(channel)
252
+ if channel.get("name") != "Area":
253
+ renamed = new_names.pop(0)
254
+ channel["name"] = renamed
255
+ channel["fullname"] = renamed
256
+ channels.append(channel)
257
+
258
+ updated = project.patch(image=replace(project.image, channels=tuple(channels)))
259
+ return updated.save(data_root)
260
+
261
+
262
+ def _channel_names_from_ome_xml(image_path, n_channels):
263
+ """Channel names embedded in the image's own OME-XML metadata, if
264
+ present and if the count matches -- returns None otherwise. Shared by
265
+ derive_anndata_channel_names (tier 2 there) and derive_image_channel_names
266
+ (tier 2 here); a pure extraction, same tifffile/ome_types read as before."""
267
+ import tifffile as tf
268
+ from ome_types import from_xml
269
+
270
+ try:
271
+ with tf.TiffFile(str(image_path), is_ome=False) as tiff:
272
+ xml = tiff.pages[0].tags['ImageDescription'].value
273
+ ome_channels = from_xml(xml).images[0].pixels.channels
274
+ ome_names = [c.name for c in ome_channels]
275
+ if len(ome_names) == n_channels and all(ome_names):
276
+ return [str(n) for n in ome_names]
277
+ except Exception:
278
+ pass
279
+ return None
280
+
281
+
282
+ def derive_image_channel_names(image_path, n_channels):
283
+ """Resolve display names for a quick-view (no feature table) image:
284
+ OME-XML channel names if present and complete, else generic "Channel N".
285
+ Same tier-2/tier-4 logic as derive_anndata_channel_names, minus the
286
+ var_names/all_markers tiers that only make sense with an AnnData table.
287
+ """
288
+ ome_names = _channel_names_from_ome_xml(image_path, n_channels)
289
+ if ome_names is not None:
290
+ return ome_names, "image metadata"
291
+ return [f"Channel {i + 1}" for i in range(n_channels)], "generic"
292
+
293
+
294
+ def derive_anndata_channel_names(image_path, features_path, n_channels):
295
+ """Resolve a display name for every image channel, trying progressively
296
+ less-authoritative sources in order and only accepting one that accounts
297
+ for every channel -- a partial or wrong-length source is more likely to
298
+ silently mislabel a channel than a generic name is, so it's skipped:
299
+
300
+ 1. adata.var_names (deduplicated) -- the marker panel actually used for
301
+ analysis/gating. Checked first (ahead of embedded image metadata)
302
+ because gating always matches channels to var_names by name; a
303
+ length match here is treated as proof the two are already in the
304
+ same per-channel order, so it's linked by index rather than by
305
+ comparing text -- var_names' own text is what's used, even if the
306
+ image's embedded metadata disagrees or uses different wording for
307
+ the same channels.
308
+ 2. Channel names embedded in the image's own OME-XML metadata --
309
+ falls back to this only when var_names' length doesn't fit (e.g. QC
310
+ trimmed the panel), since matching gating out of the box beats a
311
+ more "authoritative" name that gating can't use.
312
+ 3. adata.uns['all_markers'] -- some pipelines (e.g. scimap) keep the full
313
+ acquisition panel here separately from var_names, which may have been
314
+ trimmed by QC.
315
+ 4. Generic "Channel N" names.
316
+
317
+ Whichever tier resolves, the result is returned alongside a short label
318
+ identifying the source, so callers can surface it to the user -- names
319
+ from tiers 2-4 are not guaranteed to match the vocabulary gating markers
320
+ use (e.g. a marker panel recorded as gene symbols in var_names vs.
321
+ antibody/clinical names in all_markers), so auto-matching between gating
322
+ and image channels may still require renaming channels (e.g. via the
323
+ channel-list CSV upload in the viewer) or manual matching there.
324
+ """
325
+ import anndata as ad
326
+
327
+ adata = ad.read_h5ad(features_path, backed='r')
328
+ try:
329
+ return _derive_channel_names_from_adata(image_path, adata, n_channels)
330
+ finally:
331
+ if adata.isbacked:
332
+ adata.file.close()
333
+
334
+
335
+ def derive_spatialdata_channel_names(image_path, store, table, n_channels):
336
+ """derive_anndata_channel_names() for one table inside a SpatialData
337
+ store -- identical tier order and semantics, since the resolved table is
338
+ an AnnData with its own var_names and uns['all_markers']."""
339
+ from plexora.server.models.adapters.spatialdata_adapter import read_spatialdata_table
340
+
341
+ adata = read_spatialdata_table(store, table)
342
+ return _derive_channel_names_from_adata(image_path, adata, n_channels)
343
+
344
+
345
+ def _derive_channel_names_from_adata(image_path, adata, n_channels):
346
+ """Tier logic of derive_anndata_channel_names(), against an already-open
347
+ AnnData so the .h5ad and SpatialData entry points share one
348
+ implementation. Caller owns opening and closing `adata`."""
349
+ from plexora.server.models.adapters.anndata_adapter import _deduplicate_names
350
+
351
+ var_names = _deduplicate_names([str(v) for v in adata.var_names])
352
+ if len(var_names) == n_channels:
353
+ return var_names, "adata.var_names"
354
+
355
+ ome_names = _channel_names_from_ome_xml(image_path, n_channels)
356
+ if ome_names is not None:
357
+ return ome_names, "image metadata"
358
+
359
+ all_markers = adata.uns.get('all_markers')
360
+ if all_markers is not None:
361
+ all_markers = [str(m) for m in all_markers]
362
+ if len(all_markers) == n_channels:
363
+ return all_markers, "adata.uns['all_markers']"
364
+
365
+ return [f"Channel {i + 1}" for i in range(n_channels)], "generic"
366
+
367
+
368
+ def register_datasource(
369
+ name,
370
+ image,
371
+ features,
372
+ x=None,
373
+ y=None,
374
+ segmentation=None,
375
+ id_column=None,
376
+ celltype_column=None,
377
+ channel_names=None,
378
+ copy=False,
379
+ data_dir=None,
380
+ segmentation_async=False,
381
+ segmentation_mode=None,
382
+ ):
383
+ """Register a dataset in Plexora's config without using the upload UI.
384
+
385
+ `segmentation_async` defers mask conversion to a background job and leaves
386
+ `segmentation_status` as "pending"; callers then poll
387
+ /get_segmentation_status. It defaults to off so programmatic callers get a
388
+ fully-registered datasource back from a single call.
389
+ """
390
+ from plexora import paths
391
+ from plexora.server.models import data_model
392
+
393
+ data_root = Path(data_dir).expanduser().resolve() if data_dir else paths.data_root()
394
+ dataset_dir = data_root / name
395
+ dataset_dir.mkdir(parents=True, exist_ok=True)
396
+ config_path = data_root / "config.json"
397
+ if not config_path.exists():
398
+ write_config(config_path, {})
399
+
400
+ image_path = _copy_if_requested(image, dataset_dir, copy)
401
+ segmentation_path = _copy_if_requested(segmentation, dataset_dir, copy) if segmentation else None
402
+ features_path = _copy_if_requested(features, dataset_dir, copy)
403
+
404
+ feature_table = pl.read_csv(features_path, n_rows=1)
405
+
406
+ # One predictor for the marker/metadata split and the column roles, shared
407
+ # with the import UI (adapters/classify.py). Explicit arguments win over
408
+ # its guesses -- a caller who named the coordinate columns has answered
409
+ # already -- and anything left unset is a role nobody has established yet,
410
+ # which is a legitimate state rather than an error. Whatever first needs
411
+ # one asks for it (plexora/api/plugin.py's Requires).
412
+ classified = classify_columns(
413
+ [{"name": c, "dtype": str(dt)} for c, dt in feature_table.schema.items()]
414
+ )
415
+ guessed = classified["roles"]
416
+ roles = ColumnRoles(
417
+ cell_id=id_column or guessed.get("cell_id"),
418
+ x=x or guessed.get("x"),
419
+ y=y or guessed.get("y"),
420
+ celltype=celltype_column or guessed.get("celltype"),
421
+ image_id=guessed.get("image_id"),
422
+ )
423
+
424
+ named = {role: column for role, column in roles.to_dict().items()}
425
+ missing = [c for c in named.values() if c not in feature_table.columns]
426
+ if missing:
427
+ raise ValueError("Missing feature column(s): " + ", ".join(sorted(missing)))
428
+ markers = [c for c in classified["markers"] if c not in named.values()]
429
+ metadata = [c for c in feature_table.columns if c not in markers]
430
+
431
+ channel_info = data_model.convertOmeTiff(image_path, isLabelImg=False)
432
+ segmentation_fields, pending_segmentation_source = _segmentation_config_fields(
433
+ segmentation_path, dataset_dir, segmentation_async, segmentation_mode
434
+ )
435
+
436
+ n_channels = channel_info["num_channels"]
437
+ if channel_names is None:
438
+ channel_names = markers[:n_channels]
439
+ if len(channel_names) < n_channels:
440
+ stem = image_path.name
441
+ channel_names = list(channel_names) + [f"{stem}_{i}" for i in range(len(channel_names), n_channels)]
442
+
443
+ project = Project(
444
+ name=name,
445
+ image=_image_spec(name, image_path, channel_info, channel_names, segmentation_path),
446
+ segmentation=_segmentation_spec(segmentation_fields),
447
+ dataset=DataSpec(
448
+ type="csv",
449
+ src=str(features_path),
450
+ roles=roles,
451
+ columns=ColumnGroups(markers=tuple(markers), metadata=tuple(metadata)),
452
+ ),
453
+ created_at=_now(),
454
+ )
455
+ entry = project.save(data_root)
456
+
457
+ if pending_segmentation_source:
458
+ data_model.start_segmentation_job(
459
+ name, pending_segmentation_source, dataset_dir,
460
+ segmentation_fields["segmentationMode"],
461
+ )
462
+
463
+ return entry
464
+
465
+
466
+ def register_anndata_datasource(
467
+ name,
468
+ image,
469
+ features=None,
470
+ adata=None,
471
+ segmentation=None,
472
+ coordinate_source=None,
473
+ obsm_key=None,
474
+ x=None,
475
+ y=None,
476
+ feature_source="X",
477
+ layer=None,
478
+ feature_obs_columns=None,
479
+ obs_id_field=None,
480
+ celltype_column=None,
481
+ subset_by=None,
482
+ subset_value=None,
483
+ apply_log_transform=False,
484
+ channel_names=None,
485
+ copy=False,
486
+ data_dir=None,
487
+ table=None,
488
+ segmentation_async=False,
489
+ segmentation_mode=None,
490
+ ):
491
+ """Register an AnnData (.h5ad)-backed dataset in Plexora's config.
492
+
493
+ Exactly one of `features` (a path to an existing .h5ad file) or `adata`
494
+ (an in-memory AnnData object) must be given -- an in-memory object is
495
+ always written to `<dataset_dir>/<name>.h5ad` first, since the runtime
496
+ server process (a separate subprocess for Jupyter) reads datasources
497
+ from config.json/disk, never from a Python object held by the caller.
498
+
499
+ `coordinate_source`/`feature_source` etc. mirror AnnDataAdapter's
500
+ dataSource config fields (see adapters/anndata_adapter.py); explicit
501
+ arguments always override auto-detection (requirements §8). Leaving
502
+ `coordinate_source` unset auto-detects adata.obsm['spatial'] if present
503
+ and unambiguous.
504
+
505
+ Setting `table` switches this to SpatialData mode: `features` is then a
506
+ .zarr store and `table` names the table inside it to load. Every other
507
+ argument keeps its meaning, because the selected table is itself an
508
+ AnnData -- only the reader differs (see adapters/spatialdata_adapter.py).
509
+ register_spatialdata_datasource() below is the friendlier entry point.
510
+ """
511
+ from plexora import paths
512
+ from plexora.server.models import data_model
513
+ from plexora.server.models.adapters.anndata_adapter import AnnDataAdapter
514
+ from plexora.server.models.adapters.spatialdata_adapter import SpatialDataAdapter
515
+
516
+ if (adata is None) == (features is None):
517
+ raise ValueError("Provide exactly one of `adata` (in-memory) or `features` (.h5ad path)")
518
+ if table and adata is not None:
519
+ raise ValueError("`table` selects a table inside a .zarr store, so pass `features`, not `adata`")
520
+
521
+ data_root = Path(data_dir).expanduser().resolve() if data_dir else paths.data_root()
522
+ dataset_dir = data_root / name
523
+ dataset_dir.mkdir(parents=True, exist_ok=True)
524
+ config_path = data_root / "config.json"
525
+ if not config_path.exists():
526
+ write_config(config_path, {})
527
+
528
+ image_path = _copy_if_requested(image, dataset_dir, copy)
529
+ segmentation_path = _copy_if_requested(segmentation, dataset_dir, copy) if segmentation else None
530
+
531
+ if adata is not None:
532
+ features_path = dataset_dir / f"{name}.h5ad"
533
+ adata.write_h5ad(features_path)
534
+ else:
535
+ features_path = _copy_if_requested(features, dataset_dir, copy)
536
+
537
+ coordinates_config = {}
538
+ if coordinate_source == "obsm":
539
+ coordinates_config = {"source": "obsm", "obsm_key": obsm_key or "spatial"}
540
+ elif coordinate_source == "obs":
541
+ if not x or not y:
542
+ raise ValueError("x and y are required when coordinate_source='obs'")
543
+ coordinates_config = {"source": "obs", "x_column": x, "y_column": y}
544
+ elif coordinate_source is not None:
545
+ raise ValueError(f"Unknown coordinate_source: {coordinate_source!r}")
546
+ # coordinate_source left as None: coordinates_config stays empty and
547
+ # AnnDataAdapter auto-detects adata.obsm['spatial'] if unambiguous.
548
+
549
+ if feature_source == "X":
550
+ features_config = {"source": "X"}
551
+ elif feature_source == "layer":
552
+ if not layer:
553
+ raise ValueError("layer is required when feature_source='layer'")
554
+ features_config = {"source": "layer", "layer": layer}
555
+ elif feature_source == "obs":
556
+ if not feature_obs_columns:
557
+ raise ValueError("feature_obs_columns is required when feature_source='obs'")
558
+ features_config = {"source": "obs", "obs_columns": list(feature_obs_columns)}
559
+ else:
560
+ raise ValueError(f"Unknown feature_source: {feature_source!r}")
561
+
562
+ subset_config = {}
563
+ if subset_by:
564
+ subset_config = {"column": subset_by, "value": subset_value}
565
+
566
+ spec = DataSpec(
567
+ type="spatialdata" if table else "anndata",
568
+ # In SpatialData mode this is the *store root*, with the chosen table
569
+ # named alongside it, so a plugin needing the store's other elements
570
+ # (images/labels/shapes) can open it from here.
571
+ src=str(features_path),
572
+ table=str(table) if table else None,
573
+ coordinates=coordinates_config,
574
+ features=features_config,
575
+ subset=subset_config,
576
+ # True only when apply_log_transform is explicitly requested --
577
+ # no heuristic guessing at whether the chosen feature source "looks"
578
+ # already transformed. This also gates whether the gate slider/
579
+ # auto-gate keep float precision or round to whole numbers, so an
580
+ # incorrect guess here would silently destroy narrow-range gates
581
+ # (e.g. rounding a real [1.85, 2.23] gate to [1, 3] matches nearly
582
+ # every cell) -- the user's call, every time.
583
+ is_transformed=bool(apply_log_transform),
584
+ obs_id_field=obs_id_field,
585
+ roles=ColumnRoles(
586
+ # The adapter synthesizes X/Y columns with these literal names.
587
+ x="X",
588
+ y="Y",
589
+ # Defaults to the adapter's own positional "id" column (0..n-1,
590
+ # always int -- matches NormalizedDatasource.id_column), not
591
+ # DEFAULT_ID_COLUMN ("obs_id"). The cell_id role has to be
592
+ # uint32-castable: get_all_cells() packs [cell_id, X, Y] into one
593
+ # flat array and casts the whole thing to uint32 for the fast
594
+ # binary cell-loading path (numericData.js), which crashes if it
595
+ # holds adata.obs_names strings -- the common case, since those
596
+ # are rarely small integers. An explicit obs_id_field is still
597
+ # honored as-is; a non-numeric choice there is the caller's
598
+ # informed tradeoff, not a silent default.
599
+ #
600
+ # This is a description of the emitted table, NOT an answer to the
601
+ # cell-id question -- `obs_id_field` is where that lives, and it
602
+ # stays None here until somebody says otherwise. Reading the role
603
+ # as the answer is what let every import arrive pre-answered with
604
+ # a row number nobody chose (see plugin.py's `_answered`).
605
+ cell_id=obs_id_field or "id",
606
+ celltype=celltype_column,
607
+ image_id=subset_by or None,
608
+ ),
609
+ )
610
+
611
+ # Validate end-to-end (subset/coordinates/features resolve, coordinates
612
+ # are finite, etc.) before writing anything or doing any expensive image
613
+ # pyramid work. The resolved table also tells us the marker/metadata split
614
+ # for free, which is why the result is kept rather than discarded: for
615
+ # AnnData the file already draws that line (var = markers, obs = metadata),
616
+ # so unlike CSV the user is never asked to confirm it.
617
+ adapter_class = SpatialDataAdapter if table else AnnDataAdapter
618
+ normalized = adapter_class(spec).load_table()
619
+ markers = list(normalized.feature_columns)
620
+ metadata = [c for c in normalized.table.columns if c not in set(markers)]
621
+ spec = replace(
622
+ spec,
623
+ columns=ColumnGroups(markers=tuple(markers), metadata=tuple(metadata)),
624
+ # Kept alongside the split, and not the same thing: `metadata` is what
625
+ # the loaded table holds, while these are the file's own annotations --
626
+ # the list a user picks from when saying which column holds the cell id
627
+ # or the coordinates (see Project.role_columns).
628
+ obs_columns=tuple(normalized.obs_columns),
629
+ # Likewise: the other matrices the file carries, so the choice of which
630
+ # one to threshold on stays changeable after import.
631
+ layers=tuple(normalized.layers),
632
+ # And the obsm arrays, so the coordinate source stays changeable too.
633
+ # Without these recorded the coordinate question has nothing to offer,
634
+ # and the importer's name-based pick is the only one there will ever be.
635
+ obsm=tuple(normalized.obsm),
636
+ )
637
+
638
+ channel_info = data_model.convertOmeTiff(image_path, isLabelImg=False)
639
+ segmentation_fields, pending_segmentation_source = _segmentation_config_fields(
640
+ segmentation_path, dataset_dir, segmentation_async, segmentation_mode
641
+ )
642
+
643
+ n_channels = channel_info["num_channels"]
644
+ if channel_names is None:
645
+ if table:
646
+ channel_names, _ = derive_spatialdata_channel_names(
647
+ image_path, features_path, table, n_channels
648
+ )
649
+ else:
650
+ channel_names, _ = derive_anndata_channel_names(image_path, features_path, n_channels)
651
+ elif len(channel_names) != n_channels:
652
+ raise ValueError(
653
+ f"channel_names has {len(channel_names)} entries but the image has {n_channels} channels."
654
+ )
655
+
656
+ project = Project(
657
+ name=name,
658
+ image=_image_spec(name, image_path, channel_info, channel_names, segmentation_path),
659
+ segmentation=_segmentation_spec(segmentation_fields),
660
+ dataset=spec,
661
+ created_at=_now(),
662
+ )
663
+ entry = project.save(data_root)
664
+
665
+ if pending_segmentation_source:
666
+ data_model.start_segmentation_job(
667
+ name, pending_segmentation_source, dataset_dir,
668
+ segmentation_fields["segmentationMode"],
669
+ )
670
+
671
+ return entry
672
+
673
+
674
+ def register_spatialdata_datasource(
675
+ name,
676
+ image,
677
+ store,
678
+ table,
679
+ **kwargs,
680
+ ):
681
+ """Register one table of a SpatialData (.zarr) store as a dataset.
682
+
683
+ Thin wrapper over register_anndata_datasource() -- a SpatialData table is
684
+ an AnnData, so `coordinate_source`, `feature_source`, `subset_by`,
685
+ `celltype_column`, `channel_names`, `copy`, `data_dir` etc. all behave
686
+ exactly as they do there and are accepted as keyword arguments.
687
+
688
+ `store` is the .zarr store root and `table` is the name of the table
689
+ inside it (see spatialdata_adapter.list_spatialdata_tables() to
690
+ enumerate them). Only that one table is read, never the whole store.
691
+ """
692
+ if not table:
693
+ raise ValueError("`table` is required -- name which table inside the .zarr store to load.")
694
+ return register_anndata_datasource(
695
+ name=name,
696
+ image=image,
697
+ features=store,
698
+ table=table,
699
+ **kwargs,
700
+ )
701
+
702
+
703
+ def register_image_datasource(name, image, channel_names=None, copy=False, data_dir=None):
704
+ """Register a datasource from just an OME-TIFF/TIFF image -- no feature
705
+ table, no segmentation. Used by the quick-view landing page for a fast
706
+ first look, and the floor of the new import flow: an image is the only
707
+ thing a project must have.
708
+
709
+ A project with no `dataset` block is the first-class "image only" state --
710
+ load_datasource(), load_ball_tree() and every direct consumer of the
711
+ feature table/ball tree check `project.has_table` rather than requiring a
712
+ real (or synthesized) feature CSV to exist on disk.
713
+ """
714
+ from plexora import paths
715
+ from plexora.server.models import data_model
716
+
717
+ data_root = Path(data_dir).expanduser().resolve() if data_dir else paths.data_root()
718
+ dataset_dir = data_root / name
719
+ dataset_dir.mkdir(parents=True, exist_ok=True)
720
+ config_path = data_root / "config.json"
721
+ if not config_path.exists():
722
+ write_config(config_path, {})
723
+
724
+ image_path = _copy_if_requested(image, dataset_dir, copy)
725
+
726
+ channel_info = data_model.convertOmeTiff(image_path, isLabelImg=False)
727
+ n_channels = channel_info["num_channels"]
728
+ if channel_names is None:
729
+ channel_names, _ = derive_image_channel_names(image_path, n_channels)
730
+ elif len(channel_names) != n_channels:
731
+ raise ValueError(
732
+ f"channel_names has {len(channel_names)} entries but the image has {n_channels} channels."
733
+ )
734
+
735
+ # dataset=None is the explicit "no feature table" state. Everything that
736
+ # needs one -- load_datasource(), load_ball_tree(), and the Tools menu via
737
+ # Requires.missing_from() -- reads it as such, and the tool menu turns it
738
+ # into a request for the missing data rather than hiding the tool.
739
+ project = Project(
740
+ name=name,
741
+ image=_image_spec(name, image_path, channel_info, channel_names, None),
742
+ dataset=None,
743
+ created_at=_now(),
744
+ )
745
+ return project.save(data_root)
746
+
747
+
748
+ def register_rgb_datasource(name, image, copy=False, data_dir=None):
749
+ """Register a datasource from a flat RGB image (PNG/JPEG) -- the
750
+ minimal quick-view path: view-only, no channels, no gating. Displayed
751
+ client-side via OpenSeadragon's native single-image tile source (see
752
+ RgbImageViewer), served whole by GET /generated/rgb/<name>, not tiled.
753
+ """
754
+ from PIL import Image
755
+
756
+ from plexora import paths
757
+
758
+ data_root = Path(data_dir).expanduser().resolve() if data_dir else paths.data_root()
759
+ dataset_dir = data_root / name
760
+ dataset_dir.mkdir(parents=True, exist_ok=True)
761
+
762
+ image_path = _copy_if_requested(image, dataset_dir, copy)
763
+ with Image.open(image_path) as img:
764
+ width, height = img.size
765
+
766
+ project = Project(
767
+ name=name,
768
+ image=ImageSpec(
769
+ src=str(image_path),
770
+ # 'rgb' is permanently incompatible with marker tools -- a flat
771
+ # image has no channels to threshold. Requires.applies_to() reads
772
+ # this, so those tools are hidden rather than offered and blocked.
773
+ kind="rgb",
774
+ channels=(),
775
+ width=width,
776
+ height=height,
777
+ num_channels=0,
778
+ ),
779
+ dataset=None,
780
+ created_at=_now(),
781
+ )
782
+ return project.save(data_root)