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,1659 @@
1
+ from sklearn.neighbors import BallTree
2
+ from sklearn.preprocessing import MinMaxScaler
3
+ import numpy as np
4
+ import polars as pl
5
+ import polars.selectors as cs
6
+ import os
7
+ import io
8
+ from pathlib import Path
9
+ from ome_types import from_xml
10
+ from plexora import paths, get_config
11
+ from plexora.server.utils import fast_png
12
+ from plexora.server.utils import segmentation_pyramid
13
+ from plexora.server.models.adapters import MetadataColumn, get_adapter
14
+ from plexora.server.models import database_model, centroid_tiles
15
+ from plexora.server.models.project import (
16
+ Project, config_transaction, read_config, write_config,
17
+ )
18
+ from plexora.server.utils import smallestenclosingcircle
19
+ from PIL import Image
20
+ from itertools import chain
21
+ import time
22
+ import pickle
23
+ import tifffile as tf
24
+ import re
25
+ import threading
26
+ import zarr
27
+ from sklearn.mixture import GaussianMixture
28
+ from scipy.stats import norm
29
+ from skimage.measure import block_reduce
30
+
31
+ ball_tree = None
32
+ database = None
33
+ source = None
34
+ config = None
35
+ seg = None
36
+ zarray = None
37
+ channels = None
38
+ metadata = None
39
+ load_lock = threading.RLock()
40
+ # Name of the datasource whose load_datasource() body has run to completion.
41
+ # This is the ONLY correct "is it loaded?" signal: `datasource` and `seg` are
42
+ # legitimately None for image-only projects (no feature table, no
43
+ # segmentation), so guards that infer loadedness from them can never
44
+ # short-circuit -- they re-run the whole load (reopening the OME-TIFF, wiping
45
+ # the derived caches, bumping load_generation) on every single tile request.
46
+ _loaded_source = None
47
+
48
+ # Cache of derived, expensive-to-recompute results, keyed off the currently
49
+ # loaded datasource. Cleared whenever load_datasource actually (re)loads data,
50
+ # since these caches were only ever valid for the previously loaded content.
51
+ _gmm_cache = {}
52
+ # Per-key locks making an uncached get_channel_gmm() single-flight: the compute
53
+ # is ~0.7 s and is triggered from the tile path, so concurrent requests for the
54
+ # same channel must wait for one computation rather than each doing their own.
55
+ _gmm_compute_locks = {}
56
+ _gmm_compute_locks_guard = threading.Lock()
57
+
58
+
59
+ def _gmm_compute_lock(cache_key):
60
+ with _gmm_compute_locks_guard:
61
+ lock = _gmm_compute_locks.get(cache_key)
62
+ if lock is None:
63
+ lock = threading.Lock()
64
+ _gmm_compute_locks[cache_key] = lock
65
+ return lock
66
+
67
+
68
+ _image_stats_cache = {}
69
+ _description_cache = {}
70
+ _gate_filter_cache = {}
71
+ # One entry per (datasource, annotation column) -- see get_metadata_column.
72
+ # Bounded rather than unbounded like the caches above: those hold one small
73
+ # summary per datasource, while each entry here is a full-length array, and a
74
+ # table can carry hundreds of annotation columns. Oldest-first eviction is
75
+ # enough -- the access pattern is a user picking columns one at a time, and
76
+ # what matters is that going back to the previous one is free.
77
+ _metadata_column_cache = {}
78
+ _METADATA_COLUMN_CACHE_MAX = 8
79
+ # Incremented each time load_datasource actually (re)loads data, so other
80
+ # modules can key a cache off "which load is this" without importing this
81
+ # module's internal cache dicts directly.
82
+ load_generation = 0
83
+
84
+
85
+ def _zarr_level(group, level):
86
+ if isinstance(group, zarr.Array):
87
+ return group
88
+ return group[str(level)]
89
+
90
+
91
+ def _served_directly_as_outlines(segmentation_path):
92
+ """True when a mask can be handed to the viewer as-is.
93
+
94
+ That covers both a mask this app generated and a user's own outline export
95
+ -- in either case there is no filled-label interior to strip, so deriving
96
+ anything would be wasted work.
97
+ """
98
+ if segmentation_pyramid.is_generated_outline_mask(segmentation_path):
99
+ return True
100
+ try:
101
+ return segmentation_pyramid.looks_like_outline_mask(segmentation_path)
102
+ except Exception:
103
+ return False
104
+
105
+
106
+ def describe_segmentation_work(segmentation_path, mode):
107
+ """What the conversion is about to do, and why, for the import page's
108
+ progress panel.
109
+
110
+ Worth spelling out: a user who supplies a mask that is already filled
111
+ labels (or already pyramidal) reasonably expects it to be served untouched,
112
+ and a panel that just says "preparing" for two minutes gives them no way to
113
+ tell a missing requirement from a hang.
114
+ """
115
+ if mode == segmentation_pyramid.MODE_FILLED:
116
+ gaps = segmentation_pyramid.label_pyramid_gaps(segmentation_path)
117
+ if gaps:
118
+ return f"Building a tiled label pyramid, because {' and '.join(gaps)}"
119
+ return "Preparing label pyramid"
120
+ return "Generating cell outlines"
121
+
122
+
123
+ def _servable_as_is(segmentation_path, mode):
124
+ """True when `segmentation_path` can go straight to the viewer unconverted.
125
+
126
+ In outline mode that means it already contains outlines; in filled mode it
127
+ means it is a tiled label pyramid the tile route can serve at every zoom
128
+ level -- which is the "user brought their own pyramidised mask" case, the
129
+ one situation where importing costs no conversion at all.
130
+ """
131
+ if mode == segmentation_pyramid.MODE_FILLED:
132
+ return segmentation_pyramid.is_servable_label_pyramid(segmentation_path)
133
+ return _served_directly_as_outlines(segmentation_path)
134
+
135
+
136
+ def resolve_outline_segmentation(segmentation_path, dataDirectory=None, progress_callback=None,
137
+ mode=segmentation_pyramid.DEFAULT_MODE):
138
+ """Return the mask to serve for `segmentation_path`, converting it in one
139
+ pass when it is not already servable.
140
+
141
+ `mode` selects what gets written: a filled label pyramid (the default,
142
+ where renderLabelTile derives boundaries at tile-load time), or an outline
143
+ pyramid with the boundaries baked in.
144
+
145
+ In the default mode a source that is already a tiled label pyramid is
146
+ returned untouched -- the case this is built around. Outline mode has no
147
+ such shortcut: it always reads the source's highest-resolution level and
148
+ derives its own pyramid, so "already tiled" and "flat" input cost the same.
149
+ """
150
+ if _servable_as_is(segmentation_path, mode):
151
+ return str(segmentation_path)
152
+ output_path = segmentation_pyramid.derived_output_path(
153
+ segmentation_path, dataDirectory, mode=mode
154
+ )
155
+ return segmentation_pyramid.pyramidize_segmentation_mask(
156
+ segmentation_path,
157
+ output_path,
158
+ overwrite=True,
159
+ outline=mode != segmentation_pyramid.MODE_FILLED,
160
+ progress_callback=progress_callback,
161
+ )
162
+
163
+
164
+ def segmentation_mode(entry):
165
+ """Which kind of mask this datasource serves.
166
+
167
+ MODE_FILLED (the default for anything imported now) serves filled labels
168
+ and has renderLabelTile derive boundaries at tile-load time. MODE_OUTLINES
169
+ bakes them into the file instead; imports no longer choose it, but
170
+ datasources built that way keep working.
171
+
172
+ An entry written before the mode was recorded gets it read back off its own
173
+ derived file rather than assuming the current default: those were all
174
+ outlines, and calling them filled would make every pre-existing project
175
+ look stale and rebuild its mask on the next load.
176
+ """
177
+ entry = entry or {}
178
+ mode = entry.get('segmentationMode')
179
+ if mode in (segmentation_pyramid.MODE_FILLED, segmentation_pyramid.MODE_OUTLINES):
180
+ return mode
181
+ derived = entry.get('segmentation')
182
+ if derived and segmentation_pyramid.generated_mask_kind(derived) == segmentation_pyramid.MODE_OUTLINES:
183
+ return segmentation_pyramid.MODE_OUTLINES
184
+ return segmentation_pyramid.DEFAULT_MODE
185
+
186
+
187
+ def _segmentation_mapping_is_current(entry):
188
+ """True when `entry`'s recorded derived mask still matches its source.
189
+
190
+ Stat-only, so this can gate every datasource load. Before this mapping
191
+ existed each load re-sampled the mask's pixels to guess whether outlines
192
+ were still needed.
193
+ """
194
+ derived = entry.get('segmentation')
195
+ source = entry.get('segmentationSource')
196
+ recorded_key = entry.get('segmentationSourceKey')
197
+ if not derived or not source or not recorded_key:
198
+ return False
199
+ if not Path(derived).exists():
200
+ return False
201
+ # A datasource switched between outline and filled mode still has the other
202
+ # kind's file recorded here; that has to be re-derived rather than served.
203
+ # None means the user's own mask is being served as-is, which no mode owns.
204
+ kind = segmentation_pyramid.generated_mask_kind(derived)
205
+ if kind is not None and kind != segmentation_mode(entry):
206
+ return False
207
+ return segmentation_pyramid.source_fingerprint(source) == recorded_key
208
+
209
+
210
+ def refresh_segmentation_mapping(entry, datasource_name):
211
+ """Bring `entry`'s segmentation mapping up to date in place.
212
+
213
+ Returns (changed, source_needing_generation). A returned source means the
214
+ caller should hand it to start_segmentation_job() once it has finished
215
+ writing config -- generation takes tens of seconds on a large mask, far too
216
+ long to do inline on a load that a tile request is waiting behind.
217
+ """
218
+ # Record the mode explicitly on entries that predate the key, so this is the
219
+ # last load that has to infer it by reading the derived file's OME header.
220
+ backfilled = False
221
+ if not entry.get('segmentationMode') and entry.get('segmentation'):
222
+ entry['segmentationMode'] = segmentation_mode(entry)
223
+ backfilled = True
224
+
225
+ if _segmentation_mapping_is_current(entry):
226
+ return backfilled, None
227
+
228
+ source = entry.get('segmentationSource') or entry.get('segmentation')
229
+ if not source:
230
+ return False, None
231
+ # A legacy entry predates this mapping, so a fingerprint mismatch tells us
232
+ # nothing about whether its derived file is stale -- only a mismatch
233
+ # against a key we actually recorded means the user's mask has changed.
234
+ had_mapping = bool(entry.get('segmentationSource') and entry.get('segmentationSourceKey'))
235
+ source_changed = had_mapping and (
236
+ segmentation_pyramid.source_fingerprint(source) != entry.get('segmentationSourceKey')
237
+ )
238
+
239
+ mode = segmentation_mode(entry)
240
+ if mode == segmentation_pyramid.MODE_FILLED and _served_directly_as_outlines(source):
241
+ # Asked for shader outlining, but handed a mask that already *is*
242
+ # outlines -- the shader would then trace the boundary of each outline
243
+ # stroke, hollowing it out. Nothing to derive from this input in either
244
+ # mode, so fall back to serving it as outline mode would.
245
+ mode = segmentation_pyramid.MODE_OUTLINES
246
+ entry['segmentationMode'] = mode
247
+
248
+ pending_source = None
249
+ derived = segmentation_pyramid.derived_output_path(
250
+ source, paths.derived_root(datasource_name), mode=mode
251
+ )
252
+ if not source_changed and segmentation_pyramid.generated_mask_kind(derived) == mode:
253
+ # Backfilling a legacy entry: its derived file is one of ours, of the
254
+ # kind this datasource wants, and nothing says the source moved on --
255
+ # so adopt it rather than spending a minute reproducing it. Checked
256
+ # before the content sniff below because this reads metadata only,
257
+ # where the sniff has to pull pixels out of the user's own mask -- and
258
+ # this runs on a load a page is waiting on.
259
+ entry['segmentation'] = str(derived)
260
+ entry['segmentation_status'] = 'ready'
261
+ elif _servable_as_is(source, mode):
262
+ entry['segmentation'] = str(source)
263
+ entry['segmentation_status'] = 'ready'
264
+ else:
265
+ entry['segmentation'] = None
266
+ entry['segmentation_status'] = 'pending'
267
+ pending_source = str(source)
268
+
269
+ entry['segmentationSource'] = str(source)
270
+ entry['segmentationSourceKey'] = segmentation_pyramid.source_fingerprint(source)
271
+ return True, pending_source
272
+
273
+
274
+ def init(datasource_name):
275
+ load_ball_tree(datasource_name)
276
+
277
+
278
+ # Read-only accessors onto this module's currently-loaded-datasource state.
279
+ # Plugins (plexora/plugins/*, reaching core only via plexora.api) should go
280
+ # through these rather than reading data_model.datasource/.config/etc.
281
+ # directly -- same values today, but it keeps module code from depending on
282
+ # this module's private global names surviving a future rewrite (see
283
+ # SKILL.md's "Known Performance Hot Spots" for why that rewrite is a
284
+ # deliberately deferred, separate task, not something to block on here).
285
+ def get_current_datasource_name():
286
+ return source
287
+
288
+
289
+ def get_datasource_df():
290
+ return datasource
291
+
292
+
293
+ def get_current_config():
294
+ return config
295
+
296
+
297
+ def get_current_ball_tree():
298
+ return ball_tree
299
+
300
+
301
+ def get_current_zarray():
302
+ return zarray
303
+
304
+
305
+ def get_current_channels():
306
+ return channels
307
+
308
+
309
+ def gmm_cache_get_or_set(key, compute_fn):
310
+ """Shared entry point into this module's _gmm_cache for feature modules
311
+ (e.g. gating's per-selection GMM) that want the same warm/invalidate-on-
312
+ reload behavior as this module's own GMM caching, without reaching into
313
+ _gmm_cache directly -- this module keeps sole invalidation authority
314
+ (load_datasource() clears it on every reload)."""
315
+ if key in _gmm_cache:
316
+ return _gmm_cache[key]
317
+ value = compute_fn()
318
+ _gmm_cache[key] = value
319
+ return value
320
+
321
+
322
+
323
+ def _project(datasource_name):
324
+ """This module's `config` global, as a typed record.
325
+
326
+ Reads the in-memory dict rather than re-reading config.json: load_config()
327
+ has already normalized paths in it, and a fresh read would drop those
328
+ fixups. Everything here that asks what a project's columns mean goes
329
+ through this, so the on-disk shape is known in exactly one place
330
+ (server/models/project.py).
331
+ """
332
+ return Project.from_entry(datasource_name, (config or {}).get(datasource_name) or {})
333
+
334
+
335
+ def loaded_scope(datasource_name):
336
+ """What "this datasource is already loaded" has to match on.
337
+
338
+ The name alone when there is one root, which is every single-user install.
339
+ With shared roots in play the name is not enough: a project can be shadowed
340
+ -- a user who imports their own `sample1` while the shared `sample1` is the
341
+ one loaded resolves to a different project under the same name, and every
342
+ guard keyed on the name would report it as already loaded and go on serving
343
+ the other one's table.
344
+
345
+ Short-circuited on `shared_roots()` because this is consulted from the tile
346
+ path, and resolving the root means reading a config.json per root.
347
+ """
348
+ if not paths.shared_roots():
349
+ return datasource_name
350
+ return (str(Project.root_for(datasource_name) or ""), datasource_name)
351
+
352
+
353
+ def load_datasource(datasource_name, reload=False):
354
+ global datasource
355
+ global source
356
+ global config
357
+ global seg
358
+ global zarray
359
+ global channels
360
+ global metadata
361
+ global load_generation
362
+ global _loaded_source
363
+ with load_lock:
364
+ if _loaded_source == loaded_scope(datasource_name) and reload is False:
365
+ return
366
+ load_config(datasource_name)
367
+ project = _project(datasource_name)
368
+ if project.has_table:
369
+ adapter = get_adapter(project.dataset.type)(project.dataset)
370
+ print("Loading datasource data.. (this can take some time)")
371
+ loaded_datasource = adapter.load_table().table
372
+ else:
373
+ loaded_datasource = None
374
+ print("Loading segmentation.")
375
+ segmentation_path = config[datasource_name].get('segmentation')
376
+ if not segmentation_path:
377
+ loaded_seg = None
378
+ elif str(segmentation_path).endswith('.zarr'):
379
+ loaded_seg = zarr.open(segmentation_path)
380
+ else:
381
+ seg_io = tf.TiffFile(segmentation_path, is_ome=False)
382
+ loaded_seg = zarr.open(seg_io.series[0].aszarr())
383
+ channel_io = tf.TiffFile(config[datasource_name]['channelFile'], is_ome=False)
384
+ print("Loading image descriptions.")
385
+ try:
386
+ xml = channel_io.pages[0].tags['ImageDescription'].value
387
+ loaded_metadata = from_xml(xml).images[0].pixels
388
+ except:
389
+ loaded_metadata = {}
390
+ loaded_channels = zarr.open(channel_io.series[0].aszarr())
391
+
392
+ level_series = next(
393
+ level for level in reversed(channel_io.series[0].levels)
394
+ if all(d >= 200 for d in level.shape[1:])
395
+ )
396
+ loaded_zarray = zarr.open(level_series.aszarr())
397
+ if loaded_zarray.shape[1] > 400 or loaded_zarray.shape[2] > 400:
398
+ x_reduce = loaded_zarray.shape[1] // 200
399
+ y_reduce = loaded_zarray.shape[2] // 200
400
+ reduce = np.min([x_reduce, y_reduce])
401
+ # block_reduce needs a real strided numpy array -- loaded_zarray
402
+ # here is a lazy zarr.Array, which has no .strides. This is
403
+ # already the smallest pyramid level with both dims >= 200, so
404
+ # materializing it is bounded regardless of the source image's
405
+ # full resolution.
406
+ loaded_zarray = block_reduce(np.asarray(loaded_zarray), (1, reduce, reduce), np.mean)
407
+
408
+ datasource = loaded_datasource
409
+ seg = loaded_seg
410
+ channels = loaded_channels
411
+ zarray = loaded_zarray
412
+ metadata = loaded_metadata
413
+ source = datasource_name
414
+ if reload:
415
+ # After the table, not before it. load_ball_tree indexes this
416
+ # module's `datasource` global, and a reload of the project that is
417
+ # already loaded skips its own refresh (`source` matches), so
418
+ # building the tree first indexed the table from before the change.
419
+ # Every path that changes what a project reads is a same-name
420
+ # reload, and that is exactly when the coordinate columns can stop
421
+ # existing -- swapping a CSV for an .h5ad renames them
422
+ # X_centroid -> X, and the build then raised ColumnNotFound against
423
+ # the very table it was replacing.
424
+ load_ball_tree(datasource_name, reload=True)
425
+ # Data on disk just changed underneath us (first load or explicit
426
+ # reload) -- any cached GMM/description results are now stale.
427
+ _gmm_cache.clear()
428
+ _description_cache.clear()
429
+ _image_stats_cache.clear()
430
+ _gate_filter_cache.clear()
431
+ _metadata_column_cache.clear()
432
+ # Bumped so downstream tile-byte caches (keyed on this) know to
433
+ # treat previously cached tiles as stale without needing a direct
434
+ # reference back into this module's caches.
435
+ load_generation += 1
436
+ # Set last, after every global above is in place, so a concurrent
437
+ # reader never sees _loaded_source set against a half-built state.
438
+ _loaded_source = loaded_scope(datasource_name)
439
+ print("Data loading done.")
440
+
441
+ # Warm the description/GMM caches in the background so the first real
442
+ # request after this load doesn't pay for them synchronously.
443
+ threading.Thread(
444
+ target=_warm_datasource_caches, args=(datasource_name,), daemon=True
445
+ ).start()
446
+
447
+
448
+ def load_config(datasource_name):
449
+ global config
450
+
451
+ # The migration below only ever rewrites this datasource's own entry, so the
452
+ # read happens outside the lock (refresh_segmentation_mapping can stat and
453
+ # sample files, which is too slow to hold every other writer behind) and
454
+ # only the write re-takes it, against a fresh copy of the file.
455
+ config = Project.load_all()
456
+ entry = config[datasource_name]
457
+ updated = False
458
+ # Update the feature-table path -- skipped entirely for an image-only
459
+ # datasource, which has no source file to fix up.
460
+ spec = (entry or {}).get('dataset')
461
+ if spec and spec.get('src'):
462
+ original = spec['src']
463
+ resolved = original.replace('static/data', 'plexora/data')
464
+ if Path(resolved).exists() is False and Path('.' + resolved).exists():
465
+ resolved = '.' + resolved
466
+ spec['src'] = str(Path(resolved))
467
+ if original != spec['src']:
468
+ updated = True
469
+
470
+ pending_segmentation_source = None
471
+ segmentation_path = entry.get('segmentation')
472
+ if segmentation_path:
473
+ migrated_path = segmentation_path.replace('static/data', 'plexora/data')
474
+ if migrated_path != segmentation_path:
475
+ entry['segmentation'] = migrated_path
476
+ updated = True
477
+ mapping_changed, pending_segmentation_source = refresh_segmentation_mapping(
478
+ entry, datasource_name
479
+ )
480
+ updated = updated or mapping_changed
481
+
482
+ if updated:
483
+ # Skipped rather than attempted for a project on a read-only shared
484
+ # root. Everything above is a path fixup applied to the in-memory
485
+ # entry, so this load works either way; what would not work is letting
486
+ # write_config spend its two-second Windows retry budget discovering
487
+ # that somebody else's root is not ours to rewrite, on every load.
488
+ config_file = Project.config_path_for(datasource_name)
489
+ if paths.is_writable(config_file.parent):
490
+ with config_transaction():
491
+ stored = read_config(config_file)
492
+ # Gone means deleted while this load was in flight; re-adding
493
+ # it here would resurrect a project the user just removed.
494
+ if datasource_name in stored:
495
+ stored[datasource_name] = entry
496
+ write_config(config_file, stored)
497
+
498
+ # Started only after the write above has landed, since the job patches the
499
+ # same file when it finishes.
500
+ if pending_segmentation_source:
501
+ start_segmentation_job(
502
+ datasource_name,
503
+ pending_segmentation_source,
504
+ paths.derived_root(datasource_name),
505
+ segmentation_mode(config[datasource_name]),
506
+ )
507
+
508
+
509
+ def _ball_tree_source_signature(csv_path):
510
+ stat = csv_path.stat()
511
+ return {
512
+ "csv_size": stat.st_size,
513
+ "csv_mtime_ns": stat.st_mtime_ns,
514
+ }
515
+
516
+
517
+ def load_ball_tree(datasource_name_name, reload=False):
518
+ global ball_tree
519
+ global datasource
520
+ global config
521
+ if loaded_scope(datasource_name_name) != _loaded_source:
522
+ load_datasource(datasource_name_name)
523
+
524
+ project = _project(datasource_name_name)
525
+ if not project.has_table or not (project.roles.x and project.roles.y):
526
+ # Nothing to build a tree from: either there is no feature table at all
527
+ # (image-only project), or one was imported whose coordinate columns
528
+ # nobody has identified yet -- a spatial index over columns we cannot
529
+ # name is not something to guess at. Every direct consumer of
530
+ # ball_tree/datasource checks for None rather than dereferencing a tree
531
+ # that was never built.
532
+ ball_tree = None
533
+ return
534
+
535
+ pickled_kd_tree_path = str(
536
+ paths.derived_root(datasource_name_name) / "ball_tree.pickle")
537
+
538
+ csvPath = Path(project.dataset.src)
539
+ signature = _ball_tree_source_signature(csvPath)
540
+
541
+ if Path(pickled_kd_tree_path).is_file() and reload is False:
542
+ print("Pickled KD Tree Exists, Loading")
543
+ try:
544
+ with open(pickled_kd_tree_path, "rb") as tree_file:
545
+ cached = pickle.load(tree_file)
546
+ if isinstance(cached, dict) and cached.get('signature') == signature:
547
+ ball_tree = cached['tree']
548
+ print("Pickled KD Tree Loaded.")
549
+ return
550
+ print("Pickled KD Tree is stale (source CSV changed), rebuilding.")
551
+ except Exception as exc:
552
+ print(f"Could not load pickled KD Tree, rebuilding: {exc}")
553
+
554
+ print("Creating KD Tree.")
555
+ xCoordinate = project.roles.x
556
+ yCoordinate = project.roles.y
557
+ # Reuse the feature table load_datasource already parsed instead of
558
+ # re-reading the (potentially multi-million-row) CSV from disk again.
559
+ points = datasource.select([xCoordinate, yCoordinate]).to_numpy()
560
+ ball_tree = BallTree(points, metric='euclidean')
561
+ # The tree itself is built and usable at this point; the file is only a
562
+ # cache of it. A project directory that does not exist yet is an ordinary
563
+ # state for a project registered without any data attached, and losing the
564
+ # spatial index over a cache write is a much worse outcome than rebuilding
565
+ # it next time.
566
+ try:
567
+ Path(pickled_kd_tree_path).parent.mkdir(parents=True, exist_ok=True)
568
+ with open(pickled_kd_tree_path, 'wb') as tree_file:
569
+ pickle.dump({'signature': signature, 'tree': ball_tree}, tree_file)
570
+ except OSError as exc:
571
+ print(f"Could not cache KD Tree ({exc}); it will be rebuilt next load.")
572
+ print('Creating KD Tree done.')
573
+
574
+
575
+ def _ensure_loaded(datasource_name):
576
+ """Ensure the CSV/BallTree for datasource_name is the currently loaded one."""
577
+ if loaded_scope(datasource_name) != _loaded_source:
578
+ load_ball_tree(datasource_name)
579
+
580
+
581
+ _warmup_locks = {}
582
+ _warmup_locks_guard = threading.Lock()
583
+
584
+
585
+ def _warmup_lock_for(datasource_name):
586
+ with _warmup_locks_guard:
587
+ if datasource_name not in _warmup_locks:
588
+ _warmup_locks[datasource_name] = threading.Lock()
589
+ return _warmup_locks[datasource_name]
590
+
591
+
592
+ def _saved_channels_first(datasource_name, fullnames):
593
+ """Reorder `fullnames` so the channels the project last had switched on come
594
+ first.
595
+
596
+ Warming is not free even in pass 1: the quantization window needs one
597
+ full-resolution read per channel, and all tile I/O is globally serialized
598
+ (zarr funnels through a single io thread, tifffile takes a per-file read
599
+ lock). Warming in config order therefore puts up to 18 channels the user has
600
+ not asked for ahead of the ones the page is about to request, and the page
601
+ waits behind them. Best-effort: any failure here just leaves the original
602
+ order, which is what this did before.
603
+ """
604
+ try:
605
+ saved = get_saved_channel_list(datasource_name)
606
+ if not saved:
607
+ return fullnames
608
+ # save_channel_list writes whatever map_channels supplied, which the
609
+ # client builds from imageChannelsIdx -- i.e. fullnames. Also match the
610
+ # config's short `name` so this keeps working for a datasource where
611
+ # the two differ.
612
+ active = {row['channel'] for row in saved if row.get('channel_active')}
613
+ if not active:
614
+ return fullnames
615
+ short_of = {c['fullname']: c.get('name') for c in config[datasource_name]['imageData']}
616
+ is_active = lambda n: n in active or short_of.get(n) in active
617
+ return ([n for n in fullnames if is_active(n)] +
618
+ [n for n in fullnames if not is_active(n)])
619
+ except Exception:
620
+ return fullnames
621
+
622
+
623
+ def _warm_datasource_caches(datasource_name):
624
+ """Pre-populate description/GMM caches in the background so the first
625
+ real request after a datasource load doesn't pay for them synchronously.
626
+ Best-effort only: if a concurrent switch to a different datasource races
627
+ this, the _ensure_loaded() calls inside will just reload as needed.
628
+
629
+ Two passes, not one pass per channel. The client blocks on
630
+ get_image_channel_stats (histogram, quantization window, provisional
631
+ auto-level) before it can display anything, and that is ~0.13 s per
632
+ channel; get_channel_gmm only refines contrast afterwards and costs
633
+ 0.2-1.9 s per channel (17 s for all 19 here). Interleaving them meant a
634
+ channel the user activated early queued behind GMM fits for channels they
635
+ had not asked for -- measured 2.44 s for a single on-demand GMM against
636
+ ~0.95 s in isolation. Same total work either way; this just finishes
637
+ everything the UI actually waits on ~7x sooner.
638
+ """
639
+ lock = _warmup_lock_for(datasource_name)
640
+ if not lock.acquire(blocking=False):
641
+ return
642
+ try:
643
+ get_datasource_description(datasource_name)
644
+ to_warm = [c['fullname'] for c in config[datasource_name]['imageData']
645
+ if c['name'] != 'Area']
646
+ to_warm = _saved_channels_first(datasource_name, to_warm)
647
+ # Pass 1 -- everything the first paint blocks on.
648
+ for fullname in to_warm:
649
+ get_image_channel_stats(fullname, datasource_name)
650
+ # Pass 2 -- the expensive refinement nothing blocks on.
651
+ for fullname in to_warm:
652
+ get_channel_gmm(fullname, datasource_name)
653
+ except Exception as exc:
654
+ print(f"Background cache warmup failed for {datasource_name}: {exc}")
655
+ finally:
656
+ lock.release()
657
+
658
+
659
+ def query_for_closest_cell(x, y, datasource_name):
660
+ global datasource
661
+ global source
662
+ global ball_tree
663
+ _ensure_loaded(datasource_name)
664
+ if ball_tree is None:
665
+ return {}
666
+ distance, index = ball_tree.query([[x, y]], k=1)
667
+ if distance == np.inf:
668
+ return {}
669
+ # Nothing found
670
+ else:
671
+ try:
672
+ row = datasource[index[0].tolist()]
673
+ obj = row.to_dicts()[0]
674
+ if 'celltype' not in obj:
675
+ obj['celltype'] = ''
676
+ return obj
677
+ except:
678
+ return {}
679
+
680
+
681
+ def get_row(row, datasource_name):
682
+ global database
683
+ global source
684
+ global ball_tree
685
+ _ensure_loaded(datasource_name)
686
+ obj = database.loc[[row]].to_dict(orient='records')[0]
687
+ obj['id'] = row
688
+ return obj
689
+
690
+
691
+ def get_channel_names(datasource_name, shortnames=True):
692
+ global datasource
693
+ global source
694
+ _ensure_loaded(datasource_name)
695
+ # imageData[0] is only the "Area" placeholder when segmentation was
696
+ # registered -- without it, index 0 is a real channel. Filter by name
697
+ # instead of slicing [1:], which silently dropped the first real
698
+ # channel for segmentation-less datasources.
699
+ real_channels = [channel for channel in config[datasource_name]['imageData'] if channel['fullname'] != 'Area']
700
+ key = 'name' if shortnames else 'fullname'
701
+ return [channel[key] for channel in real_channels]
702
+
703
+
704
+ def get_filter_columns(datasource_name, columns):
705
+ """Numeric numpy views of the requested columns pulled from the
706
+ already-loaded datasource, cached (one entry at a time, like
707
+ centroid_tiles._load_filter_table) so repeated range-filter queries on
708
+ the same columns reuse the same arrays instead of re-deriving them per
709
+ request. Shared core primitive -- used directly by get_channel_cells
710
+ below, and by the gating plugin's own queries (plexora/plugins/gating/
711
+ server/model.py) via this same function, not a private copy.
712
+ """
713
+ if datasource is None:
714
+ # Defensive backstop -- callers into this shared primitive
715
+ # (get_channel_cells above, the gating module) should already
716
+ # short-circuit on project.has_table before reaching here.
717
+ return {}
718
+ key = (datasource_name, tuple(sorted(set(columns))))
719
+ cached = _gate_filter_cache.get(key)
720
+ if cached is not None:
721
+ return cached
722
+ cols = {
723
+ c: datasource[c].cast(pl.Float32, strict=False).fill_null(float('nan')).to_numpy()
724
+ for c in columns
725
+ }
726
+ _gate_filter_cache.clear()
727
+ _gate_filter_cache[key] = cols
728
+ return cols
729
+
730
+
731
+ def _frame_metadata_column(name, series):
732
+ """A polars column as a MetadataColumn, keeping a declared level order."""
733
+ categories = None
734
+ dtype = series.dtype
735
+ if dtype in (pl.Categorical, pl.Enum):
736
+ # Polars states the level order for these two dtypes the same way
737
+ # pandas does for a Categorical, and for the same reason -- so honour it
738
+ # here rather than letting the legend fall back to sorting.
739
+ categories = tuple(str(v) for v in series.cat.get_categories().to_list())
740
+ series = series.cast(pl.Utf8)
741
+ return MetadataColumn(name=name, values=series.to_numpy(), categories=categories)
742
+
743
+
744
+ def get_metadata_column(datasource_name, column):
745
+ """One annotation column's values, aligned row-for-row with the table.
746
+
747
+ Two sources, one answer. A CSV's loaded frame holds every column of the
748
+ file, so the frame is it. AnnData and SpatialData are the reason this
749
+ function exists: their adapters materialize only id/X/Y/the id field/the
750
+ markers/the celltype column, so an arbitrary `.obs` column is listed by
751
+ `TableHandle.metadata_columns` and is nowhere in `frame()`. Asking the
752
+ adapter to read that one column keeps the alignment (it applies the same
753
+ subset) without re-importing the file.
754
+
755
+ Raises KeyError for a column neither place has -- which is the honest answer
756
+ for a stale saved preference naming a column the data no longer carries.
757
+ """
758
+ _ensure_loaded(datasource_name)
759
+ key = (datasource_name, column)
760
+ cached = _metadata_column_cache.get(key)
761
+ if cached is not None:
762
+ return cached
763
+
764
+ frame = get_datasource_df()
765
+ if frame is not None and column in frame.columns:
766
+ result = _frame_metadata_column(column, frame[column])
767
+ else:
768
+ result = _read_metadata_column(datasource_name, column)
769
+ if frame is not None and len(result.values) != frame.height:
770
+ # Loud on purpose. A length mismatch means the obs read and the
771
+ # loaded table disagree about which cells they describe, and the
772
+ # values would then be attached to whichever cells happen to sit at
773
+ # those row numbers -- a picture that looks entirely plausible and
774
+ # is wrong. Better no overlay than a convincing one.
775
+ raise ValueError(
776
+ f"metadata column {column!r} has {len(result.values)} values but "
777
+ f"the loaded table has {frame.height} rows"
778
+ )
779
+
780
+ if len(_metadata_column_cache) >= _METADATA_COLUMN_CACHE_MAX:
781
+ _metadata_column_cache.pop(next(iter(_metadata_column_cache)))
782
+ _metadata_column_cache[key] = result
783
+ return result
784
+
785
+
786
+ def _read_metadata_column(datasource_name, column):
787
+ project = _project(datasource_name)
788
+ if not project.has_table:
789
+ raise KeyError(column)
790
+ adapter = get_adapter(project.dataset.type)(project.dataset)
791
+ read = getattr(adapter, "read_obs_column", None)
792
+ result = read(column) if read is not None else None
793
+ if result is None:
794
+ # Either the format has no second place to look (CSV), or it is an
795
+ # adapter written before this method existed. Both mean the loaded
796
+ # frame was the whole answer, and it did not have the column.
797
+ raise KeyError(column)
798
+ return result
799
+
800
+
801
+ def apply_range_mask(columns, gates, mode='and'):
802
+ n = len(next(iter(columns.values()))) if columns else 0
803
+ keep = np.ones(n, dtype=bool) if mode == 'and' else np.zeros(n, dtype=bool)
804
+ for key, value in gates.items():
805
+ if key not in columns:
806
+ continue
807
+ low, high = float(value[0]), float(value[1])
808
+ match = (columns[key] > low) & (columns[key] < high)
809
+ if mode == 'and':
810
+ keep &= match
811
+ else:
812
+ keep |= match
813
+ return keep
814
+
815
+
816
+ def get_channel_cells(datasource_name, channels):
817
+ global datasource
818
+
819
+ _ensure_loaded(datasource_name)
820
+ if not _project(datasource_name).has_table:
821
+ return []
822
+
823
+ if not channels:
824
+ return []
825
+
826
+ gate_range = (0, 65536)
827
+ columns = get_filter_columns(datasource_name, channels)
828
+ keep = apply_range_mask(columns, {c: gate_range for c in channels}, mode='and')
829
+ ids = datasource['id'].to_numpy()[keep].tolist()
830
+ return [{'id': v} for v in ids]
831
+
832
+
833
+ def get_phenotype_description(datasource):
834
+ try:
835
+ data = ''
836
+ csvPath = _project(datasource).dataset.celltype_data
837
+ if Path(csvPath).is_file():
838
+ #old os.path usage: if os.path.isfile(csvPath):
839
+ data = pl.read_csv(csvPath)
840
+ data = data.to_numpy().tolist()
841
+ # data = data.to_json(orient='records', lines=True)
842
+ return data;
843
+ except (KeyError, IndexError):
844
+ return ''
845
+ except TypeError:
846
+ return ''
847
+
848
+
849
+ def get_phenotype_column_name(datasource):
850
+ return _project(datasource).roles.celltype or ''
851
+
852
+
853
+ def get_cells_phenotype(datasource_name):
854
+ global datasource
855
+ global source
856
+ global ball_tree
857
+
858
+ range = [0, 65536]
859
+
860
+ # Load if not loaded
861
+ _ensure_loaded(datasource_name)
862
+ if not _project(datasource_name).has_table:
863
+ return []
864
+
865
+ phenotype_field = _project(datasource_name).roles.celltype or 'celltype'
866
+
867
+ query = datasource.select(['id', phenotype_field]).to_dicts()
868
+ return query
869
+
870
+
871
+ def get_all_cells(datasource_name, start_keys, data_type=float):
872
+ global datasource
873
+ global source
874
+
875
+ # Load if not loaded
876
+ _ensure_loaded(datasource_name)
877
+ if not _project(datasource_name).has_table:
878
+ return np.array([], dtype=np.uint32 if np.issubdtype(data_type, int) else np.float32)
879
+
880
+ query = datasource.select(start_keys).to_numpy().flatten()
881
+ if np.issubdtype(data_type, int):
882
+ return query.astype(np.uint32)
883
+ return query.astype(np.float32)
884
+
885
+
886
+ def get_centroid_manifest(datasource_name):
887
+ global config
888
+ if config is None or datasource_name not in config:
889
+ load_config(datasource_name)
890
+ return centroid_tiles.get_manifest(config, datasource_name, build=True)
891
+
892
+
893
+ def get_centroid_tiles(datasource_name, level, tiles, gates=None, max_points=None):
894
+ global config
895
+ if config is None or datasource_name not in config:
896
+ load_config(datasource_name)
897
+ return centroid_tiles.get_tiles(config, datasource_name, level, tiles, gates or {}, max_points)
898
+
899
+
900
+ def save_channel_list(datasource_name, map_channels, active_channels, list_colors, list_ranges, list_channels):
901
+ global datasource
902
+ global source
903
+ global ball_tree
904
+
905
+ # Load if not loaded
906
+ _ensure_loaded(datasource_name)
907
+ rows = []
908
+ for channel in map_channels:
909
+ channel_name = map_channels[channel]
910
+ rows.append([channel_name, list_channels[channel_name][0], list_channels[channel_name][1], 255, 255, 255, 1, False])
911
+ csv = pl.DataFrame(rows, schema=['channel', 'start', 'end', 'r', 'g', 'b', 'opacity', 'channel_active'], orient='row')
912
+
913
+ schema = csv.schema
914
+ for channel in list_colors:
915
+ is_channel = pl.col('channel') == map_channels[channel]
916
+ color = list_colors[channel]['color']
917
+ csv = csv.with_columns([
918
+ pl.when(is_channel).then(pl.lit(color['r']).cast(schema['r'])).otherwise(pl.col('r')).alias('r'),
919
+ pl.when(is_channel).then(pl.lit(color['g']).cast(schema['g'])).otherwise(pl.col('g')).alias('g'),
920
+ pl.when(is_channel).then(pl.lit(color['b']).cast(schema['b'])).otherwise(pl.col('b')).alias('b'),
921
+ pl.when(is_channel).then(pl.lit(color['opacity']).cast(schema['opacity'])).otherwise(pl.col('opacity')).alias('opacity'),
922
+ ])
923
+ for channel in active_channels:
924
+ is_channel = pl.col('channel') == map_channels[channel]
925
+ csv = csv.with_columns(
926
+ pl.when(is_channel).then(pl.lit(True)).otherwise(pl.col('channel_active')).alias('channel_active')
927
+ )
928
+
929
+ temp = csv.to_dicts()
930
+ f = pickle.dumps(temp, protocol=4)
931
+ database_model.save_list(database_model.ChannelList, datasource=datasource_name, cells=f)
932
+
933
+
934
+
935
+
936
+ def get_saved_channel_list(datasource_name):
937
+ channel_list = database_model.get(database_model.ChannelList, datasource=datasource_name)
938
+ if channel_list is None:
939
+ return None
940
+ return pickle.loads(channel_list.cells)
941
+
942
+
943
+ def _describe_numeric(df):
944
+ """Vectorized equivalent of df.describe().to_dict() for numeric columns.
945
+ Avoids pandas' per-column describe() loop, which is slow at millions of
946
+ rows across dozens of columns.
947
+ """
948
+ numeric_df = df.select(cs.numeric())
949
+ values = numeric_df.cast(pl.Float64).to_numpy()
950
+ count = np.sum(~np.isnan(values), axis=0)
951
+ mean = np.nanmean(values, axis=0)
952
+ std = np.nanstd(values, axis=0, ddof=1)
953
+ minimum = np.nanmin(values, axis=0)
954
+ maximum = np.nanmax(values, axis=0)
955
+ q25, q50, q75 = np.nanpercentile(values, [25, 50, 75], axis=0)
956
+ description = {}
957
+ for i, column in enumerate(numeric_df.columns):
958
+ description[column] = {
959
+ 'count': count[i],
960
+ 'mean': mean[i],
961
+ 'std': std[i],
962
+ 'min': minimum[i],
963
+ '25%': q25[i],
964
+ '50%': q50[i],
965
+ '75%': q75[i],
966
+ 'max': maximum[i],
967
+ }
968
+ return description
969
+
970
+
971
+
972
+ def get_datasource_description(datasource_name):
973
+ global datasource
974
+ global source
975
+ global ball_tree
976
+ global config
977
+
978
+ # Load if not loaded
979
+ _ensure_loaded(datasource_name)
980
+
981
+ if datasource_name in _description_cache:
982
+ return _description_cache[datasource_name]
983
+
984
+ if not _project(datasource_name).has_table:
985
+ _description_cache[datasource_name] = {}
986
+ return {}
987
+
988
+ description = _describe_numeric(datasource)
989
+ for column in description:
990
+ column_data = datasource[column].to_numpy()
991
+ [hist, bin_edges] = np.histogram(column_data[~np.isnan(column_data)], bins=50, density=True)
992
+ midpoints = (bin_edges[1:] + bin_edges[:-1]) / 2
993
+ description[column]['histogram'] = {}
994
+ dat = []
995
+ for i in range(len(hist)):
996
+ obj = {}
997
+ obj['x'] = midpoints[i]
998
+ obj['y'] = hist[i]
999
+ dat.append(obj)
1000
+ description[column]['histogram'] = dat
1001
+
1002
+ _description_cache[datasource_name] = description
1003
+ return description
1004
+
1005
+
1006
+ def get_channel_gmm(channel_name, datasource_name):
1007
+ _ensure_loaded(datasource_name)
1008
+ cache_key = (datasource_name, channel_name)
1009
+ cached = _gmm_cache.get(cache_key)
1010
+ if cached is not None:
1011
+ return cached
1012
+ # Single-flight. encode_tile() calls this for every default-quality tile,
1013
+ # and a cold entry costs ~0.7 s: a GaussianMixture(3) fit plus a .max()
1014
+ # over the entire full-resolution channel plane (~218 MB, see the comment
1015
+ # on qmax below for why a downsampled source is not valid here). Without
1016
+ # this lock, the burst of concurrent tile requests that opening a project
1017
+ # produces would each redo that same work for the same channel.
1018
+ with _gmm_compute_lock(cache_key):
1019
+ cached = _gmm_cache.get(cache_key)
1020
+ if cached is not None:
1021
+ return cached
1022
+ return _compute_channel_gmm(channel_name, datasource_name, cache_key)
1023
+
1024
+
1025
+ def get_channel_quantization_window(channel_name, datasource_name):
1026
+ """(qmin, qmax) for the default (non-HD) WebP tile path.
1027
+
1028
+ Deliberately separate from the GMM's vmin/vmax (the display/contrast-slider
1029
+ default) AND from get_channel_gmm() itself: encode_tile() needs only this
1030
+ window, while the surrounding GaussianMixture fit costs ~1 s per channel.
1031
+ Folding the two together made every first tile of a channel block on that
1032
+ fit -- with 7+ channels active that was several seconds of stall on the
1033
+ first pan after opening a project. The max below is ~8 ms by comparison.
1034
+
1035
+ Straight linear (data/max)*255: no clipping anywhere, ever, at the cost of
1036
+ coarser uint8 steps through the bulk of the image whenever a channel has a
1037
+ single much-brighter-than-typical peak (verified against real slide data --
1038
+ see webp_compare_report.pdf for the tradeoff vs a percentile window).
1039
+
1040
+ The `zarray` sample is NOT a valid source for this ceiling, even though
1041
+ it's already loaded: it's mean-pooled down from full resolution (a pyramid
1042
+ level plus an additional block_reduce, ~1000x area averaging in a typical
1043
+ whole-slide image here). Mean-pooling dilutes real single/few-pixel peaks
1044
+ far below what the full-resolution tiles served by encode_tile() contain --
1045
+ using it as a max-based ceiling under-clips real data. Verified live: it
1046
+ caused whole channels to saturate to a single solid color, since most
1047
+ full-res pixels legitimately exceeded that artificially low ceiling. The
1048
+ true max requires reading full-resolution data at least once.
1049
+ """
1050
+ _ensure_loaded(datasource_name)
1051
+ cache_key = ('qwindow', datasource_name, channel_name)
1052
+ cached = _gmm_cache.get(cache_key)
1053
+ if cached is not None:
1054
+ return cached
1055
+ with _gmm_compute_lock(cache_key):
1056
+ cached = _gmm_cache.get(cache_key)
1057
+ if cached is not None:
1058
+ return cached
1059
+ real_channels = [d for d in config[datasource_name]['imageData'] if d['fullname'] != 'Area']
1060
+ idx = next(i for (i, d) in enumerate(real_channels) if d['fullname'] == channel_name)
1061
+ if isinstance(channels, zarr.Array):
1062
+ full_res_channel = channels[idx]
1063
+ else:
1064
+ full_res_channel = _zarr_level(channels, 0)[idx]
1065
+ window = (0.0, max(float(np.asarray(full_res_channel).max()), 1.0))
1066
+ _gmm_cache[cache_key] = window
1067
+ return window
1068
+
1069
+
1070
+ def _compute_channel_gmm(channel_name, datasource_name, cache_key):
1071
+ global datasource
1072
+ global source
1073
+ global ball_tree
1074
+ global config
1075
+
1076
+ packet_gmm = {}
1077
+
1078
+ # zarray only ever holds the real image channels (Area is a Plexora-side
1079
+ # UI placeholder, never part of the physical image) -- so the correct
1080
+ # zarray index is the channel's position among imageData entries with
1081
+ # fullname != 'Area', not its raw imageData index minus a hardcoded 1
1082
+ # (which was only correct when segmentation put Area at position 0).
1083
+ real_channels = [d for d in config[datasource_name]['imageData'] if d['fullname'] != 'Area']
1084
+ image_channelIdx = next(index for (index, d) in enumerate(real_channels) if d["fullname"] == channel_name)
1085
+ image_data = zarray[image_channelIdx]
1086
+ nonzero = image_data[image_data > 0]
1087
+ img_log = np.log(nonzero)
1088
+ gmm = GaussianMixture(3, max_iter=1000, tol=1e-6)
1089
+ gmm.fit(img_log.reshape((-1, 1)))
1090
+
1091
+ means = gmm.means_[:, 0]
1092
+ i0, i1, i2 = np.argsort(means)
1093
+ mean1, mean2 = means[[i1, i2]]
1094
+ std1, std2 = gmm.covariances_[[i1, i2], 0, 0] ** 0.5
1095
+
1096
+ x = np.linspace(mean1, mean2, 50)
1097
+ y1 = norm(mean1, std1).pdf(x) * gmm.weights_[i1]
1098
+ y2 = norm(mean2, std2).pdf(x) * gmm.weights_[i2]
1099
+
1100
+ lmax = mean2 + 2 * std2
1101
+ lmin = x[np.argmin(np.abs(y1 - y2))]
1102
+ if lmin >= mean2:
1103
+ lmin = mean2 - 2 * std2
1104
+ vmin = max(np.exp(lmin), image_data.min(), 0)
1105
+ vmax = min(np.exp(lmax), image_data.max())
1106
+ packet_gmm['vmin'] = np.rint(vmin)
1107
+ packet_gmm['vmax'] = np.rint(vmax)
1108
+
1109
+ # Quantization window for the default (non-HD) WebP tile path. Computed by
1110
+ # get_channel_quantization_window() so the tile path can reach it without
1111
+ # paying for the GaussianMixture fit above; see that function for why the
1112
+ # ceiling has to come from full-resolution data.
1113
+ qmin, qmax = get_channel_quantization_window(channel_name, datasource_name)
1114
+ packet_gmm['qmin'] = qmin
1115
+ packet_gmm['qmax'] = qmax
1116
+
1117
+
1118
+ [hist, bin_edges] = np.histogram(img_log.flatten(), bins=50, density=True)
1119
+ midpoints = (bin_edges[1:] + bin_edges[:-1]) / 2
1120
+
1121
+ covars = gmm.covariances_[:, 0, 0]
1122
+ weights = gmm.weights_
1123
+ pdf_gmm1 = weights[i0] * norm.pdf(midpoints, means[i0], np.sqrt(covars[i0]))
1124
+ pdf_gmm2 = weights[i1] * norm.pdf(midpoints, means[i1], np.sqrt(covars[i1]))
1125
+ pdf_gmm3 = weights[i2] * norm.pdf(midpoints, means[i2], np.sqrt(covars[i2]))
1126
+
1127
+ dat_gmm1 = []
1128
+ dat_gmm2 = []
1129
+ dat_gmm3 = []
1130
+ for i in range(len(hist)):
1131
+ obj1 = {}
1132
+ obj1['x'] = midpoints[i]
1133
+ obj1['y'] = pdf_gmm1[i]
1134
+ dat_gmm1.append(obj1)
1135
+
1136
+ obj2 = {}
1137
+ obj2['x'] = midpoints[i]
1138
+ obj2['y'] = pdf_gmm2[i]
1139
+ dat_gmm2.append(obj2)
1140
+
1141
+ obj3 = {}
1142
+ obj3['x'] = midpoints[i]
1143
+ obj3['y'] = pdf_gmm3[i]
1144
+ dat_gmm3.append(obj3)
1145
+
1146
+ packet_gmm['image_gmm_1'] = dat_gmm1
1147
+ packet_gmm['image_gmm_2'] = dat_gmm2
1148
+ packet_gmm['image_gmm_3'] = dat_gmm3
1149
+
1150
+ _gmm_cache[cache_key] = packet_gmm
1151
+ return packet_gmm
1152
+
1153
+
1154
+
1155
+ # Percentiles of the log-intensity distribution used for vmin_hint/vmax_hint
1156
+ # below. Chosen by measurement, not taste: swept 7 vmin x 5 vmax candidates
1157
+ # against the real GMM auto-level for all 19 channels of the reference slide,
1158
+ # scoring the error in the BYTE domain (what the shader actually applies, and
1159
+ # where a big raw-unit error high up the range can be worth only a byte or
1160
+ # two). p50/p99.5 won on worst case -- 15 byte-levels of combined error, mean
1161
+ # 6.6. For comparison the [0, 255] default this replaces is ~234 off for a
1162
+ # typical channel, which is why a newly enabled channel looks black today.
1163
+ _HINT_PERCENTILES = (50.0, 99.5)
1164
+
1165
+
1166
+ def get_image_channel_stats(channel_name, datasource_name):
1167
+ """Per-channel image_min/image_max/image_histogram, split out of
1168
+ get_datasource_description so a page load only pays for the channels the
1169
+ user actually activates instead of every channel up front.
1170
+
1171
+ Also carries two things the client needs *before* it can show a channel,
1172
+ both far cheaper than the GaussianMixture fit in get_channel_gmm():
1173
+
1174
+ - qmin/qmax, the server's quantization window. The client stores channel
1175
+ ranges in raw 16-bit units but its slider and shader work in the [0, 255]
1176
+ byte domain, so it cannot even display a *saved* range without these.
1177
+ They used to be reachable only as two extra fields on the GMM packet,
1178
+ which meant restoring saved channels ran a ~1 s fit per channel purely to
1179
+ read them -- serialized, so 3 saved channels cost 5.8 s on a cold server.
1180
+ - vmin_hint/vmax_hint, a provisional auto-level so a newly enabled channel
1181
+ is visible immediately instead of near-black until the fit lands.
1182
+
1183
+ The GMM stays authoritative: the client applies the hint on arrival and
1184
+ replaces it with the real vmin/vmax once the fit completes.
1185
+ """
1186
+ global zarray
1187
+ global config
1188
+
1189
+ _ensure_loaded(datasource_name)
1190
+
1191
+ cache_key = (datasource_name, channel_name)
1192
+ if cache_key in _image_stats_cache:
1193
+ return _image_stats_cache[cache_key]
1194
+
1195
+ real_channels = [d for d in config[datasource_name]['imageData'] if d['fullname'] != 'Area']
1196
+ image_channelIdx = next(index for (index, d) in enumerate(real_channels) if d["fullname"] == channel_name)
1197
+ image_data = zarray[image_channelIdx]
1198
+ img_log = np.log(image_data[image_data > 0])
1199
+ [hist, bin_edges] = np.histogram(img_log.flatten(), bins=50, density=True)
1200
+ midpoints = (bin_edges[1:] + bin_edges[:-1]) / 2
1201
+
1202
+ dat = []
1203
+ for i in range(len(hist)):
1204
+ obj = {}
1205
+ obj['x'] = midpoints[i]
1206
+ obj['y'] = hist[i]
1207
+ dat.append(obj)
1208
+
1209
+ pmin, pmax = _HINT_PERCENTILES
1210
+ qmin, qmax = get_channel_quantization_window(channel_name, datasource_name)
1211
+
1212
+ stats = {
1213
+ 'image_histogram': dat,
1214
+ 'image_min': np.ceil(np.exp(np.min(img_log))),
1215
+ 'image_max': np.ceil(np.exp(np.max(img_log))),
1216
+ 'qmin': qmin,
1217
+ 'qmax': qmax,
1218
+ 'vmin_hint': float(np.rint(np.exp(np.percentile(img_log, pmin)))),
1219
+ 'vmax_hint': float(np.rint(np.exp(np.percentile(img_log, pmax)))),
1220
+ }
1221
+ _image_stats_cache[cache_key] = stats
1222
+ return stats
1223
+
1224
+
1225
+ def ensure_loaded(datasource_name):
1226
+ """Load `datasource_name` if it isn't already the loaded one, and return the
1227
+ resulting load_generation.
1228
+
1229
+ Callers that key a cache on load_generation must call this BEFORE reading
1230
+ the generation: loading is what bumps it, so a generation sampled first
1231
+ would key the entry under the pre-load value and be missed by every
1232
+ subsequent request.
1233
+ """
1234
+ if _loaded_source != loaded_scope(datasource_name):
1235
+ load_datasource(datasource_name)
1236
+ return load_generation
1237
+
1238
+
1239
+ def generate_zarr_png(datasource_name, channel, level, tile):
1240
+ global channels
1241
+ global seg
1242
+ ensure_loaded(datasource_name)
1243
+ [tx, ty] = tile.replace('.png', '').split('_')
1244
+ tx = int(tx)
1245
+ ty = int(ty)
1246
+ level = int(level)
1247
+ tile_width = config[datasource_name]['tileWidth']
1248
+ tile_height = config[datasource_name]['tileHeight']
1249
+ ix = tx * tile_width
1250
+ iy = ty * tile_height
1251
+ channel_num, segmentation = _parse_channel(channel)
1252
+ if segmentation:
1253
+ tile = _zarr_level(seg, level)[iy:iy + tile_height, ix:ix + tile_width]
1254
+ if tile.dtype.itemsize != 4:
1255
+ tile = tile.astype(np.uint32)
1256
+ tile = tile.view('uint8').reshape(tile.shape + (-1,))[..., [0, 1, 2]]
1257
+ tile = np.append(tile, np.zeros((tile.shape[0], tile.shape[1], 1), dtype='uint8'), axis=2)
1258
+ else:
1259
+ if isinstance(channels, zarr.Array):
1260
+ tile = channels[channel_num, iy:iy + tile_height, ix:ix + tile_width]
1261
+ else:
1262
+ tile = _zarr_level(channels, level)[channel_num, iy:iy + tile_height, ix:ix + tile_width]
1263
+ tile = tile.astype('uint16')
1264
+
1265
+ # tile = np.ascontiguousarray(tile, dtype='uint32')
1266
+ # png = tile.view('uint8').reshape(tile.shape + (-1,))[..., [2, 1, 0]]
1267
+ return tile
1268
+
1269
+
1270
+ def _parse_channel(channel):
1271
+ """Returns (channel_num, is_segmentation) for a channel identifier like
1272
+ "<file>_<N>" (channel_num=N), or a segmentation/label channel name with
1273
+ no trailing "_<N>" (is_segmentation=True)."""
1274
+ try:
1275
+ return int(re.match(r".*_(\d*)$", channel).groups()[0]), False
1276
+ except AttributeError:
1277
+ return None, True
1278
+
1279
+
1280
+ def _channel_num_to_name(datasource_name, channel_num):
1281
+ real_channels = [d for d in config[datasource_name]['imageData'] if d['fullname'] != 'Area']
1282
+ return real_channels[channel_num]['fullname']
1283
+
1284
+ # Cache of 65536-entry uint16 -> uint8 quantization tables, keyed by the
1285
+ # (qmin, span) window they encode. Applying one is a single gather over the
1286
+ # tile; the arithmetic form it replaces promoted each 1024x1024 tile to float64
1287
+ # (an 8 MB temporary) and made four passes over it for the rint/clip/astype.
1288
+ _quantize_lut_cache = {}
1289
+ _quantize_lut_lock = threading.Lock()
1290
+
1291
+
1292
+ def _quantize_to_uint8(array, qmin, span):
1293
+ """Linearly quantize `array` from [qmin, qmin + span] into [0, 255]."""
1294
+ if array.dtype != np.uint16:
1295
+ # Non-uint16 sources (a non-pyramidal zarr keeps its native dtype) can't
1296
+ # index a 65536-entry table -- fall back to the arithmetic form.
1297
+ return np.clip(np.rint((array.astype(np.float32) - qmin) / span * 255), 0, 255).astype(np.uint8)
1298
+ key = (float(qmin), float(span))
1299
+ with _quantize_lut_lock:
1300
+ lut = _quantize_lut_cache.get(key)
1301
+ if lut is None:
1302
+ levels = np.arange(65536, dtype=np.float32)
1303
+ lut = np.clip(np.rint((levels - qmin) / span * 255), 0, 255).astype(np.uint8)
1304
+ with _quantize_lut_lock:
1305
+ _quantize_lut_cache[key] = lut
1306
+ return lut[array]
1307
+
1308
+
1309
+
1310
+ def encode_tile(datasource_name, channel, level, tile, quality):
1311
+ """Returns (encoded_bytes, mimetype) for one tile request. `quality` is
1312
+ 'hd' for full-precision 16-bit, 'legacy' for the original uncompressed
1313
+ PNG behavior, anything else selects the fast/default 8-bit-quantized
1314
+ WebP path for channel tiles. Segmentation tiles ignore `quality`
1315
+ entirely -- they always need byte-exact label IDs, and always use the
1316
+ fast libdeflate PNG encoder (never WebP: verified that WebP lossless,
1317
+ even with exact=True at encode time, gets its RGB corrupted by the
1318
+ browser's own decoder wherever alpha=0 -- which is every pixel here --
1319
+ regardless of decode API used; PNG has no such decode-side risk since
1320
+ the frontend parses PNG bytes directly via UPNG.js, not a canvas)."""
1321
+ array = generate_zarr_png(datasource_name, channel, level, tile)
1322
+ channel_num, is_segmentation = _parse_channel(channel)
1323
+
1324
+ if is_segmentation:
1325
+ return fast_png.encode_rgba8_png(array), 'image/png'
1326
+
1327
+ if quality == 'hd':
1328
+ # Stored (uncompressed) deflate, not level 6. The client decodes 16-bit
1329
+ # PNG with UPNG.js/pako in JavaScript, and a CPU profile of a 7-channel
1330
+ # HD pan put 81% of ALL time in pako's inflate (inflate_fast alone was
1331
+ # 71%). Storing the data uncompressed leaves nothing to inflate: the
1332
+ # stream is copied rather than Huffman-decoded. Costs ~15% more bytes
1333
+ # over what is normally a loopback connection, and also drops the
1334
+ # server-side encode from ~37 ms to a few ms.
1335
+ return fast_png.encode_gray16_png(array, compress_level=0), 'image/png'
1336
+
1337
+ if quality == 'legacy':
1338
+ file_object = io.BytesIO()
1339
+ Image.fromarray(array).save(file_object, 'PNG', compress_level=0)
1340
+ return file_object.getvalue(), 'image/png'
1341
+
1342
+ # Default: quantize linearly into [0, channel_max] (see
1343
+ # get_channel_quantization_window) -- deliberately NOT vmin/vmax, which is
1344
+ # the narrower GMM display/contrast-slider window applied separately
1345
+ # client-side. This window never clips, at the cost of a coarser uint8 step
1346
+ # size across the image whenever one pixel is much brighter than the rest
1347
+ # (see webp_compare_report.pdf for the measured tradeoff).
1348
+ #
1349
+ # Note this asks for the window only, NOT the full get_channel_gmm packet:
1350
+ # the GaussianMixture fit in there costs ~1 s per channel and nothing on
1351
+ # the tile path needs its output.
1352
+ channel_name = _channel_num_to_name(datasource_name, channel_num)
1353
+ qmin, qmax = get_channel_quantization_window(channel_name, datasource_name)
1354
+ span = qmax - qmin # qmax is guarded >= 1 and qmin is 0, so span >= 1
1355
+ quantized = _quantize_to_uint8(array, qmin, span)
1356
+ file_object = io.BytesIO()
1357
+ # method=0, not libwebp's default-ish method=6. Measured on a real
1358
+ # 1024x1024 tile from this dataset (encode time / output bytes):
1359
+ # method=0 21.2 ms 64410 method=4 58.3 ms 59876
1360
+ # method=1 26.0 ms 64288 method=6 97.0 ms 58884
1361
+ # method=2 28.7 ms 61698
1362
+ # Encoding is ~70% of the remaining per-tile cost (zarr read is 7.9 ms,
1363
+ # LUT quantization 1.1 ms), and these tiles are generated on demand while
1364
+ # the user pans. 9% more bytes to localhost is far cheaper than 76 ms of
1365
+ # extra latency per tile per channel.
1366
+ Image.fromarray(quantized, mode='L').save(file_object, 'WEBP', quality=90, method=0)
1367
+ return file_object.getvalue(), 'image/webp'
1368
+
1369
+
1370
+ def generate_channel_overview(datasource_name, channel_name):
1371
+ """One channel's whole-tissue overview, for the viewer's mini-map.
1372
+
1373
+ Returns WebP bytes, or None if `channel_name` is not one of this
1374
+ datasource's image channels.
1375
+
1376
+ Two properties make this the right source, and both are easy to lose:
1377
+
1378
+ - It is `zarray`, the downsampled array load_datasource() already holds
1379
+ (~200-400 px per side, every channel, resident for the loaded project).
1380
+ So this costs a quantize and an encode, with no zarr read at all. The
1381
+ tile route cannot substitute: `tileWidth` is fixed at 1024 while pyramid
1382
+ depth is whatever the source file happened to be written with, so "the
1383
+ coarsest level" is a 1x1 tile grid for some files and 4x4 for others,
1384
+ and there is no level that is reliably one whole-image tile.
1385
+ - It is quantized with get_channel_quantization_window() -- the SAME window
1386
+ encode_tile() uses -- so the bytes land in the same [0, 255] domain the
1387
+ contrast slider works in, and the mini-map needs no colour conversion of
1388
+ its own to match what the main viewer draws.
1389
+
1390
+ Note the window comes from full-resolution data even though the pixels
1391
+ here do not. That split is deliberate: get_channel_quantization_window()'s
1392
+ docstring records that deriving the ceiling FROM `zarray` saturates whole
1393
+ channels, because mean-pooling dilutes the real peaks. Pooled pixels
1394
+ against a full-res ceiling is correct; a pooled ceiling is not.
1395
+ """
1396
+ _ensure_loaded(datasource_name)
1397
+
1398
+ # zarray only ever holds the real image channels -- 'Area' is a
1399
+ # Plexora-side UI placeholder, never part of the physical image -- so the
1400
+ # index is the channel's position among the non-Area entries, not its raw
1401
+ # imageData index. Same reasoning as _compute_channel_gmm().
1402
+ real_channels = [d for d in config[datasource_name]['imageData'] if d['fullname'] != 'Area']
1403
+ image_channelIdx = next(
1404
+ (i for (i, d) in enumerate(real_channels) if d['fullname'] == channel_name), None
1405
+ )
1406
+ if image_channelIdx is None:
1407
+ return None
1408
+
1409
+ qmin, qmax = get_channel_quantization_window(channel_name, datasource_name)
1410
+ span = qmax - qmin # qmax is guarded >= 1 and qmin is 0, so span >= 1
1411
+ quantized = _quantize_to_uint8(np.asarray(zarray[image_channelIdx]), qmin, span)
1412
+
1413
+ file_object = io.BytesIO()
1414
+ # Fully opaque mode 'L', so the browser-side WebP alpha corruption that
1415
+ # rules WebP out for label tiles cannot apply here.
1416
+ #
1417
+ # Lossless, unlike the tile path. Measured on this array (298x357):
1418
+ # lossless is 50408 B / 3.0 ms and byte-exact, quality=90 is 23514 B /
1419
+ # 3.4 ms with a max error of 11 grey levels. Tiles can afford that error
1420
+ # because they are viewed at their own scale; the mini-map cannot, because
1421
+ # the client applies the contrast window ON TOP of these bytes, and a
1422
+ # narrow window multiplies a small byte error into a large visible one.
1423
+ # 27 KB once per channel is not worth an artefact the slider amplifies.
1424
+ Image.fromarray(quantized, mode='L').save(file_object, 'WEBP', lossless=True, method=0)
1425
+ return file_object.getvalue()
1426
+
1427
+
1428
+ def generate_thumbnail(datasource_name, max_size=320):
1429
+ """Cheap preview image for the Open Project grid. Deliberately does NOT
1430
+ go through load_datasource/encode_tile -- those pull in the full feature
1431
+ table, ball tree and segmentation (see load_datasource above), which is
1432
+ far more than a thumbnail needs and would make browsing a page of many
1433
+ projects trigger a full data load per card. This opens only the channel
1434
+ image file and reads the smallest pyramid level with both dims >= 200
1435
+ (same level-selection heuristic load_datasource uses for its own
1436
+ overview array), so it never touches the shared source/channels/seg
1437
+ globals other requests depend on.
1438
+
1439
+ Returns (encoded_bytes, mimetype), or None if the project has no
1440
+ channel image yet or it can't be opened.
1441
+ """
1442
+ cfg = Project.load_all()
1443
+ entry = cfg.get(datasource_name)
1444
+ channel_file = entry.get('channelFile') if entry else None
1445
+ if not channel_file or not Path(channel_file).exists():
1446
+ return None
1447
+
1448
+ try:
1449
+ channel_io = tf.TiffFile(channel_file, is_ome=False)
1450
+ level_series = next(
1451
+ level for level in reversed(channel_io.series[0].levels)
1452
+ if all(d >= 200 for d in level.shape[1:])
1453
+ )
1454
+ array = np.asarray(zarr.open(level_series.aszarr()))
1455
+ except Exception:
1456
+ return None
1457
+
1458
+ if array.ndim == 3:
1459
+ array = array[0]
1460
+ array = array.astype(np.float32)
1461
+ low, high = np.percentile(array, [1, 99])
1462
+ span = max(high - low, 1)
1463
+ quantized = np.clip((array - low) / span * 255, 0, 255).astype(np.uint8)
1464
+
1465
+ image = Image.fromarray(quantized, mode='L')
1466
+ image.thumbnail((max_size, max_size))
1467
+ file_object = io.BytesIO()
1468
+ image.save(file_object, 'WEBP', quality=85, method=6)
1469
+ return file_object.getvalue(), 'image/webp'
1470
+
1471
+
1472
+ def get_ome_metadata(datasource_name):
1473
+ global metadata
1474
+ # `metadata` is {} (not None) when the OME-XML fails to parse, so it was
1475
+ # never a reliable loadedness signal either -- use _loaded_source.
1476
+ if _loaded_source != loaded_scope(datasource_name):
1477
+ load_datasource(datasource_name)
1478
+ return metadata
1479
+
1480
+
1481
+ def convertOmeTiff(filePath, channelFilePath=None, dataDirectory=None, isLabelImg=False,
1482
+ progress_callback=None, segmentation_mode_=segmentation_pyramid.DEFAULT_MODE):
1483
+ channel_info = {}
1484
+ channelNames = []
1485
+
1486
+ # image is a normal channel?
1487
+ if isLabelImg == False:
1488
+ channel_io = tf.TiffFile(str(filePath), is_ome=False)
1489
+ channels = zarr.open(channel_io.series[0].aszarr())
1490
+ if isinstance(channels, zarr.Array):
1491
+ channel_info['maxLevel'] = 1
1492
+ chunks = channels.chunks
1493
+ shape = channels.shape
1494
+ else:
1495
+ channel_info['maxLevel'] = len(channels)
1496
+ shape = _zarr_level(channels, 0).shape
1497
+ chunks = (1, 1024, 1024)
1498
+ chunks = (chunks[-2], chunks[-1])
1499
+ channel_info['tileHeight'] = chunks[0]
1500
+ channel_info['tileWidth'] = chunks[1]
1501
+ channel_info['height'] = shape[1]
1502
+ channel_info['width'] = shape[2]
1503
+ channel_info['num_channels'] = shape[0]
1504
+ for i in range(shape[0]):
1505
+ channelName = re.sub(r'\.ome\.tiff|\.ome\.tif|\.tiff|\.tif|\.png', '', filePath.name) + "_" + str(i)
1506
+ channelNames.append(channelName)
1507
+ channel_info['channel_names'] = channelNames
1508
+ return channel_info
1509
+
1510
+ # segmentation mask. `channelFilePath` is accepted for call-site
1511
+ # compatibility but no longer read: the mask's own geometry is all the
1512
+ # conversion needs, and opening the (much larger) channel image here just
1513
+ # to discard it cost a file handle per import.
1514
+ else:
1515
+ write_path = resolve_outline_segmentation(
1516
+ filePath, dataDirectory, progress_callback=progress_callback,
1517
+ mode=segmentation_mode_,
1518
+ )
1519
+ return {'segmentation': write_path}
1520
+
1521
+
1522
+ _segmentation_jobs = {}
1523
+ _segmentation_job_locks = {}
1524
+ _segmentation_job_locks_guard = threading.Lock()
1525
+
1526
+
1527
+ def _segmentation_job_lock_for(datasource_name):
1528
+ with _segmentation_job_locks_guard:
1529
+ if datasource_name not in _segmentation_job_locks:
1530
+ _segmentation_job_locks[datasource_name] = threading.Lock()
1531
+ return _segmentation_job_locks[datasource_name]
1532
+
1533
+
1534
+ def _patch_config_segmentation(datasource_name, segmentation_path, status,
1535
+ segmentation_source=None):
1536
+ # The same lock every other writer takes -- this runs on the segmentation
1537
+ # job's thread, so it can land on top of a request saving an edit.
1538
+ config_file = Project.config_path_for(datasource_name)
1539
+ if not paths.is_writable(config_file.parent):
1540
+ # A shared project's registry entry is not ours to patch. The derived
1541
+ # mask still went to a root this user can write (see
1542
+ # paths.derived_root); what cannot be recorded is the pointer to it,
1543
+ # so the conversion is redone next time rather than silently half-done.
1544
+ return
1545
+ with config_transaction():
1546
+ cfg = read_config(config_file)
1547
+ if datasource_name not in cfg:
1548
+ return # datasource was deleted while the job was running
1549
+ entry = cfg[datasource_name]
1550
+ entry['segmentation'] = segmentation_path
1551
+ entry['segmentation_status'] = status
1552
+ if segmentation_source is not None:
1553
+ # Record which source this derived file came from, so a later load
1554
+ # can confirm it is still current with a stat rather than by
1555
+ # re-deriving or re-sampling pixels (see refresh_segmentation_mapping).
1556
+ entry['segmentationSource'] = str(segmentation_source)
1557
+ entry['segmentationSourceKey'] = segmentation_pyramid.source_fingerprint(
1558
+ segmentation_source
1559
+ )
1560
+ write_config(config_file, cfg)
1561
+
1562
+
1563
+ def start_segmentation_job(datasource_name, label_file, data_directory,
1564
+ mode=segmentation_pyramid.DEFAULT_MODE):
1565
+ """Convert a label mask into the layer the viewer draws, on a background
1566
+ thread, so the request that triggers it never blocks on the conversion.
1567
+
1568
+ That conversion is tens of seconds on a large mask, so both import flows
1569
+ start it the moment their first form page is submitted and then report
1570
+ progress out of get_segmentation_job_status() while the user works through
1571
+ the second page.
1572
+ """
1573
+ lock = _segmentation_job_lock_for(datasource_name)
1574
+ if not lock.acquire(blocking=False):
1575
+ return # already running for this datasource
1576
+ # Reading this costs a header parse, and it is the only chance to explain
1577
+ # *why* a mask the user thinks is ready is being converted anyway.
1578
+ work = describe_segmentation_work(label_file, mode)
1579
+ _segmentation_jobs[datasource_name] = {
1580
+ "status": "pending",
1581
+ "error": None,
1582
+ "progress": 0,
1583
+ "message": work,
1584
+ }
1585
+
1586
+ def _run():
1587
+ def report(done, total):
1588
+ percent = int(done * 100 / total) if total else 0
1589
+ record = _segmentation_jobs.get(datasource_name)
1590
+ # Only touch the record on a percentage change: this fires once per
1591
+ # written tile, which is thousands of calls on a large pyramid.
1592
+ if record is not None and record.get("progress") != percent:
1593
+ record["progress"] = percent
1594
+ record["message"] = f"{work} ({percent}%)"
1595
+
1596
+ try:
1597
+ result = convertOmeTiff(
1598
+ label_file,
1599
+ dataDirectory=data_directory,
1600
+ isLabelImg=True,
1601
+ progress_callback=report,
1602
+ segmentation_mode_=mode,
1603
+ )
1604
+ _segmentation_jobs[datasource_name] = {
1605
+ "status": "ready",
1606
+ "error": None,
1607
+ "segmentation": result["segmentation"],
1608
+ "progress": 100,
1609
+ "message": "Segmentation mask ready",
1610
+ }
1611
+ _patch_config_segmentation(
1612
+ datasource_name, result["segmentation"], "ready",
1613
+ segmentation_source=label_file,
1614
+ )
1615
+ if source == datasource_name:
1616
+ load_datasource(datasource_name, reload=True)
1617
+ except Exception as exc:
1618
+ _segmentation_jobs[datasource_name] = {
1619
+ "status": "error",
1620
+ "error": str(exc),
1621
+ "progress": 0,
1622
+ "message": "Segmentation mask failed",
1623
+ }
1624
+ _patch_config_segmentation(
1625
+ datasource_name, None, "error", segmentation_source=label_file
1626
+ )
1627
+ finally:
1628
+ lock.release()
1629
+
1630
+ threading.Thread(target=_run, daemon=True).start()
1631
+
1632
+
1633
+ def get_segmentation_job_status(datasource_name):
1634
+ if datasource_name in _segmentation_jobs:
1635
+ return _segmentation_jobs[datasource_name]
1636
+ # Fall back to config.json's persisted status (server restarted mid-job,
1637
+ # or this process never ran the job -- e.g. multi-worker deployment).
1638
+ entry = get_config().get(datasource_name, {})
1639
+ status = entry.get("segmentation_status", "ready")
1640
+ return {
1641
+ "status": status,
1642
+ "error": None,
1643
+ "progress": 100 if status == "ready" else 0,
1644
+ "message": None,
1645
+ # Reported here as well as in the in-memory record above, because the
1646
+ # viewer takes the finished mask on without reloading the page and needs
1647
+ # the path to do it. This branch is the one a restarted server serves,
1648
+ # and a viewer that got no path here would have no way to pick the mask
1649
+ # up short of the reload this replaced.
1650
+ "segmentation": entry.get("segmentation"),
1651
+ }
1652
+
1653
+
1654
+ def logTransform(csvPath, skip_columns=[]):
1655
+ df = pl.read_csv(csvPath)
1656
+ transform_cols = [c for c in df.columns if c not in skip_columns]
1657
+ df = df.with_columns([pl.col(c).log1p().alias(c) for c in transform_cols])
1658
+ df.write_csv(csvPath)
1659
+