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.
- plexora/__init__.py +129 -0
- plexora/__main__.py +15 -0
- plexora/_url.py +105 -0
- plexora/api/__init__.py +55 -0
- plexora/api/dataset.py +398 -0
- plexora/api/http.py +19 -0
- plexora/api/plugin.py +541 -0
- plexora/api/store.py +221 -0
- plexora/cli.py +777 -0
- plexora/client/dist/354_bundle.js +2 -0
- plexora/client/dist/354_bundle.js.LICENSE.txt +20 -0
- plexora/client/dist/418_bundle.js +2 -0
- plexora/client/dist/418_bundle.js.LICENSE.txt +1 -0
- plexora/client/dist/693b935cb1f907814be9c6199a68c217.svg +19 -0
- plexora/client/dist/713c134ff47cd328a5b711526f151082.svg +18 -0
- plexora/client/dist/770_bundle.js +1 -0
- plexora/client/dist/ff743f408972e0e96ca9153ed5ae4c83.svg +23 -0
- plexora/client/dist/vendor_bundle.js +2 -0
- plexora/client/dist/vendor_bundle.js.LICENSE.txt +308 -0
- plexora/client/external/openseadragon-bin-2.4.0/LICENSE.txt +28 -0
- plexora/client/external/openseadragon-bin-2.4.0/canvas-overlay-hd.js +140 -0
- plexora/client/external/openseadragon-bin-2.4.0/changelog.txt +501 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/.gitattributes +17 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/LICENSE.txt +116 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/Toolbar.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/fullpage.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/home.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/next.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/previous.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateleft.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateright.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/zoom.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/README.md +9 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-scalebar.js +562 -0
- plexora/client/src/css/import.css +474 -0
- plexora/client/src/css/main.css +903 -0
- plexora/client/src/css/openProject.css +363 -0
- plexora/client/src/css/quickView.css +166 -0
- plexora/client/src/css/tokens.css +51 -0
- plexora/client/src/css/viewer.css +1655 -0
- plexora/client/src/img/apple-touch-icon.png +0 -0
- plexora/client/src/img/favicon.ico +0 -0
- plexora/client/src/img/logo.ai +8892 -33
- plexora/client/src/img/logo.svg +1 -0
- plexora/client/src/img/logo_with_text.ai +9011 -34
- plexora/client/src/img/logo_with_text.svg +1 -0
- plexora/client/src/js/main.js +704 -0
- plexora/client/src/js/pluginRegistry.js +108 -0
- plexora/client/src/js/services/appStatus.js +286 -0
- plexora/client/src/js/services/browsePicker.js +73 -0
- plexora/client/src/js/services/dataLayer.js +400 -0
- plexora/client/src/js/services/datasetContext.js +143 -0
- plexora/client/src/js/services/glRenderer.js +143 -0
- plexora/client/src/js/services/importFormValidation.js +257 -0
- plexora/client/src/js/services/numericData.js +100 -0
- plexora/client/src/js/services/passVariablesToFrontend.js +20 -0
- plexora/client/src/js/services/simpleEventHandler.js +25 -0
- plexora/client/src/js/vendor.js +29 -0
- plexora/client/src/js/views/channelList.js +663 -0
- plexora/client/src/js/views/colorSwatchPicker.js +202 -0
- plexora/client/src/js/views/columnClassifier.js +146 -0
- plexora/client/src/js/views/coordinateField.js +133 -0
- plexora/client/src/js/views/dataSourceField.js +276 -0
- plexora/client/src/js/views/imageViewer.js +3306 -0
- plexora/client/src/js/views/miniMap.js +787 -0
- plexora/client/src/js/views/navbarControls.js +178 -0
- plexora/client/src/js/views/openProjectPage.js +239 -0
- plexora/client/src/js/views/projectEdit.js +335 -0
- plexora/client/src/js/views/quickViewLanding.js +136 -0
- plexora/client/src/js/views/rainbow.js +326 -0
- plexora/client/src/js/views/requirementsModal.js +572 -0
- plexora/client/src/js/views/rgbImageViewer.js +115 -0
- plexora/client/src/js/views/searchableSelect.js +411 -0
- plexora/client/src/js/views/segmentationProgress.js +122 -0
- plexora/client/src/js/views/toolLoader.js +747 -0
- plexora/client/src/js/views/viewerControls.js +689 -0
- plexora/client/src/js/views/viewerManager.js +434 -0
- plexora/client/src/js/views/viewerSidebar.js +1117 -0
- plexora/client/src/js/workers/tileDecoder.js +130 -0
- plexora/client/src/shaders/frag.glsl +528 -0
- plexora/client/src/shaders/vert.glsl +12 -0
- plexora/client/templates/base.html +212 -0
- plexora/client/templates/index.html +238 -0
- plexora/client/templates/open_project.html +96 -0
- plexora/client/templates/project_columns.html +86 -0
- plexora/client/templates/project_edit.html +196 -0
- plexora/client/templates/upload.html +118 -0
- plexora/connect.py +528 -0
- plexora/datasource.py +782 -0
- plexora/jupyter.py +406 -0
- plexora/notebook_env.py +232 -0
- plexora/paths.py +486 -0
- plexora/plugins/__init__.py +12 -0
- plexora/plugins/cell_explorer/__init__.py +100 -0
- plexora/plugins/cell_explorer/server/__init__.py +11 -0
- plexora/plugins/cell_explorer/server/routes.py +167 -0
- plexora/plugins/cell_explorer/server/state.py +281 -0
- plexora/plugins/cell_explorer/server/values.py +145 -0
- plexora/plugins/cell_explorer/server/variables.py +367 -0
- plexora/plugins/cell_explorer/static/cellExplorerApi.js +158 -0
- plexora/plugins/cell_explorer/static/cellExplorerColors.js +316 -0
- plexora/plugins/cell_explorer/static/cellExplorerContinuous.js +403 -0
- plexora/plugins/cell_explorer/static/cellExplorerFigureBridge.js +133 -0
- plexora/plugins/cell_explorer/static/cellExplorerLegend.js +188 -0
- plexora/plugins/cell_explorer/static/cellExplorerRoiBridge.js +822 -0
- plexora/plugins/cell_explorer/static/cellExplorerSidebarController.js +826 -0
- plexora/plugins/cell_explorer/static/cellExplorerState.js +372 -0
- plexora/plugins/cell_explorer/static/cell_explorer.css +752 -0
- plexora/plugins/cell_explorer/templates/cell_explorer/panel.html +137 -0
- plexora/plugins/figure_builder/__init__.py +125 -0
- plexora/plugins/figure_builder/server/__init__.py +7 -0
- plexora/plugins/figure_builder/server/compose.py +365 -0
- plexora/plugins/figure_builder/server/export.py +784 -0
- plexora/plugins/figure_builder/server/export_jobs.py +148 -0
- plexora/plugins/figure_builder/server/operations.py +556 -0
- plexora/plugins/figure_builder/server/pixels.py +142 -0
- plexora/plugins/figure_builder/server/provenance.py +174 -0
- plexora/plugins/figure_builder/server/render.py +278 -0
- plexora/plugins/figure_builder/server/repository.py +701 -0
- plexora/plugins/figure_builder/server/routes.py +594 -0
- plexora/plugins/figure_builder/server/schema.py +1023 -0
- plexora/plugins/figure_builder/server/sources.py +120 -0
- plexora/plugins/figure_builder/server/textmetrics.py +155 -0
- plexora/plugins/figure_builder/static/figureActions.js +363 -0
- plexora/plugins/figure_builder/static/figureBuilderApi.js +255 -0
- plexora/plugins/figure_builder/static/figureCanvas.js +2362 -0
- plexora/plugins/figure_builder/static/figureCaptureBoxes.js +369 -0
- plexora/plugins/figure_builder/static/figureCaptureDock.js +466 -0
- plexora/plugins/figure_builder/static/figureCaptureTool.js +1034 -0
- plexora/plugins/figure_builder/static/figureConfirm.js +136 -0
- plexora/plugins/figure_builder/static/figureContextBar.js +1193 -0
- plexora/plugins/figure_builder/static/figureContextMenu.js +253 -0
- plexora/plugins/figure_builder/static/figureDocumentState.js +275 -0
- plexora/plugins/figure_builder/static/figureExportUi.js +174 -0
- plexora/plugins/figure_builder/static/figureLibrary.js +247 -0
- plexora/plugins/figure_builder/static/figureQuickEdit.js +628 -0
- plexora/plugins/figure_builder/static/figureRichText.js +634 -0
- plexora/plugins/figure_builder/static/figureSceneSnapshot.js +361 -0
- plexora/plugins/figure_builder/static/figureSchema.js +218 -0
- plexora/plugins/figure_builder/static/figureSidebarController.js +1159 -0
- plexora/plugins/figure_builder/static/figureTextEditor.js +503 -0
- plexora/plugins/figure_builder/static/figureTextPanel.js +491 -0
- plexora/plugins/figure_builder/static/figureViewOptions.js +273 -0
- plexora/plugins/figure_builder/static/figureWorkspace.js +2030 -0
- plexora/plugins/figure_builder/static/figure_builder.css +3292 -0
- plexora/plugins/figure_builder/templates/figure_builder/library.html +74 -0
- plexora/plugins/figure_builder/templates/figure_builder/workspace.html +27 -0
- plexora/plugins/figure_builder/templates/figure_builder/workspace_body.html +458 -0
- plexora/plugins/gating/__init__.py +76 -0
- plexora/plugins/gating/server/__init__.py +0 -0
- plexora/plugins/gating/server/anndata_gates.py +367 -0
- plexora/plugins/gating/server/database.py +16 -0
- plexora/plugins/gating/server/model.py +320 -0
- plexora/plugins/gating/server/routes.py +235 -0
- plexora/plugins/gating/static/csvGatingList.js +947 -0
- plexora/plugins/gating/static/gating.css +289 -0
- plexora/plugins/gating/static/gatingApi.js +221 -0
- plexora/plugins/gating/static/gatingSidebarController.js +513 -0
- plexora/plugins/gating/templates/gating/legacy.html +26 -0
- plexora/plugins/gating/templates/gating/panel.html +69 -0
- plexora/plugins/roi/__init__.py +95 -0
- plexora/plugins/roi/server/__init__.py +8 -0
- plexora/plugins/roi/server/adapters.py +655 -0
- plexora/plugins/roi/server/geojson.py +267 -0
- plexora/plugins/roi/server/geometry.py +181 -0
- plexora/plugins/roi/server/mapping.py +169 -0
- plexora/plugins/roi/server/operations.py +293 -0
- plexora/plugins/roi/server/repository.py +215 -0
- plexora/plugins/roi/server/routes.py +409 -0
- plexora/plugins/roi/server/schema.py +323 -0
- plexora/plugins/roi/static/roi.css +583 -0
- plexora/plugins/roi/static/roiApi.js +177 -0
- plexora/plugins/roi/static/roiFigureBridge.js +107 -0
- plexora/plugins/roi/static/roiGeometry.js +350 -0
- plexora/plugins/roi/static/roiRenderer.js +278 -0
- plexora/plugins/roi/static/roiSidebarController.js +1038 -0
- plexora/plugins/roi/static/roiState.js +518 -0
- plexora/plugins/roi/static/roiTools.js +993 -0
- plexora/plugins/roi/templates/roi/panel.html +240 -0
- plexora/proxy.py +57 -0
- plexora/server/models/adapters/__init__.py +94 -0
- plexora/server/models/adapters/anndata_adapter.py +388 -0
- plexora/server/models/adapters/base.py +93 -0
- plexora/server/models/adapters/classify.py +192 -0
- plexora/server/models/adapters/csv_adapter.py +105 -0
- plexora/server/models/adapters/inspection.py +265 -0
- plexora/server/models/adapters/spatialdata_adapter.py +248 -0
- plexora/server/models/centroid_tiles.py +305 -0
- plexora/server/models/data_model.py +1659 -0
- plexora/server/models/database_model.py +169 -0
- plexora/server/models/project.py +1270 -0
- plexora/server/plugins.py +199 -0
- plexora/server/routes/browse_routes.py +41 -0
- plexora/server/routes/data_routes.py +294 -0
- plexora/server/routes/import_routes.py +603 -0
- plexora/server/routes/page_routes.py +139 -0
- plexora/server/routes/project_routes.py +438 -0
- plexora/server/routes/quick_view_routes.py +62 -0
- plexora/server/routes/system_routes.py +39 -0
- plexora/server/routes/tool_routes.py +418 -0
- plexora/server/utils/addHEColumns.py +11 -0
- plexora/server/utils/fast_png.py +97 -0
- plexora/server/utils/fullConversion.py +50 -0
- plexora/server/utils/native_dialog.py +168 -0
- plexora/server/utils/pre_normalization.py +47 -0
- plexora/server/utils/segmentation_pyramid.py +598 -0
- plexora/server/utils/smallestenclosingcircle.py +126 -0
- plexora/server/utils/tiffsurgeon.py +372 -0
- plexora/server_cli.py +48 -0
- plexora/test.py +0 -0
- plexora-0.0.1.dist-info/METADATA +341 -0
- plexora-0.0.1.dist-info/RECORD +248 -0
- plexora-0.0.1.dist-info/WHEEL +5 -0
- plexora-0.0.1.dist-info/entry_points.txt +12 -0
- plexora-0.0.1.dist-info/licenses/LICENSE +207 -0
- 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
|
+
|