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,598 @@
1
+ """Single-pass conversion of a raster label mask into the tiled, pyramidal
2
+ outline OME-TIFF the viewer draws.
3
+
4
+ This replaces the old three-step pipeline (pyramid_assemble -> pyramid_upgrade
5
+ -> ensure_outline_segmentation), which built a full *filled* pyramid only to
6
+ discard it: nothing ever served the filled file, because the viewer's label
7
+ layer is outlines-only (see imageViewer.js's "Label/segmentation tiles must
8
+ stay transparent outside outlines"). Writing outlines directly collapses three
9
+ passes into one and drops the throwaway intermediate -- on a 34050x5797 uint32
10
+ Orion mask that is ~60s and ~5MB, where step one alone previously cost ~60s and
11
+ 1.19GB.
12
+
13
+ Outlines are recomputed from filled labels independently at every pyramid level
14
+ rather than by downsampling an outline image, which is what keeps boundaries
15
+ continuous (and one pixel wide) as the user zooms out. Labels are downsampled
16
+ with nearest-neighbour sampling so integer cell IDs survive exactly, and the
17
+ full-resolution mask is never required in RAM: each output tile is produced by
18
+ one indexed read of the source.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import tempfile
25
+ from pathlib import Path
26
+ from typing import Callable, Optional
27
+
28
+ import numpy as np
29
+ import tifffile as tf
30
+ import zarr
31
+
32
+ # Suffix for masks this module generates. The old pipeline wrote
33
+ # ".fast-outlines.pyramid.ome.tiff" even when the boundaries were exact;
34
+ # generated files are now named for what they are. Paths already recorded in
35
+ # config.json keep working -- nothing derives this suffix from a stored path.
36
+ OUTLINE_SUFFIX = ".outlines.pyramid.ome.tiff"
37
+
38
+ # The other thing this module can write: a *filled* label pyramid, for the
39
+ # viewer's on-the-fly mode where the shader derives boundaries per fragment
40
+ # instead of reading them out of the file (see u32_rgba_map in frag.glsl).
41
+ # Cheaper to produce than outlines -- no halo reads, no boundary pass -- and it
42
+ # keeps a cell ID on every pixel rather than only on boundaries.
43
+ FILLED_SUFFIX = ".labels.pyramid.ome.tiff"
44
+
45
+ # Stamped into the OME XML of every mask we write, so recognising our own
46
+ # output is an exact metadata check rather than a statistical guess about
47
+ # pixel content (see generated_mask_kind). The two kinds need distinct markers:
48
+ # a filled pyramid must never be mistaken for outlines, since serving it to a
49
+ # viewer that is not outlining paints solid blobs over the image.
50
+ OUTLINE_MARKER = "plexora-outline-mask"
51
+ FILLED_MARKER = "plexora-label-pyramid"
52
+
53
+ # config.json's `segmentationMode`, and this module's `mode=` argument.
54
+ MODE_OUTLINES = "outlines"
55
+ MODE_FILLED = "filled"
56
+
57
+ # What an import produces unless told otherwise. Filled, because measuring the
58
+ # two against each other on a real 47296x64246 mask found no reason to prefer
59
+ # outlines: per tile, end to end (server read + PNG encode + UPNG decode +
60
+ # renderLabelTile), filled came out at 33.9 ms against 34.7 ms for outlines and
61
+ # 41 KB against 57 KB -- filled labels compress better than scattered boundary
62
+ # pixels, which pays back the client-side boundary pass several times over. And
63
+ # a mask that is *already* a tiled pyramid is served untouched in this mode,
64
+ # where outlines would spend a minute-plus deriving a second copy of it.
65
+ #
66
+ # MODE_OUTLINES is still fully supported and exercised by the tests; nothing in
67
+ # the UI selects it any more. Pass mode= explicitly to get it back.
68
+ DEFAULT_MODE = MODE_FILLED
69
+
70
+ # Ceiling on the level-0 bytes we will pull into RAM in a single read. Reading
71
+ # the whole mask at once is dramatically faster than per-tile reads when the
72
+ # source lives on a network mount -- measured 60s vs 179s for the Orion mask on
73
+ # an SMB share, because hundreds of small scattered reads pay the round-trip
74
+ # each time. Above this budget we fall back to streaming tiles, which bounds
75
+ # memory at the cost of that speedup.
76
+ _DEFAULT_FULL_READ_MAX_BYTES = 8 * 1024 ** 3
77
+
78
+ _FINGERPRINT_SCAN_LIMIT = 20000
79
+
80
+
81
+ def _full_read_budget() -> int:
82
+ raw = os.environ.get("PLEXORA_MASK_FULL_READ_MAX_BYTES")
83
+ if not raw:
84
+ return _DEFAULT_FULL_READ_MAX_BYTES
85
+ try:
86
+ return max(0, int(raw))
87
+ except ValueError:
88
+ return _DEFAULT_FULL_READ_MAX_BYTES
89
+
90
+
91
+ def derived_output_path(segmentation_path, data_directory=None, *,
92
+ mode: str = MODE_OUTLINES) -> Path:
93
+ """Destination for the mask derived from `segmentation_path`.
94
+
95
+ Generated masks live in the dataset's own directory (alongside its copied
96
+ feature table) so that removing a dataset removes its derived image too,
97
+ and so we never write into the user's -- possibly read-only or
98
+ network-mounted -- source folder.
99
+
100
+ The two modes get distinct names so switching a datasource between them
101
+ neither overwrites nor silently reuses the other kind of file.
102
+ """
103
+ source_path = Path(segmentation_path)
104
+ target_dir = Path(data_directory) if data_directory else source_path.parent
105
+ stem = source_path.name
106
+ for extension in (".ome.tiff", ".ome.tif", ".tiff", ".tif", ".png", ".zarr"):
107
+ if stem.lower().endswith(extension):
108
+ stem = stem[: -len(extension)]
109
+ break
110
+ suffix = FILLED_SUFFIX if mode == MODE_FILLED else OUTLINE_SUFFIX
111
+ return target_dir / f"{stem}{suffix}"
112
+
113
+
114
+ def outline_output_path(segmentation_path, data_directory=None) -> Path:
115
+ """Destination for the outline mask derived from `segmentation_path`."""
116
+ return derived_output_path(segmentation_path, data_directory, mode=MODE_OUTLINES)
117
+
118
+
119
+ def source_fingerprint(path) -> Optional[str]:
120
+ """Cheap staleness key for a mask source, used to decide whether an
121
+ already-generated outline file still corresponds to it. Stat-only: never
122
+ reads pixels, so it is safe to call on every datasource load.
123
+ """
124
+ source_path = Path(path)
125
+ try:
126
+ stat_result = source_path.stat()
127
+ except OSError:
128
+ return None
129
+ if not source_path.is_dir():
130
+ return f"{stat_result.st_size}-{stat_result.st_mtime_ns}"
131
+ # A .zarr mask is a directory tree, so aggregate over its entries instead.
132
+ count = 0
133
+ total_size = 0
134
+ newest = 0
135
+ for root, _, files in os.walk(source_path):
136
+ for name in files:
137
+ try:
138
+ entry = os.stat(os.path.join(root, name))
139
+ except OSError:
140
+ continue
141
+ count += 1
142
+ total_size += entry.st_size
143
+ newest = max(newest, entry.st_mtime_ns)
144
+ if count >= _FINGERPRINT_SCAN_LIMIT:
145
+ # Pathologically large store: fall back to the directory's own
146
+ # stat rather than walking forever.
147
+ return f"dir-{stat_result.st_mtime_ns}"
148
+ return f"{count}-{total_size}-{newest}"
149
+
150
+
151
+ def _open_level_zero(path):
152
+ """Return (array, closer) for the highest-resolution plane of `path`.
153
+
154
+ `is_ome=False` matches how the rest of plexora opens masks: OME metadata on
155
+ these files is decorative, and trusting it makes tifffile reinterpret the
156
+ page layout of some third-party masks.
157
+ """
158
+ if str(path).endswith(".zarr"):
159
+ group = zarr.open(str(path), mode="r")
160
+ if isinstance(group, zarr.Array):
161
+ return group, lambda: None
162
+ return group["0"], lambda: None
163
+ reader = tf.TiffFile(str(path), is_ome=False)
164
+ store = reader.series[0].aszarr(level=0)
165
+ array = zarr.open(store, mode="r")
166
+
167
+ def _close():
168
+ store.close()
169
+ reader.close()
170
+
171
+ return array, _close
172
+
173
+
174
+ def _memmap_plane(path, expected_shape=None):
175
+ """A memory map of `path`'s full-resolution plane, or None if it can't be
176
+ mapped.
177
+
178
+ Worth reaching for before the Zarr adapter whenever pixels are read in
179
+ windows. Segmentation masks are routinely written uncompressed and
180
+ *untiled*, frequently as a single strip spanning the whole image, and the
181
+ adapter has to decode a whole strip to answer any read inside it. Sampling
182
+ twelve 512x512 windows out of a 12GB single-strip Orion mask costs 59s that
183
+ way and 0.015s through a memory map.
184
+
185
+ Returns None for anything compressed, tiled or multi-plane, where memmap
186
+ either raises or hands back something that isn't the plane asked for --
187
+ hence `expected_shape`, which callers pass from the reader whose geometry
188
+ they actually trust.
189
+ """
190
+ if str(path).endswith(".zarr"):
191
+ return None
192
+ try:
193
+ candidate = tf.memmap(str(path))
194
+ except Exception:
195
+ return None
196
+ shape = tuple(getattr(candidate, "shape", ()))
197
+ if len(shape) != 2:
198
+ return None
199
+ if expected_shape is not None and shape != tuple(expected_shape):
200
+ return None
201
+ return candidate
202
+
203
+
204
+ def generated_mask_kind(path) -> Optional[str]:
205
+ """MODE_OUTLINES, MODE_FILLED, or None for a mask we did not write.
206
+
207
+ Keyed on the OME marker rather than the filename, so a mask the user has
208
+ since renamed or moved is still recognised as generated output.
209
+ """
210
+ candidate = Path(path)
211
+ if not candidate.is_file():
212
+ return None
213
+ try:
214
+ with tf.TiffFile(str(candidate)) as reader:
215
+ recorded = reader.ome_metadata or ""
216
+ except Exception:
217
+ return None
218
+ if OUTLINE_MARKER in recorded:
219
+ return MODE_OUTLINES
220
+ if FILLED_MARKER in recorded:
221
+ return MODE_FILLED
222
+ return None
223
+
224
+
225
+ def is_generated_outline_mask(path) -> bool:
226
+ """True when `path` is an outline mask this module wrote."""
227
+ return generated_mask_kind(path) == MODE_OUTLINES
228
+
229
+
230
+ def label_pyramid_gaps(path) -> Optional[list]:
231
+ """Requirements `path` fails for being served straight to the tile route as
232
+ a filled label layer. An empty list means it can be served untouched; None
233
+ means it could not be read at all.
234
+
235
+ Both requirements matter, and "the mask is already filled labels" satisfies
236
+ neither on its own -- which is the usual surprise. A single-level mask has
237
+ nothing to answer a request for level 3 with (`_zarr_level` hands back full
238
+ resolution for every level, so the viewer draws the wrong scale), and an
239
+ untiled mask makes every tile request decode whole strips -- masks are
240
+ routinely written as one strip spanning the entire image.
241
+ """
242
+ candidate = Path(path)
243
+ try:
244
+ if str(path).endswith(".zarr"):
245
+ group = zarr.open(str(path), mode="r")
246
+ if isinstance(group, zarr.Array) or len(group) <= 1:
247
+ return ["it has only one resolution level (no pyramid)"]
248
+ return []
249
+ if not candidate.is_file():
250
+ return None
251
+ with tf.TiffFile(str(candidate), is_ome=False) as reader:
252
+ series = reader.series[0]
253
+ page = series.levels[0].pages[0]
254
+ gaps = []
255
+ if len(series.levels) <= 1:
256
+ gaps.append("it has only one resolution level (no pyramid)")
257
+ if not page.is_tiled:
258
+ gaps.append("it is not tiled")
259
+ return gaps
260
+ except Exception:
261
+ return None
262
+
263
+
264
+ def is_servable_label_pyramid(path) -> bool:
265
+ """True when `path` can be served straight to the tile route as a filled
266
+ label layer: tiled, and pyramidal enough for the zoomed-out levels."""
267
+ return label_pyramid_gaps(path) == []
268
+
269
+
270
+ def looks_like_outline_mask(path) -> bool:
271
+ """Heuristic "is this mask already outlines?" test for *user-supplied*
272
+ files, where there is no metadata marker to trust.
273
+
274
+ Sampled from contiguous windows on purpose. An earlier version strided the
275
+ whole plane (`level[::step, ::step]`), which for a large mask meant a step
276
+ of ~23px -- far enough apart that neighbouring samples almost never shared
277
+ a label, so every big filled mask scored as "outlines" and outline
278
+ generation was skipped entirely. Interior-ness is a local property and only
279
+ survives contiguous sampling.
280
+ """
281
+ try:
282
+ array, close = _open_level_zero(path)
283
+ except Exception:
284
+ return False
285
+ try:
286
+ if len(array.shape) != 2 or array.size == 0:
287
+ return False
288
+ height, width = (int(size) for size in array.shape)
289
+ # Geometry stays authoritative from the reader opened above (the one
290
+ # that serves tiles); the memory map, when there is one, only makes the
291
+ # windowed reads below cheap enough to run on a datasource load.
292
+ mapped = _memmap_plane(path, (height, width))
293
+ if mapped is not None:
294
+ array = mapped
295
+ window = 512
296
+ nonzero_total = 0
297
+ pixel_total = 0
298
+ interior_total = 0
299
+ boundary_total = 0
300
+ for y_fraction in (0.3, 0.5, 0.7):
301
+ for x_fraction in (0.2, 0.4, 0.6, 0.8):
302
+ y = min(max(0, int(height * y_fraction) - window // 2), max(0, height - window))
303
+ x = min(max(0, int(width * x_fraction) - window // 2), max(0, width - window))
304
+ sample = np.asarray(array[y:y + window, x:x + window])
305
+ if sample.ndim != 2 or sample.size == 0:
306
+ continue
307
+ nonzero = sample != 0
308
+ nonzero_total += int(np.count_nonzero(nonzero))
309
+ pixel_total += sample.size
310
+ center = nonzero[1:-1, 1:-1]
311
+ if center.size == 0:
312
+ continue
313
+ center_values = sample[1:-1, 1:-1]
314
+ same_id_interior = (
315
+ center
316
+ & (center_values == sample[:-2, 1:-1])
317
+ & (center_values == sample[2:, 1:-1])
318
+ & (center_values == sample[1:-1, :-2])
319
+ & (center_values == sample[1:-1, 2:])
320
+ )
321
+ interior_total += int(np.count_nonzero(same_id_interior))
322
+ boundary_total += int(np.count_nonzero(center))
323
+ finally:
324
+ close()
325
+
326
+ if pixel_total == 0 or nonzero_total == 0 or boundary_total == 0:
327
+ return False
328
+ density = nonzero_total / pixel_total
329
+ interior_fraction = interior_total / boundary_total
330
+ return density <= 0.20 and interior_fraction <= 0.05
331
+
332
+
333
+ def pyramidize_segmentation_mask(
334
+ input_path,
335
+ output_path=None,
336
+ *,
337
+ tile_size: int = 1024,
338
+ compression: Optional[str] = "zlib",
339
+ max_workers: Optional[int] = None,
340
+ outline: bool = True,
341
+ method: str = "exact",
342
+ overwrite: bool = False,
343
+ full_read: Optional[bool] = None,
344
+ progress_callback: Optional[Callable[[int, int], None]] = None,
345
+ ) -> str:
346
+ """Convert a 2-D label TIFF/Zarr to a tiled, pyramidal OME-TIFF.
347
+
348
+ Parameters
349
+ ----------
350
+ input_path
351
+ A single-plane 2-D integer label mask with zero as background. Already
352
+ pyramidal input is fine -- its highest-resolution level is used and a
353
+ fresh outline pyramid is derived from it.
354
+ output_path
355
+ Destination. Defaults to `outline_output_path(input_path)`.
356
+ tile_size
357
+ Square TIFF tile edge, a multiple of 16. Larger tiles cut conversion
358
+ time noticeably on huge masks.
359
+ compression
360
+ Lossless TIFF compression; None writes uncompressed tiles.
361
+ max_workers
362
+ Worker threads tifffile may use for compression.
363
+ outline
364
+ Write only label-boundary pixels, keeping their original cell IDs.
365
+ False writes a conventional filled label pyramid.
366
+ method
367
+ "exact" marks every cell-cell and cell-background boundary
368
+ (8-neighbour). "fast" marks only foreground-background boundaries
369
+ (4-neighbour) -- cheaper, but misses seams where two cells touch.
370
+ overwrite
371
+ Replace an existing destination.
372
+ full_read
373
+ True bulk-reads the whole mask into RAM before tiling, False always
374
+ streams per tile. None (default) decides from the level-0 byte size
375
+ against `PLEXORA_MASK_FULL_READ_MAX_BYTES`.
376
+ progress_callback
377
+ Called as (tiles_written, tiles_total) as the pyramid is written.
378
+ """
379
+ source_path = Path(input_path).expanduser()
380
+ if not source_path.exists():
381
+ raise FileNotFoundError(source_path)
382
+ if not isinstance(tile_size, int) or tile_size < 16 or tile_size % 16:
383
+ raise ValueError("tile_size must be an integer multiple of 16.")
384
+ outline_method = str(method).strip().lower()
385
+ if outline_method not in {"exact", "fast"}:
386
+ raise ValueError("method must be 'exact' or 'fast'.")
387
+
388
+ destination = (
389
+ outline_output_path(source_path)
390
+ if output_path is None
391
+ else Path(output_path).expanduser()
392
+ )
393
+ if destination.resolve() == source_path.resolve():
394
+ raise ValueError("output_path must be different from input_path.")
395
+ if destination.exists() and not overwrite:
396
+ raise FileExistsError(
397
+ f"Destination already exists: {destination}. Pass overwrite=True to replace it."
398
+ )
399
+
400
+ labels = None
401
+ close_source = None
402
+ memmap = None
403
+ temporary_path: Optional[Path] = None
404
+ try:
405
+ # Metadata-only probe first: shape and dtype decide the read strategy,
406
+ # so they must be known before anything is pulled into memory.
407
+ if str(source_path).endswith(".zarr"):
408
+ probe, probe_close = _open_level_zero(source_path)
409
+ shape, dtype = probe.shape, np.dtype(probe.dtype)
410
+ probe_close()
411
+ else:
412
+ with tf.TiffFile(str(source_path), is_ome=False) as reader:
413
+ series = reader.series[0]
414
+ shape, dtype = series.shape, np.dtype(series.dtype)
415
+ if len(shape) != 2:
416
+ raise ValueError(
417
+ "input_path must contain one 2-D label plane; "
418
+ f"found shape {tuple(shape)!r}."
419
+ )
420
+ if not np.issubdtype(dtype, np.integer):
421
+ raise TypeError(f"Label mask must have an integer dtype, found {dtype}.")
422
+ height, width = (int(size) for size in shape)
423
+ if height == 0 or width == 0:
424
+ raise ValueError("Label mask must have non-zero height and width.")
425
+
426
+ use_full_read = (
427
+ bool(full_read)
428
+ if full_read is not None
429
+ else (height * width * dtype.itemsize) <= _full_read_budget()
430
+ )
431
+
432
+ if use_full_read:
433
+ # One sequential read, then every tile is served from RAM. Routed
434
+ # through `memmap` so the direct-indexing branches below (which
435
+ # already special-case a real ndarray) pick it up unchanged.
436
+ if str(source_path).endswith(".zarr"):
437
+ array, close_source = _open_level_zero(source_path)
438
+ memmap = np.asarray(array)
439
+ close_source()
440
+ close_source = None
441
+ else:
442
+ memmap = np.asarray(tf.imread(str(source_path), is_ome=False, series=0, level=0))
443
+ labels = memmap
444
+ else:
445
+ # Streaming per tile: map the source where possible, so each tile is
446
+ # a handful of page faults rather than a strip decode.
447
+ memmap = _memmap_plane(source_path, (height, width))
448
+ if memmap is not None:
449
+ labels = memmap
450
+ if labels is None:
451
+ labels, close_source = _open_level_zero(source_path)
452
+
453
+ factors = [1]
454
+ while max(
455
+ (height + factors[-1] - 1) // factors[-1],
456
+ (width + factors[-1] - 1) // factors[-1],
457
+ ) > tile_size:
458
+ factors.append(factors[-1] * 2)
459
+ total_tiles = sum(
460
+ (((height + factor - 1) // factor + tile_size - 1) // tile_size)
461
+ * (((width + factor - 1) // factor + tile_size - 1) // tile_size)
462
+ for factor in factors
463
+ )
464
+ tiles_written = 0
465
+
466
+ destination.parent.mkdir(parents=True, exist_ok=True)
467
+ file_descriptor, temporary_name = tempfile.mkstemp(
468
+ dir=destination.parent,
469
+ prefix=f".{destination.name}-",
470
+ suffix=".tmp.ome.tiff",
471
+ )
472
+ os.close(file_descriptor)
473
+ temporary_path = Path(temporary_name)
474
+
475
+ def outline_tile(halo: np.ndarray) -> np.ndarray:
476
+ center = halo[1:-1, 1:-1]
477
+ if outline_method == "fast":
478
+ foreground = center != 0
479
+ is_boundary = foreground & (
480
+ (halo[:-2, 1:-1] == 0) | (halo[2:, 1:-1] == 0)
481
+ | (halo[1:-1, :-2] == 0) | (halo[1:-1, 2:] == 0)
482
+ )
483
+ else:
484
+ # An exact boundary is any labelled pixel adjacent to a
485
+ # *different* label or to background -- not just those next to
486
+ # empty space, which is what makes touching cells separable.
487
+ is_boundary = (center != 0) & (
488
+ (halo[:-2, 1:-1] != center) | (halo[2:, 1:-1] != center)
489
+ | (halo[1:-1, :-2] != center) | (halo[1:-1, 2:] != center)
490
+ | (halo[:-2, :-2] != center) | (halo[:-2, 2:] != center)
491
+ | (halo[2:, :-2] != center) | (halo[2:, 2:] != center)
492
+ )
493
+ return np.where(is_boundary, center, 0)
494
+
495
+ def read_block(y_start, y_stop, x_start, x_stop, factor):
496
+ if factor == 1:
497
+ return np.asarray(labels[y_start:y_stop, x_start:x_stop])
498
+ rows = np.arange(y_start * factor, y_stop * factor, factor)
499
+ columns = np.arange(x_start * factor, x_stop * factor, factor)
500
+ if memmap is not None:
501
+ return np.asarray(labels[np.ix_(rows, columns)])
502
+ return np.asarray(labels.oindex[rows, columns])
503
+
504
+ def tiles_for_level(factor: int):
505
+ nonlocal tiles_written
506
+ level_height = (height + factor - 1) // factor
507
+ level_width = (width + factor - 1) // factor
508
+ for y_start in range(0, level_height, tile_size):
509
+ y_stop = min(y_start + tile_size, level_height)
510
+ for x_start in range(0, level_width, tile_size):
511
+ x_stop = min(x_start + tile_size, level_width)
512
+ if outline:
513
+ # Read a one-pixel halo *in this level's* coordinates.
514
+ # Comparing against real neighbours rather than a
515
+ # zero-padded edge is what keeps an outline unbroken
516
+ # where two TIFF tiles meet.
517
+ read_y_start = max(0, y_start - 1)
518
+ read_y_stop = min(level_height, y_stop + 1)
519
+ read_x_start = max(0, x_start - 1)
520
+ read_x_stop = min(level_width, x_stop + 1)
521
+ sampled = read_block(
522
+ read_y_start, read_y_stop, read_x_start, read_x_stop, factor
523
+ )
524
+ halo = np.zeros(
525
+ (y_stop - y_start + 2, x_stop - x_start + 2), dtype=dtype
526
+ )
527
+ insert_y = read_y_start - (y_start - 1)
528
+ insert_x = read_x_start - (x_start - 1)
529
+ halo[
530
+ insert_y:insert_y + sampled.shape[0],
531
+ insert_x:insert_x + sampled.shape[1],
532
+ ] = sampled
533
+ tile = outline_tile(halo)
534
+ else:
535
+ tile = read_block(y_start, y_stop, x_start, x_stop, factor)
536
+ if tile.shape != (tile_size, tile_size):
537
+ padded = np.zeros((tile_size, tile_size), dtype=dtype)
538
+ padded[: tile.shape[0], : tile.shape[1]] = tile
539
+ tile = padded
540
+ tiles_written += 1
541
+ if progress_callback is not None:
542
+ progress_callback(tiles_written, total_tiles)
543
+ yield np.ascontiguousarray(tile)
544
+
545
+ with tf.TiffWriter(str(temporary_path), bigtiff=True, ome=True) as writer:
546
+ for level_index, factor in enumerate(factors):
547
+ level_shape = (
548
+ (height + factor - 1) // factor,
549
+ (width + factor - 1) // factor,
550
+ )
551
+ metadata = None
552
+ if level_index == 0:
553
+ metadata = {"axes": "YX", "Channel": {"Name": "cell"}}
554
+ metadata["Name"] = OUTLINE_MARKER if outline else FILLED_MARKER
555
+ writer.write(
556
+ tiles_for_level(factor),
557
+ shape=level_shape,
558
+ dtype=dtype,
559
+ tile=(tile_size, tile_size),
560
+ compression=compression,
561
+ photometric="minisblack",
562
+ metadata=metadata,
563
+ subifds=len(factors) - 1 if level_index == 0 else None,
564
+ subfiletype=1 if level_index else None,
565
+ maxworkers=max_workers,
566
+ )
567
+ os.replace(temporary_path, destination)
568
+ temporary_path = None
569
+ finally:
570
+ if memmap is not None:
571
+ underlying = getattr(memmap, "_mmap", None)
572
+ if underlying is not None:
573
+ underlying.close()
574
+ if close_source is not None:
575
+ close_source()
576
+ if temporary_path is not None:
577
+ temporary_path.unlink(missing_ok=True)
578
+
579
+ return str(destination)
580
+
581
+
582
+ __all__ = [
583
+ "FILLED_MARKER",
584
+ "FILLED_SUFFIX",
585
+ "MODE_FILLED",
586
+ "MODE_OUTLINES",
587
+ "OUTLINE_MARKER",
588
+ "OUTLINE_SUFFIX",
589
+ "derived_output_path",
590
+ "generated_mask_kind",
591
+ "is_generated_outline_mask",
592
+ "is_servable_label_pyramid",
593
+ "label_pyramid_gaps",
594
+ "looks_like_outline_mask",
595
+ "outline_output_path",
596
+ "pyramidize_segmentation_mask",
597
+ "source_fingerprint",
598
+ ]