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,3306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* viewer.js.
|
|
3
|
+
*
|
|
4
|
+
* @class ImageViewer to render multiplexed imaging data (based on OpenSeadragon)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/* todo
|
|
8
|
+
1. major - the viewer managers should not be looking up the same renderTF
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Small standalone helper (not a class method) -- used by drawLegendVector's
|
|
12
|
+
// PDF export path to turn a channel's stored #rrggbb colorHex into the r/g/b
|
|
13
|
+
// triplet jsPDF's setFillColor() takes.
|
|
14
|
+
function hexToRgb(hex) {
|
|
15
|
+
const match = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex || "");
|
|
16
|
+
if (!match) return { r: 255, g: 255, b: 255 };
|
|
17
|
+
return {
|
|
18
|
+
r: parseInt(match[1], 16),
|
|
19
|
+
g: parseInt(match[2], 16),
|
|
20
|
+
b: parseInt(match[3], 16),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Byte-budgeted LRU of WebGL tile textures.
|
|
26
|
+
*
|
|
27
|
+
* Replaces a 24-entry round-robin slot table that could never register a hit:
|
|
28
|
+
* it tracked labels but handed every slot the SAME single WebGLTexture (one
|
|
29
|
+
* gl.createTexture() in GLRenderer), and its "is it already resident" check
|
|
30
|
+
* only compared against the most recently bound slot. The net effect was a
|
|
31
|
+
* full gl.texImage2D re-upload of every visible tile of every channel on every
|
|
32
|
+
* frame -- 1 MB per u8 1024x1024 tile, so tens of MB per frame at 7+ channels,
|
|
33
|
+
* which on its own is enough to stop a pan from being smooth.
|
|
34
|
+
*
|
|
35
|
+
* Keys must include the pixel format, not just tile identity: the source's
|
|
36
|
+
* getTileKey() does not distinguish the HD (16-bit) variant from the default
|
|
37
|
+
* 8-bit one, so a format-blind key could serve a u8 texture to a u16 draw.
|
|
38
|
+
*/
|
|
39
|
+
class GLTileTextureCache {
|
|
40
|
+
constructor(gl, byteBudget) {
|
|
41
|
+
this.gl = gl;
|
|
42
|
+
this.byteBudget = byteBudget;
|
|
43
|
+
this.entries = new Map(); // insertion order == LRU order
|
|
44
|
+
this.bytes = 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @returns {{texture: WebGLTexture, resident: boolean}} `resident` is true
|
|
49
|
+
* when the texture already holds this key's pixels, so the caller can skip
|
|
50
|
+
* the upload entirely.
|
|
51
|
+
*/
|
|
52
|
+
acquire(key, byteLength) {
|
|
53
|
+
const existing = this.entries.get(key);
|
|
54
|
+
if (existing) {
|
|
55
|
+
// Re-insert to move this entry to the most-recently-used end.
|
|
56
|
+
this.entries.delete(key);
|
|
57
|
+
this.entries.set(key, existing);
|
|
58
|
+
return { texture: existing.texture, resident: true };
|
|
59
|
+
}
|
|
60
|
+
while (this.bytes + byteLength > this.byteBudget && this.entries.size > 0) {
|
|
61
|
+
const [oldestKey, oldest] = this.entries.entries().next().value;
|
|
62
|
+
this.entries.delete(oldestKey);
|
|
63
|
+
this.bytes -= oldest.byteLength;
|
|
64
|
+
this.gl.deleteTexture(oldest.texture);
|
|
65
|
+
}
|
|
66
|
+
const texture = this.gl.createTexture();
|
|
67
|
+
this.entries.set(key, { texture, byteLength });
|
|
68
|
+
this.bytes += byteLength;
|
|
69
|
+
return { texture, resident: false };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
clear() {
|
|
73
|
+
for (const entry of this.entries.values()) {
|
|
74
|
+
this.gl.deleteTexture(entry.texture);
|
|
75
|
+
}
|
|
76
|
+
this.entries.clear();
|
|
77
|
+
this.bytes = 0;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Round-robin pool of tile-decode workers (see workers/tileDecoder.js).
|
|
84
|
+
*
|
|
85
|
+
* Decoding is per tile per channel, so the work scales with channel count: a
|
|
86
|
+
* CPU profile put it at ~2% of a 7-channel pan but ~60% of a 15-channel one.
|
|
87
|
+
* Spreading it across workers both takes it off the main thread and lets
|
|
88
|
+
* several tiles decode in parallel.
|
|
89
|
+
*
|
|
90
|
+
* Falls back to null from create() if Worker/OffscreenCanvas are unavailable,
|
|
91
|
+
* and callers then decode inline -- the inline path has to exist anyway for
|
|
92
|
+
* the HD and segmentation formats.
|
|
93
|
+
*/
|
|
94
|
+
class TileDecoderPool {
|
|
95
|
+
static create(url, size) {
|
|
96
|
+
if (typeof Worker === "undefined" || typeof OffscreenCanvas === "undefined") {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
return new TileDecoderPool(url, size);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.warn("Tile decode workers unavailable, decoding inline:", err);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
constructor(url, size) {
|
|
108
|
+
this.pending = new Map();
|
|
109
|
+
this.nextId = 0;
|
|
110
|
+
this.next = 0;
|
|
111
|
+
this.workers = [];
|
|
112
|
+
for (let i = 0; i < size; i++) {
|
|
113
|
+
const worker = new Worker(url);
|
|
114
|
+
worker.onmessage = (event) => {
|
|
115
|
+
const { id, ok, array, width, height, error, unsupported } = event.data;
|
|
116
|
+
const entry = this.pending.get(id);
|
|
117
|
+
if (!entry) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
this.pending.delete(id);
|
|
121
|
+
if (ok) {
|
|
122
|
+
entry.resolve({ array, width, height });
|
|
123
|
+
} else {
|
|
124
|
+
const err = new Error(error);
|
|
125
|
+
// Distinguishes "this worker cannot handle this input" from
|
|
126
|
+
// "the worker broke": the caller falls back rather than
|
|
127
|
+
// treating it as a failure.
|
|
128
|
+
err.unsupported = !!unsupported;
|
|
129
|
+
entry.reject(err);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
worker.onerror = (err) => {
|
|
133
|
+
// A worker-level failure orphans everything queued on it, so
|
|
134
|
+
// reject all outstanding work rather than leaving tiles hung on
|
|
135
|
+
// promises OSD is awaiting.
|
|
136
|
+
for (const [id, entry] of this.pending) {
|
|
137
|
+
entry.reject(err instanceof Error ? err : new Error(String(err && err.message)));
|
|
138
|
+
this.pending.delete(id);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
this.workers.push(worker);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param buffer - the raw tile bytes as delivered by the server
|
|
147
|
+
* @param kind - "webp" (default 8-bit path) or "gray16png" (HD path)
|
|
148
|
+
* @returns Promise<{array: Uint8Array, width: number, height: number}>
|
|
149
|
+
*/
|
|
150
|
+
decode(buffer, kind) {
|
|
151
|
+
const id = this.nextId++;
|
|
152
|
+
const worker = this.workers[this.next];
|
|
153
|
+
this.next = (this.next + 1) % this.workers.length;
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
this.pending.set(id, { resolve, reject });
|
|
156
|
+
// `buffer` is deliberately copied, not transferred: it belongs to
|
|
157
|
+
// OpenSeadragon's XHR response and detaching it here would corrupt
|
|
158
|
+
// whatever OSD does with the response afterwards. The decoded
|
|
159
|
+
// result, which is the large one, IS transferred back.
|
|
160
|
+
worker.postMessage({ id, buffer, kind });
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
terminate() {
|
|
165
|
+
this.workers.forEach((w) => w.terminate());
|
|
166
|
+
this.workers = [];
|
|
167
|
+
this.pending.clear();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class ImageViewer {
|
|
173
|
+
// Vars
|
|
174
|
+
viewerManagers = [];
|
|
175
|
+
|
|
176
|
+
//: Filled masks hide the tissue underneath at full strength, and outlines
|
|
177
|
+
//: read poorly at low strength. One control has to serve both, so this is a
|
|
178
|
+
//: compromise leaning towards keeping the image visible.
|
|
179
|
+
static DEFAULT_CELL_LAYER_OPACITY = 0.7;
|
|
180
|
+
|
|
181
|
+
//: The modes drawn from the label tiles, and therefore the ones the sidebar
|
|
182
|
+
//: order restacks. Centroids are drawn on the overlay canvas instead, which
|
|
183
|
+
//: is above every tile whatever the order says -- see centroidDrawList.
|
|
184
|
+
static MASK_MODES = ["outlines", "filled"];
|
|
185
|
+
|
|
186
|
+
//: The stand-in layer used while no plugin has registered one. Named so a
|
|
187
|
+
//: per-tile canvas keyed by it can never collide with a plugin's name.
|
|
188
|
+
static CORE_LAYER = "__core__";
|
|
189
|
+
|
|
190
|
+
//: How big centroid dots are drawn, as a multiplier on the zoom-adaptive
|
|
191
|
+
//: size. A multiplier rather than a size: the adaptive part is what keeps
|
|
192
|
+
//: points visible when the whole slide is on screen and from swamping the
|
|
193
|
+
//: tissue at full zoom, and a fixed size chosen at one zoom is wrong at the
|
|
194
|
+
//: other. Bounds are generous because cell density varies by orders of
|
|
195
|
+
//: magnitude between a tissue microarray core and a whole section.
|
|
196
|
+
static DEFAULT_CENTROID_SCALE = 1;
|
|
197
|
+
static MIN_CENTROID_SCALE = 0.4;
|
|
198
|
+
static MAX_CENTROID_SCALE = 3;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Constructor for ImageViewer.
|
|
202
|
+
*
|
|
203
|
+
* @param config - the cinfiguration file (json)
|
|
204
|
+
* @param imgMetadata - image metadata from ome
|
|
205
|
+
* @param numericData - custom numeric data layer
|
|
206
|
+
* @param eventHandler - the event handler for distributing interface and data updates
|
|
207
|
+
*/
|
|
208
|
+
constructor(config, dataLayer, imgMetadata, numericData, eventHandler) {
|
|
209
|
+
this.ready = false;
|
|
210
|
+
this.config = config;
|
|
211
|
+
this.dataLayer = dataLayer;
|
|
212
|
+
// Every plugin currently colouring cells, keyed by name. Each entry is
|
|
213
|
+
// one LAYER -- see registerCellLayer for the record's shape -- and
|
|
214
|
+
// several may be live at once, which is what lets a phenotype map and a
|
|
215
|
+
// gate be looked at together rather than one replacing the other.
|
|
216
|
+
//
|
|
217
|
+
// Order is held separately because it is the z-order the layers
|
|
218
|
+
// composite in (bottom first) and the user sets it by dragging cards in
|
|
219
|
+
// the sidebar; membership and stacking change independently.
|
|
220
|
+
this._cellLayers = new Map();
|
|
221
|
+
this._cellLayerOrder = [];
|
|
222
|
+
// Which layer the shared controls, picking and gating act on. Exactly
|
|
223
|
+
// one, or none: "visible" is a property of every layer, "active" is a
|
|
224
|
+
// property of the session, and conflating the two is what made opening a
|
|
225
|
+
// second tool silently take the first one's colours away.
|
|
226
|
+
this._activeCellLayer = null;
|
|
227
|
+
// Which of the three representations the cell layer draws when NO plugin
|
|
228
|
+
// has registered one. Core's own mode, kept so a plain viewer -- or one
|
|
229
|
+
// running only Thresholding before it registers -- draws exactly what it
|
|
230
|
+
// always did. Once layers exist, each carries its own mode.
|
|
231
|
+
this.cellDisplayMode = "outlines";
|
|
232
|
+
// The record coreLayerView() hands the renderers. One object, refreshed
|
|
233
|
+
// in place, because it is read once per label tile per frame.
|
|
234
|
+
this._coreLayerView = {
|
|
235
|
+
name: ImageViewer.CORE_LAYER,
|
|
236
|
+
provider: null,
|
|
237
|
+
lut: null,
|
|
238
|
+
mode: this.cellDisplayMode,
|
|
239
|
+
userMode: null,
|
|
240
|
+
supportedModes: null,
|
|
241
|
+
opacity: 1,
|
|
242
|
+
visible: true,
|
|
243
|
+
filterIds: null,
|
|
244
|
+
filterRequest: 0,
|
|
245
|
+
styleCache: new Map(),
|
|
246
|
+
};
|
|
247
|
+
// Centroid dot size, as a multiplier -- see setCentroidPointScale.
|
|
248
|
+
this.centroidPointScale = ImageViewer.DEFAULT_CENTROID_SCALE;
|
|
249
|
+
this.channelList = null;
|
|
250
|
+
this.imgMetadata = imgMetadata;
|
|
251
|
+
this.numericData = numericData;
|
|
252
|
+
this.eventHandler = eventHandler;
|
|
253
|
+
this.pickingChanged = false;
|
|
254
|
+
this._cacheKeys = {};
|
|
255
|
+
this._picking = [];
|
|
256
|
+
this.pickedId = -1;
|
|
257
|
+
this.glReady = new Promise((resolve) => {
|
|
258
|
+
this.resolveGLReady = resolve;
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
this.centers = [];
|
|
262
|
+
this.ids = [];
|
|
263
|
+
this.centroidManifest = null;
|
|
264
|
+
this.centroidTiles = new Map();
|
|
265
|
+
this.centroidFilter = {};
|
|
266
|
+
this.centroidFilterSignature = "{}";
|
|
267
|
+
this.centroidTileTimer = null;
|
|
268
|
+
this.centroidTileRequest = 0;
|
|
269
|
+
this.centroidFirstLoad = true;
|
|
270
|
+
this.centroidMode = "tiled";
|
|
271
|
+
this.fullResolutionCenters = [];
|
|
272
|
+
this.idToCenterOffset = new Map();
|
|
273
|
+
this.centroidIdSet = null;
|
|
274
|
+
this.centroidsReady = false;
|
|
275
|
+
this.centroidsLoading = null;
|
|
276
|
+
this.segmentationReady = false;
|
|
277
|
+
this.segmentationLoading = null;
|
|
278
|
+
// Core's own gate -- the one that applies when no plugin has registered
|
|
279
|
+
// a layer. A registered layer carries its own `filterIds` instead, so
|
|
280
|
+
// one tool's gate cannot subtract cells from another tool's colours.
|
|
281
|
+
this.segmentationFilterIds = null;
|
|
282
|
+
this.segmentationFilterRequest = 0;
|
|
283
|
+
|
|
284
|
+
// Viewer
|
|
285
|
+
this.viewer = {};
|
|
286
|
+
|
|
287
|
+
// OSD plugins
|
|
288
|
+
this.show_scalebar = true;
|
|
289
|
+
this.show_centroids = false;
|
|
290
|
+
|
|
291
|
+
// Transfer function constant
|
|
292
|
+
this.numTFBins = 1024;
|
|
293
|
+
|
|
294
|
+
// Transfer function per channel (min,max, start color, end color)
|
|
295
|
+
this.channelTF = [];
|
|
296
|
+
|
|
297
|
+
for (let i = 0; i < this.config["imageData"].length; i = i + 1) {
|
|
298
|
+
const start_color = d3.rgb(0, 0, 0);
|
|
299
|
+
const end_color = d3.rgb(255, 255, 255);
|
|
300
|
+
|
|
301
|
+
const tf_def = this.createTFArray(0, 65535, start_color, end_color, this.numTFBins);
|
|
302
|
+
tf_def.name = this.config["imageData"][i].name;
|
|
303
|
+
|
|
304
|
+
this.channelTF.push(tf_def);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Applying TF to selection, subset, or all
|
|
308
|
+
this.show_subset = false;
|
|
309
|
+
this.show_selection = true;
|
|
310
|
+
|
|
311
|
+
// Hide Loader
|
|
312
|
+
this.setLoading(false);
|
|
313
|
+
|
|
314
|
+
// Config viewer
|
|
315
|
+
const viewer_config = {
|
|
316
|
+
id: "openseadragon",
|
|
317
|
+
prefixUrl: plexoraUrl("client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/"),
|
|
318
|
+
minZoomImageRatio: 0.1,
|
|
319
|
+
maxZoomPixelRatio: 15,
|
|
320
|
+
compositeOperation: "lighter",
|
|
321
|
+
loadTilesWithAjax: true,
|
|
322
|
+
immediateRender: false,
|
|
323
|
+
// OpenSeadragon 6 creates ONE TileCache on the viewer and hands the
|
|
324
|
+
// same instance to every TiledImage, so this budget has to cover
|
|
325
|
+
// (visible tiles x active channels), not just visible tiles. At
|
|
326
|
+
// 1024^2 tiles the old value of 100 was below a single viewport's
|
|
327
|
+
// worth once ~8 channels were on, so OSD kept evicting tiles it was
|
|
328
|
+
// about to redraw -- refetch and redecode mid-pan, which is the
|
|
329
|
+
// classic tile-popping stutter. Each cached tile also pins a ~1 MB
|
|
330
|
+
// decoded Uint8Array, so this doubles as the memory ceiling.
|
|
331
|
+
maxImageCacheCount: Math.min(512, Math.max(200, ((config["imageData"] || []).length) * 40)),
|
|
332
|
+
// Left at OSD's default of 0 (unlimited), OSD opens every queued
|
|
333
|
+
// tile request at once; the browser then serves them ~6 at a time
|
|
334
|
+
// per origin in issue order, so tiles for where the viewport USED to
|
|
335
|
+
// be block the ones where it is now. Capping the in-flight set keeps
|
|
336
|
+
// OSD's own ordering in charge.
|
|
337
|
+
imageLoaderLimit: 10,
|
|
338
|
+
timeout: 90000,
|
|
339
|
+
collectionMode: false,
|
|
340
|
+
preload: false,
|
|
341
|
+
homeFillsViewer: true,
|
|
342
|
+
visibilityRatio: 0,
|
|
343
|
+
// Force the canvas drawer: our per-tile WebGL colorize pass needs
|
|
344
|
+
// the 'tile-drawing' event's 2D `rendered` context, which is only
|
|
345
|
+
// guaranteed under the canvas drawer (OSD 6's WebGL drawer has no
|
|
346
|
+
// documented custom-shader hook as of this writing).
|
|
347
|
+
drawer: "canvas",
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// Instantiate the real OpenSeadragon viewer
|
|
351
|
+
this.viewer = OpenSeadragon(viewer_config);
|
|
352
|
+
// Lets the navbar status indicator report tiles that are still
|
|
353
|
+
// streaming in -- see appStatus.js watchViewer(), which tracks each
|
|
354
|
+
// TiledImage rather than the viewer's own aggregate.
|
|
355
|
+
window.PlexoraStatus?.watchViewer(this.viewer);
|
|
356
|
+
this.initProjectLabel();
|
|
357
|
+
this.initLegend();
|
|
358
|
+
this.initMiniMap();
|
|
359
|
+
this.addScaleBar();
|
|
360
|
+
this.selectionPolygonToDraw = [];
|
|
361
|
+
|
|
362
|
+
// OSD's own full-page button only resizes the #openseadragon element
|
|
363
|
+
// itself (it reparents that element to <body>), leaving the sidebar
|
|
364
|
+
// behind. Redirect it to a native Fullscreen API toggle on the whole
|
|
365
|
+
// app shell (sidebar + viewer) instead.
|
|
366
|
+
this.viewer.addHandler("pre-full-page", (event) => {
|
|
367
|
+
event.preventDefaultAction = true;
|
|
368
|
+
const shell = document.getElementById("bodyDiv");
|
|
369
|
+
if (!shell) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (document.fullscreenElement) {
|
|
373
|
+
document.exitFullscreen();
|
|
374
|
+
} else {
|
|
375
|
+
shell.requestFullscreen();
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// Get and shrink all button images
|
|
380
|
+
this.parent = d3.select(`#openseadragon`);
|
|
381
|
+
this.parent.selectAll('img')
|
|
382
|
+
.attr('height', 40);
|
|
383
|
+
|
|
384
|
+
// Force controls to bottom right
|
|
385
|
+
const controlsAnchor = this.parent.select('img').node().parentElement.parentElement.parentElement.parentElement;
|
|
386
|
+
controlsAnchor.style.right = 'unset';
|
|
387
|
+
controlsAnchor.style.top = 'unset';
|
|
388
|
+
controlsAnchor.style.left = '40vh';
|
|
389
|
+
controlsAnchor.style.bottom = '2vh';
|
|
390
|
+
|
|
391
|
+
// Flexible use of textures. Marker and constant textures occupy fixed
|
|
392
|
+
// texture units at the top of the range; tile textures do NOT get a
|
|
393
|
+
// unit each -- the fragment shader's u_tile sampler is pinned to unit 0
|
|
394
|
+
// once in GLRenderer.toBuffers(), so the tile being drawn is always
|
|
395
|
+
// bound to unit 0 and the cache below decides which texture object that
|
|
396
|
+
// is.
|
|
397
|
+
const constantTextures = ["ids", "centers", "ranges", "pickings"];
|
|
398
|
+
const otherOffset = 32 - constantTextures.length;
|
|
399
|
+
const renderer = new GLRenderer();
|
|
400
|
+
const nMarkers = 4;
|
|
401
|
+
const markerOffset = otherOffset - nMarkers;
|
|
402
|
+
const markerTextureKeys = [...Array(nMarkers).keys()];
|
|
403
|
+
renderer._otherOffset = otherOffset;
|
|
404
|
+
renderer._markerOffset = markerOffset;
|
|
405
|
+
renderer._markerTextures = markerTextureKeys.map(() => "");
|
|
406
|
+
renderer._constantTextures = constantTextures;
|
|
407
|
+
renderer._activeMarkerTexture = 0;
|
|
408
|
+
renderer._nextMarkerTexture = 0;
|
|
409
|
+
// 384 MB holds ~384 u8 1024x1024 tiles, comfortably more than the
|
|
410
|
+
// visible tiles x active channels a viewport can ask for (7+ channels x
|
|
411
|
+
// ~6 tiles), so panning back over recent ground re-binds instead of
|
|
412
|
+
// re-uploading. QuPath's equivalent tile cache is a similar fraction of
|
|
413
|
+
// available memory.
|
|
414
|
+
renderer._tileTextureCache = new GLTileTextureCache(renderer.gl, 384 * 1024 * 1024);
|
|
415
|
+
this.glRenderer = renderer;
|
|
416
|
+
|
|
417
|
+
const indexOfTexture = this.indexOfTexture.bind(this);
|
|
418
|
+
const selectTexture = this.selectTexture.bind(this);
|
|
419
|
+
const resolveGLReady = this.resolveGLReady;
|
|
420
|
+
|
|
421
|
+
renderer.loadArray = function (e, w, h) {
|
|
422
|
+
// Allow for custom drawing in webGL
|
|
423
|
+
var gl = this.gl;
|
|
424
|
+
const { source } = e.tiledImage;
|
|
425
|
+
const tileArgs = [e.tile.level, e.tile.x, e.tile.y];
|
|
426
|
+
const format = e.tile._format || `u${source.format}`;
|
|
427
|
+
const okFormat = ["u16", "u32", "u8"].includes(format);
|
|
428
|
+
const pixels = e.tile._array;
|
|
429
|
+
|
|
430
|
+
// Clear before starting all the draw calls
|
|
431
|
+
gl.clearColor(0, 0, 0, 0);
|
|
432
|
+
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
|
433
|
+
|
|
434
|
+
if (okFormat) {
|
|
435
|
+
// Format is part of the key because getTileKey() does not
|
|
436
|
+
// distinguish the HD 16-bit variant of a tile from the default
|
|
437
|
+
// 8-bit one -- without it a cache hit could bind u8 pixels to a
|
|
438
|
+
// draw that reads them as u16.
|
|
439
|
+
const cacheKey = `${source.getTileKey(...tileArgs)}|${format}`;
|
|
440
|
+
const { texture, resident } = this._tileTextureCache.acquire(
|
|
441
|
+
cacheKey, pixels ? pixels.byteLength : 0);
|
|
442
|
+
if (resident) {
|
|
443
|
+
// Already on the GPU: bind it and skip the upload. This is
|
|
444
|
+
// the whole point of the cache -- it turns a per-tile,
|
|
445
|
+
// per-frame megabyte transfer into a bind.
|
|
446
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
447
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
448
|
+
} else {
|
|
449
|
+
selectTexture(gl, texture, 0);
|
|
450
|
+
const textureArgs = {
|
|
451
|
+
u16: [gl.RG8UI, w, h, 0, gl.RG_INTEGER],
|
|
452
|
+
u32: [gl.RGBA8UI, w, h, 0, gl.RGBA_INTEGER],
|
|
453
|
+
u8: [gl.R8UI, w, h, 0, gl.RED_INTEGER],
|
|
454
|
+
}[format];
|
|
455
|
+
|
|
456
|
+
// Send the tile into the texture.
|
|
457
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, ...textureArgs, gl.UNSIGNED_BYTE, pixels);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
this.gl_arguments.tile_shape_2fv = new Float32Array([w, h]);
|
|
462
|
+
|
|
463
|
+
// Call gl-drawing after loading
|
|
464
|
+
this["gl-drawing"].call(this);
|
|
465
|
+
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
466
|
+
return gl.canvas;
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
renderer.vShader = plexoraUrl("client/src/shaders/vert.glsl");
|
|
470
|
+
renderer.fShader = plexoraUrl("client/src/shaders/frag.glsl");
|
|
471
|
+
|
|
472
|
+
// Default tile-drawing behavior, invoked as the "callback" from the
|
|
473
|
+
// custom handler below.
|
|
474
|
+
function tileDrawingDefault(e) {
|
|
475
|
+
var w = e.rendered.canvas.width;
|
|
476
|
+
var h = e.rendered.canvas.height;
|
|
477
|
+
var gl_w = renderer.width;
|
|
478
|
+
var gl_h = renderer.height;
|
|
479
|
+
|
|
480
|
+
// Render a webGL canvas to an input canvas
|
|
481
|
+
var output = renderer.loadArray(e, w, h);
|
|
482
|
+
e.rendered.drawImage(output, 0, 0, gl_w, gl_h, 0, 0, w, h);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const { floatRange } = this.numericData;
|
|
486
|
+
const findCurrentChannel = this.findCurrentChannel.bind(this);
|
|
487
|
+
const selectCenterProps = this.selectCenterProps.bind(this);
|
|
488
|
+
const labelOutlinesEnabled = () => !!this.viewerManagerVMain?.sel_outlines;
|
|
489
|
+
const modeFlags = () => this.modeFlags;
|
|
490
|
+
// Custom tile-drawing handler
|
|
491
|
+
const tileDrawingCustom = async (callback, e) => {
|
|
492
|
+
// Read parameters from each tile
|
|
493
|
+
const { source } = e.tiledImage;
|
|
494
|
+
const { tileFormat } = source;
|
|
495
|
+
const w = e.rendered.canvas.width;
|
|
496
|
+
const h = e.rendered.canvas.height;
|
|
497
|
+
|
|
498
|
+
if (tileFormat != 32) {
|
|
499
|
+
// A tile's URL never changes, so derive sub_url once and keep it
|
|
500
|
+
// on the tile. This was a getUrl() + String.split() allocation
|
|
501
|
+
// per tile per channel per frame.
|
|
502
|
+
let sub_url = e.tile._subUrl;
|
|
503
|
+
if (sub_url === undefined) {
|
|
504
|
+
const group = e.tile.getUrl().split("/");
|
|
505
|
+
sub_url = group[group.length - 3];
|
|
506
|
+
e.tile._subUrl = sub_url;
|
|
507
|
+
}
|
|
508
|
+
const channel = findCurrentChannel(sub_url);
|
|
509
|
+
const range = _.get(channel, "range", floatRange);
|
|
510
|
+
const color = _.get(channel, "color", d3.color("white"));
|
|
511
|
+
const floatColor = toFloatColor(color);
|
|
512
|
+
// The fast/default tile path quantizes 16-bit -> 8-bit
|
|
513
|
+
// server-side, linear against the channel's true max (see
|
|
514
|
+
// get_channel_quantization_window). The shader works directly
|
|
515
|
+
// in that same [0, 255] byte domain -- u_tile_range is
|
|
516
|
+
// expressed in byte units too in this mode (see
|
|
517
|
+
// viewerSidebar.js's getImageRange/toImageConnectorRange),
|
|
518
|
+
// so no reconstruction back into 16-bit units is needed here.
|
|
519
|
+
const tileFmt = e.tile._format === "u8" ? 8 : 16;
|
|
520
|
+
const modes = modeFlags();
|
|
521
|
+
|
|
522
|
+
// `e.rendered` is this tile's OWN persistent 2D context -- OSD
|
|
523
|
+
// resolves it per tile via DrawerBase.getDataToDraw() (the tile
|
|
524
|
+
// cache) and blits it onto the canvas right after this handler
|
|
525
|
+
// returns. So when it already holds exactly this colorization
|
|
526
|
+
// there is nothing to do.
|
|
527
|
+
//
|
|
528
|
+
// This early return is the single biggest win available on the
|
|
529
|
+
// client. A CPU profile of a 7-channel pan attributed 81.9% of
|
|
530
|
+
// all wall time to one call: the WebGL-canvas -> 2D-canvas blit
|
|
531
|
+
// at the end of this path, running ~103 times per frame at
|
|
532
|
+
// ~2.3 ms each, because OSD re-raises tile-drawing for every
|
|
533
|
+
// visible tile of every channel on every frame. The pixels were
|
|
534
|
+
// almost always identical to the previous frame's.
|
|
535
|
+
//
|
|
536
|
+
// The signature covers everything the draw below depends on:
|
|
537
|
+
// tile identity (cacheKey also changes when HD swaps the pixel
|
|
538
|
+
// data), the pixel format, and the channel's colour/range/mode.
|
|
539
|
+
const sig = `${e.tile.cacheKey}|${tileFmt}|${floatColor}|${range}|${modes.edge},${modes.or}`;
|
|
540
|
+
if (e.rendered._plexoraSig === sig) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (!e.tile._array) {
|
|
545
|
+
// Not loaded yet -- e.g. right after the HD toggle forces
|
|
546
|
+
// every visible tile to redraw immediately, before the
|
|
547
|
+
// freshly-invalidated tile's fetch/decode has finished.
|
|
548
|
+
// Falling through with pixels=undefined would still reach
|
|
549
|
+
// gl.texImage2D, which allocates the texture with whatever
|
|
550
|
+
// GPU memory happened to be there -- rendered as solid
|
|
551
|
+
// static instead of skipping this frame.
|
|
552
|
+
e.rendered._plexoraSig = null;
|
|
553
|
+
e.rendered.fillStyle = "black";
|
|
554
|
+
e.rendered.fillRect(0, 0, w, h);
|
|
555
|
+
console.warn("Missing Array for tile:", e.tile.getUrl(), "- skipping rendering");
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// The black fill is load-bearing, not redundant: the shader
|
|
560
|
+
// emits alpha 0.9, so the GL output composites over whatever is
|
|
561
|
+
// already in this reused canvas.
|
|
562
|
+
e.rendered.fillStyle = "black";
|
|
563
|
+
e.rendered.fillRect(0, 0, w, h);
|
|
564
|
+
|
|
565
|
+
// Store channel color and range to send to shader
|
|
566
|
+
renderer.gl_arguments = {
|
|
567
|
+
...selectCenterProps(e.tile, source),
|
|
568
|
+
centers: [],
|
|
569
|
+
id_end_1i: 0,
|
|
570
|
+
picked_end_1i: 0,
|
|
571
|
+
color_3fv: new Float32Array(floatColor),
|
|
572
|
+
range_2fv: new Float32Array(range),
|
|
573
|
+
fmt_1i: tileFmt,
|
|
574
|
+
};
|
|
575
|
+
callback(e);
|
|
576
|
+
e.rendered._plexoraSig = sig;
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Label/segmentation tiles must stay transparent outside outlines
|
|
581
|
+
// so image channels underneath remain visible. Not signature-cached:
|
|
582
|
+
// the label tiled image is a single item (so this is not multiplied
|
|
583
|
+
// by the channel count), and what it draws also depends on each
|
|
584
|
+
// layer's gate and colours, which rerenderSegmentationTiles()
|
|
585
|
+
// rebuilds out of band.
|
|
586
|
+
e.rendered.clearRect(0, 0, w, h);
|
|
587
|
+
|
|
588
|
+
if (e.tile._layerContexts) {
|
|
589
|
+
if (labelOutlinesEnabled()) {
|
|
590
|
+
// BLIT ORDER IS Z-ORDER, and it is the whole of the stacking
|
|
591
|
+
// model for mask layers: bottom first, each one painting over
|
|
592
|
+
// whatever the layers below it left. Reordering the sidebar
|
|
593
|
+
// therefore costs a redraw and never a re-render -- the tile
|
|
594
|
+
// canvases already hold the right pixels.
|
|
595
|
+
//
|
|
596
|
+
// Opacity is applied HERE rather than baked into a layer's
|
|
597
|
+
// pixels for the same reason. This branch already re-runs
|
|
598
|
+
// every frame, so a slider drag costs one extra blit
|
|
599
|
+
// argument; folding it into renderLabelTile would instead
|
|
600
|
+
// re-derive every visible tile's boundaries on every pointer
|
|
601
|
+
// move.
|
|
602
|
+
const previous = e.rendered.globalAlpha;
|
|
603
|
+
let alpha = previous;
|
|
604
|
+
for (const layer of this.maskDrawList()) {
|
|
605
|
+
const context = e.tile._layerContexts.get(layer.name);
|
|
606
|
+
if (!context) continue;
|
|
607
|
+
// A layer with no colours is the plain white cell layer,
|
|
608
|
+
// which predates the opacity control and must keep
|
|
609
|
+
// compositing at full strength -- see layerAlpha.
|
|
610
|
+
const next = layer.lut ? layer.opacity : 1;
|
|
611
|
+
if (next !== alpha) {
|
|
612
|
+
e.rendered.globalAlpha = next;
|
|
613
|
+
alpha = next;
|
|
614
|
+
}
|
|
615
|
+
e.rendered.drawImage(context.canvas, 0, 0, w, h);
|
|
616
|
+
}
|
|
617
|
+
if (alpha !== previous) e.rendered.globalAlpha = previous;
|
|
618
|
+
}
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (!e.tile._array) {
|
|
623
|
+
console.warn("Missing Array for tile:", e.tile.getUrl(), "- skipping rendering");
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Use new parameters for this tile
|
|
628
|
+
renderer.gl_arguments = {
|
|
629
|
+
...selectCenterProps(e.tile, source),
|
|
630
|
+
color_3fv: new Float32Array([1, 1, 1]),
|
|
631
|
+
range_2fv: new Float32Array([0, 1]),
|
|
632
|
+
fmt_1i: 32,
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
// Start webGL rendering
|
|
636
|
+
callback(e);
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
renderer["gl-drawing"] = function () {
|
|
640
|
+
const args = this.gl_arguments;
|
|
641
|
+
|
|
642
|
+
// Send color and range to shader
|
|
643
|
+
this.gl.uniform2fv(this.u_tile_shape, args.tile_shape_2fv);
|
|
644
|
+
this.gl.uniform4iv(this.u_marker_sample, args.marker_sample_4iv);
|
|
645
|
+
this.gl.uniform2iv(this.u_magnitude_shape, args.magnitude_2iv);
|
|
646
|
+
this.gl.uniform1f(this.u_tile_fraction, args.tile_fraction_1f);
|
|
647
|
+
this.gl.uniform1f(this.u_tile_scale, args.tile_scale_1f);
|
|
648
|
+
this.gl.uniform1f(this.u_pie_radius, args.pie_radius_1f);
|
|
649
|
+
this.gl.uniform1i(this.u_picked_end, args.picked_end_1i);
|
|
650
|
+
this.gl.uniform2fv(this.u_tile_origin, args.origin_2fv);
|
|
651
|
+
this.gl.uniform3fv(this.u_tile_color, args.color_3fv);
|
|
652
|
+
this.gl.uniform2fv(this.u_tile_range, args.range_2fv);
|
|
653
|
+
this.gl.uniform2iv(this.u_draw_mode, args.modes_2i);
|
|
654
|
+
this.gl.uniform2fv(this.u_x_bounds, args.x_bounds_2fv);
|
|
655
|
+
this.gl.uniform2fv(this.u_y_bounds, args.y_bounds_2fv);
|
|
656
|
+
this.gl.uniform1i(this.u_tile_fmt, args.fmt_1i);
|
|
657
|
+
this.gl.uniform1i(this.u_id_end, args.id_end_1i);
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
renderer["gl-loaded"] = function (program) {
|
|
661
|
+
// Uniform variables for coloring
|
|
662
|
+
this.u_ids_shape = this.gl.getUniformLocation(program, "u_ids_shape");
|
|
663
|
+
this.u_tile_shape = this.gl.getUniformLocation(program, "u_tile_shape");
|
|
664
|
+
this.u_cell_range_shape = this.gl.getUniformLocation(program, "u_cell_range_shape");
|
|
665
|
+
this.u_center_shape = this.gl.getUniformLocation(program, "u_center_shape");
|
|
666
|
+
this.u_picking_shape = this.gl.getUniformLocation(program, "u_picking_shape");
|
|
667
|
+
this.u_marker_sample = this.gl.getUniformLocation(program, "u_marker_sample");
|
|
668
|
+
this.u_magnitude_shape = this.gl.getUniformLocation(program, "u_magnitude_shape");
|
|
669
|
+
this.u_tile_fraction = this.gl.getUniformLocation(program, "u_tile_fraction");
|
|
670
|
+
this.u_tile_scale = this.gl.getUniformLocation(program, "u_tile_scale");
|
|
671
|
+
this.u_pie_radius = this.gl.getUniformLocation(program, "u_pie_radius");
|
|
672
|
+
this.u_tile_origin = this.gl.getUniformLocation(program, "u_tile_origin");
|
|
673
|
+
this.u_tile_range = this.gl.getUniformLocation(program, "u_tile_range");
|
|
674
|
+
this.u_tile_color = this.gl.getUniformLocation(program, "u_tile_color");
|
|
675
|
+
this.u_draw_mode = this.gl.getUniformLocation(program, "u_draw_mode");
|
|
676
|
+
this.u_x_bounds = this.gl.getUniformLocation(program, "u_x_bounds");
|
|
677
|
+
this.u_y_bounds = this.gl.getUniformLocation(program, "u_y_bounds");
|
|
678
|
+
this.u_tile_fmt = this.gl.getUniformLocation(program, "u_tile_fmt");
|
|
679
|
+
this.u_picked_end = this.gl.getUniformLocation(program, "u_picked_end");
|
|
680
|
+
this.u_id_end = this.gl.getUniformLocation(program, "u_id_end");
|
|
681
|
+
|
|
682
|
+
// Texture for colormap
|
|
683
|
+
const u_ids = this.gl.getUniformLocation(program, "u_ids");
|
|
684
|
+
const u_cell_ranges = this.gl.getUniformLocation(program, "u_cell_ranges");
|
|
685
|
+
const u_centers = this.gl.getUniformLocation(program, "u_centers");
|
|
686
|
+
const u_pickings = this.gl.getUniformLocation(program, "u_pickings");
|
|
687
|
+
this.gl.uniform1i(u_ids, indexOfTexture("ids", null));
|
|
688
|
+
this.gl.uniform1i(u_cell_ranges, indexOfTexture("ranges", null));
|
|
689
|
+
this.gl.uniform1i(u_centers, indexOfTexture("centers", null));
|
|
690
|
+
this.gl.uniform1i(u_pickings, indexOfTexture("pickings", null));
|
|
691
|
+
for (const i of [0, 1, 2, 3]) {
|
|
692
|
+
const u_mag_i = this.gl.getUniformLocation(program, `u_mag_${i}`);
|
|
693
|
+
this.gl.uniform1i(u_mag_i, i + this._markerOffset);
|
|
694
|
+
}
|
|
695
|
+
setTimeout(() => resolveGLReady(), 0);
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
const matchTile = (e, { x, y, level }) => {
|
|
699
|
+
const grid = e.tiledImage.tilesMatrix[level];
|
|
700
|
+
return ((grid || {})[x] || {})[y] || {};
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
const decodeLabelTile = (responseArray) => {
|
|
704
|
+
const upng = window.UPNG;
|
|
705
|
+
if (upng) {
|
|
706
|
+
const img = upng.decode(responseArray);
|
|
707
|
+
if (img.ctype == 6 && img.depth == 8) {
|
|
708
|
+
return {
|
|
709
|
+
data: img.data.slice(0, 4 * img.width * img.height),
|
|
710
|
+
width: img.width,
|
|
711
|
+
height: img.height,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const pngBuffer = new Buffer(responseArray);
|
|
717
|
+
const pngArray = PNG.sync.read(pngBuffer);
|
|
718
|
+
return {
|
|
719
|
+
data: pngArray.data.slice(0, 4 * pngArray.width * pngArray.height),
|
|
720
|
+
width: pngArray.width,
|
|
721
|
+
height: pngArray.height,
|
|
722
|
+
};
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
// `layer` is one entry of the cell-layer registry, or coreLayerView()
|
|
726
|
+
// when no plugin has registered one -- it carries the gate, the colours
|
|
727
|
+
// and the mode this pass draws with. Passed in rather than read off
|
|
728
|
+
// `this` because several layers are rendered from the SAME decoded tile,
|
|
729
|
+
// once each, and they must not see each other's state.
|
|
730
|
+
const renderLabelTile = (tileArray, width, height, layer) => {
|
|
731
|
+
const allowedIds = layer.filterIds;
|
|
732
|
+
// Per-cell colour from the plugin owning this layer, or null for the
|
|
733
|
+
// plain white layer this has always drawn. Read once per tile rather
|
|
734
|
+
// than per pixel, and destructured so the inner loop does no
|
|
735
|
+
// property lookups at all.
|
|
736
|
+
const lut = layer.lut;
|
|
737
|
+
const lutColors = lut?.colors || null;
|
|
738
|
+
const lutMaxId = lutColors ? lut.maxId : 0;
|
|
739
|
+
const lutMap = lutColors ? null : (lut?.map || null);
|
|
740
|
+
// Filled draws the whole cell rather than its boundary. Only
|
|
741
|
+
// possible where the labels are stored whole -- a pyramid that was
|
|
742
|
+
// pre-reduced to outlines has no interior pixels to paint, so there
|
|
743
|
+
// is nothing to fill and asking for it changes nothing.
|
|
744
|
+
const fillMode = layer.mode === "filled"
|
|
745
|
+
&& this.config?.segmentationMode === "filled";
|
|
746
|
+
// Datasources imported with "Outline cells while viewing" store the
|
|
747
|
+
// labels whole rather than pre-reduced to boundaries (config
|
|
748
|
+
// segmentationMode = "filled"), so the boundary is derived here --
|
|
749
|
+
// once per tile at load, not per frame. Everything else arrives
|
|
750
|
+
// already reduced, where every labelled pixel *is* an outline.
|
|
751
|
+
// Filled skips the derivation entirely, which is also the cheaper
|
|
752
|
+
// path: no neighbour unpacking and no eight-way test per pixel.
|
|
753
|
+
const deriveOutlines = this.config?.segmentationMode === "filled" && !fillMode;
|
|
754
|
+
const canvas = document.createElement("canvas");
|
|
755
|
+
canvas.width = width;
|
|
756
|
+
canvas.height = height;
|
|
757
|
+
const context = canvas.getContext("2d");
|
|
758
|
+
const imageData = context.createImageData(width, height);
|
|
759
|
+
const output = imageData.data;
|
|
760
|
+
|
|
761
|
+
// Unpacked up front so the neighbour lookups below are plain array
|
|
762
|
+
// reads instead of re-decoding four bytes per tap.
|
|
763
|
+
let ids = null;
|
|
764
|
+
if (deriveOutlines) {
|
|
765
|
+
ids = new Uint32Array(width * height);
|
|
766
|
+
for (let p = 0; p < ids.length; p += 1) {
|
|
767
|
+
const i = p * 4;
|
|
768
|
+
ids[p] = tileArray[i]
|
|
769
|
+
+ tileArray[i + 1] * 256
|
|
770
|
+
+ tileArray[i + 2] * 65536
|
|
771
|
+
+ tileArray[i + 3] * 16777216;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
for (let i = 0; i < tileArray.length; i += 4) {
|
|
776
|
+
const cellId = ids
|
|
777
|
+
? ids[i >> 2]
|
|
778
|
+
: tileArray[i]
|
|
779
|
+
+ tileArray[i + 1] * 256
|
|
780
|
+
+ tileArray[i + 2] * 65536
|
|
781
|
+
+ tileArray[i + 3] * 16777216;
|
|
782
|
+
if (!cellId || (allowedIds && !allowedIds.has(cellId))) continue;
|
|
783
|
+
// White at the layer's long-standing alpha unless a plugin says
|
|
784
|
+
// otherwise. Looked up BEFORE the boundary derivation below: a
|
|
785
|
+
// hidden category or a cell with no value is skipped here, so it
|
|
786
|
+
// never pays for the eight-neighbour test -- which is what makes
|
|
787
|
+
// hiding most of a legend faster to draw, not slower.
|
|
788
|
+
let red = 255;
|
|
789
|
+
let green = 255;
|
|
790
|
+
let blue = 255;
|
|
791
|
+
let alpha = 220;
|
|
792
|
+
if (lutColors) {
|
|
793
|
+
// Above maxId means the LUT simply does not describe this
|
|
794
|
+
// cell -- a segmentation object with no row in the table.
|
|
795
|
+
// Transparent, like every other kind of no-value.
|
|
796
|
+
if (cellId > lutMaxId) continue;
|
|
797
|
+
const offset = cellId * 4;
|
|
798
|
+
alpha = lutColors[offset + 3];
|
|
799
|
+
if (!alpha) continue;
|
|
800
|
+
red = lutColors[offset];
|
|
801
|
+
green = lutColors[offset + 1];
|
|
802
|
+
blue = lutColors[offset + 2];
|
|
803
|
+
} else if (lutMap) {
|
|
804
|
+
const entry = lutMap.get(cellId);
|
|
805
|
+
if (!entry || !entry[3]) continue;
|
|
806
|
+
[red, green, blue, alpha] = entry;
|
|
807
|
+
}
|
|
808
|
+
if (ids) {
|
|
809
|
+
const p = i >> 2;
|
|
810
|
+
const x = p % width;
|
|
811
|
+
const y = (p - x) / width;
|
|
812
|
+
// Eight-neighbour, matching the "exact" method the offline
|
|
813
|
+
// pyramid writer uses (segmentation_pyramid.py). Four-
|
|
814
|
+
// neighbour leaves cells that meet only corner-to-corner
|
|
815
|
+
// sharing an unbroken block of white, which reads as one
|
|
816
|
+
// large cell; measured against a precomputed pyramid of the
|
|
817
|
+
// same mask it also drew ~28% fewer boundary pixels overall.
|
|
818
|
+
//
|
|
819
|
+
// Compared against raw ids, not the gated subset: a cell's
|
|
820
|
+
// edge against a filtered-out neighbour is still its edge.
|
|
821
|
+
// A tile's own border counts as "same" rather than as a
|
|
822
|
+
// boundary -- treating it as one would draw a bright grid
|
|
823
|
+
// along every tile seam. The cost is that a real boundary
|
|
824
|
+
// landing exactly on a seam loses that pixel; a cell simply
|
|
825
|
+
// continuing across the seam stays correct, which is the
|
|
826
|
+
// overwhelmingly common case.
|
|
827
|
+
const up = y > 0;
|
|
828
|
+
const down = y < height - 1;
|
|
829
|
+
const left = x > 0;
|
|
830
|
+
const right = x < width - 1;
|
|
831
|
+
const onBoundary =
|
|
832
|
+
(left && ids[p - 1] !== cellId)
|
|
833
|
+
|| (right && ids[p + 1] !== cellId)
|
|
834
|
+
|| (up && ids[p - width] !== cellId)
|
|
835
|
+
|| (down && ids[p + width] !== cellId)
|
|
836
|
+
|| (up && left && ids[p - width - 1] !== cellId)
|
|
837
|
+
|| (up && right && ids[p - width + 1] !== cellId)
|
|
838
|
+
|| (down && left && ids[p + width - 1] !== cellId)
|
|
839
|
+
|| (down && right && ids[p + width + 1] !== cellId);
|
|
840
|
+
if (!onBoundary) continue;
|
|
841
|
+
}
|
|
842
|
+
output[i] = red;
|
|
843
|
+
output[i + 1] = green;
|
|
844
|
+
output[i + 2] = blue;
|
|
845
|
+
output[i + 3] = alpha;
|
|
846
|
+
}
|
|
847
|
+
context.putImageData(imageData, 0, 0);
|
|
848
|
+
return context;
|
|
849
|
+
};
|
|
850
|
+
this.renderLabelTile = renderLabelTile;
|
|
851
|
+
// Decode workers. Capped at 4: decode is memory-bandwidth bound, and
|
|
852
|
+
// more workers than that mostly adds ~3 MB of live intermediates each
|
|
853
|
+
// without decoding faster.
|
|
854
|
+
const decoderPool = TileDecoderPool.create(
|
|
855
|
+
plexoraUrl("client/src/js/workers/tileDecoder.js"),
|
|
856
|
+
Math.max(2, Math.min(4, navigator.hardwareConcurrency || 4)),
|
|
857
|
+
);
|
|
858
|
+
this.decoderPool = decoderPool;
|
|
859
|
+
|
|
860
|
+
// Same algorithm as the worker, for when workers aren't available.
|
|
861
|
+
const decodeWebpInline = async (responseArray) => {
|
|
862
|
+
const blob = new Blob([responseArray], { type: "image/webp" });
|
|
863
|
+
const bitmap = await createImageBitmap(blob, { premultiplyAlpha: "none", colorSpaceConversion: "none" });
|
|
864
|
+
const { width, height } = bitmap; // capture before close() -- closing zeroes these
|
|
865
|
+
const canvas = new OffscreenCanvas(width, height);
|
|
866
|
+
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
|
867
|
+
ctx.drawImage(bitmap, 0, 0);
|
|
868
|
+
const rgba = ctx.getImageData(0, 0, width, height).data;
|
|
869
|
+
bitmap.close();
|
|
870
|
+
const array = new Uint8Array(width * height);
|
|
871
|
+
for (let i = 0; i < array.length; i++) {
|
|
872
|
+
array[i] = rgba[i * 4];
|
|
873
|
+
}
|
|
874
|
+
return { array, width, height };
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
// tile-loaded handling: decode the raw tile bytes ourselves. OpenSeadragon
|
|
879
|
+
// 6.x exposes the underlying XHR on `e.tileRequest` (a stable, non-deprecated
|
|
880
|
+
// property) with `response` as an ArrayBuffer of the raw compressed tile (OSD
|
|
881
|
+
// uses responseType: "arraybuffer" for AJAX-loaded tiles), so no forked
|
|
882
|
+
// ImageJob is needed to reach it. Registered directly as an async function:
|
|
883
|
+
// OpenSeadragon awaits a handler's returned promise (raiseEventAwaiting) before
|
|
884
|
+
// considering the tile loaded, so no explicit getCompletionCallback() is needed.
|
|
885
|
+
const forceRepaint = this.forceRepaint.bind(this);
|
|
886
|
+
const handleTileLoaded = async (e) => {
|
|
887
|
+
const { source } = e.tiledImage;
|
|
888
|
+
const { tileFormat } = source;
|
|
889
|
+
try {
|
|
890
|
+
const responseArray = e.tileRequest?.response;
|
|
891
|
+
if (tileFormat == 32) {
|
|
892
|
+
e.tile._isLabel = true;
|
|
893
|
+
if (!e.tile?._array && responseArray) {
|
|
894
|
+
const decoded = decodeLabelTile(responseArray);
|
|
895
|
+
e.tile._array = decoded.data;
|
|
896
|
+
e.tile._format = "u32";
|
|
897
|
+
// Kept so a layer turned back on later can be rendered
|
|
898
|
+
// into this tile without re-deriving the shape from the
|
|
899
|
+
// canvas it no longer has.
|
|
900
|
+
e.tile._labelWidth = decoded.width;
|
|
901
|
+
e.tile._labelHeight = decoded.height;
|
|
902
|
+
this.renderTileLayers(e.tile);
|
|
903
|
+
}
|
|
904
|
+
if (e.tile?._layerContexts) {
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// Trigger loading of image
|
|
909
|
+
const tileArgs = [e.tile.level, e.tile.x, e.tile.y];
|
|
910
|
+
const tl = source.toTileLevels(...tileArgs);
|
|
911
|
+
if (tl.relativeImageScale < 1) {
|
|
912
|
+
const tile = matchTile(e, tl.outputTile);
|
|
913
|
+
if (tile?._array && tile?._format) {
|
|
914
|
+
e.tile._format = tile._format;
|
|
915
|
+
e.tile._array = tile._array;
|
|
916
|
+
}
|
|
917
|
+
if (e.tile?._array) {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
else if (tileFormat == 32) {
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
else if (responseArray) {
|
|
925
|
+
const sig = new Uint8Array(responseArray, 0, 4);
|
|
926
|
+
const isWebp = sig[0] === 0x52 && sig[1] === 0x49 && sig[2] === 0x46 && sig[3] === 0x46; // "RIFF"
|
|
927
|
+
if (isWebp) {
|
|
928
|
+
// Fast/default tile path: quantized 8-bit, single value
|
|
929
|
+
// per pixel. Decoded via createImageBitmap (not
|
|
930
|
+
// UPNG.js, which can't parse WebP) -- confirmed safe
|
|
931
|
+
// for this opaque/single-channel case by an in-browser
|
|
932
|
+
// spike; segmentation tiles never take this path (see
|
|
933
|
+
// decodeLabelTile) because the same decode approach
|
|
934
|
+
// was found to corrupt RGB wherever alpha=0.
|
|
935
|
+
//
|
|
936
|
+
// Preferably in a worker: the canvas readback and the
|
|
937
|
+
// per-pixel RGBA->R strip cost ~60% of a 15-channel pan
|
|
938
|
+
// when run on the main thread. The inline branch is the
|
|
939
|
+
// fallback when workers or OffscreenCanvas are absent.
|
|
940
|
+
let decoded;
|
|
941
|
+
try {
|
|
942
|
+
decoded = decoderPool
|
|
943
|
+
? await decoderPool.decode(responseArray, "webp")
|
|
944
|
+
: await decodeWebpInline(responseArray);
|
|
945
|
+
} catch (workerErr) {
|
|
946
|
+
// A worker failure must never cost us the tile --
|
|
947
|
+
// OSD is awaiting this handler, and a rejection here
|
|
948
|
+
// would leave the tile permanently blank.
|
|
949
|
+
console.warn("Worker tile decode failed, falling back inline:", workerErr);
|
|
950
|
+
decoded = await decodeWebpInline(responseArray);
|
|
951
|
+
}
|
|
952
|
+
e.tile._array = decoded.array;
|
|
953
|
+
e.tile._format = "u8";
|
|
954
|
+
} else {
|
|
955
|
+
// HD (16-bit grayscale PNG) and segmentation tiles.
|
|
956
|
+
//
|
|
957
|
+
// The HD path goes to the worker, which inflates with the
|
|
958
|
+
// browser's native DecompressionStream rather than
|
|
959
|
+
// UPNG.js/pako. A CPU profile of a 7-channel HD pan put
|
|
960
|
+
// 81% of ALL time in pako's JS inflate. Segmentation
|
|
961
|
+
// tiles stay on UPNG here: they are RGBA8 rather than
|
|
962
|
+
// gray16, and there is only one label layer so the cost
|
|
963
|
+
// is not multiplied by the channel count.
|
|
964
|
+
let decoded = null;
|
|
965
|
+
if (decoderPool && tileFormat != 32) {
|
|
966
|
+
try {
|
|
967
|
+
decoded = await decoderPool.decode(responseArray, "gray16png");
|
|
968
|
+
} catch (workerErr) {
|
|
969
|
+
if (!workerErr.unsupported) {
|
|
970
|
+
console.warn("Worker HD decode failed, falling back to UPNG:", workerErr);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
if (decoded) {
|
|
975
|
+
e.tile._array = decoded.array;
|
|
976
|
+
e.tile._format = "u16";
|
|
977
|
+
} else {
|
|
978
|
+
const img = window.UPNG.decode(responseArray);
|
|
979
|
+
if (img.ctype == 0 && img.depth == 16) {
|
|
980
|
+
e.tile._array = img.data.slice(0, 2 * img.width * img.height);
|
|
981
|
+
e.tile._format = "u16";
|
|
982
|
+
} else if (img.ctype == 6 && img.depth == 8) {
|
|
983
|
+
e.tile._array = img.data.slice(0, 4 * img.width * img.height);
|
|
984
|
+
e.tile._format = "u32";
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
} catch (err) {
|
|
990
|
+
console.log("Load Error, Refreshing", err, e.tile.getUrl());
|
|
991
|
+
forceRepaint();
|
|
992
|
+
}
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
// Disable canvas image smoothing once per drawer canvas, not once per
|
|
997
|
+
// tile per frame. This used to live in a "tile-drawn" handler that also
|
|
998
|
+
// ran _.size() over the entire shared tile cache and re-fetched the 2D
|
|
999
|
+
// context for every tile of every channel on every frame -- O(tiles^2)
|
|
1000
|
+
// work per frame whose only lasting effect was these four flags. (The
|
|
1001
|
+
// _imagesLoadedCount it maintained is an OpenSeadragon 2.x field that
|
|
1002
|
+
// OSD 6 no longer reads.)
|
|
1003
|
+
const disableSmoothing = () => {
|
|
1004
|
+
const canvas = this.viewer?.drawer?.canvas;
|
|
1005
|
+
const context = canvas?.getContext?.("2d");
|
|
1006
|
+
if (!context) {
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
context.mozImageSmoothingEnabled = false;
|
|
1010
|
+
context.webkitImageSmoothingEnabled = false;
|
|
1011
|
+
context.msImageSmoothingEnabled = false;
|
|
1012
|
+
context.imageSmoothingEnabled = false;
|
|
1013
|
+
};
|
|
1014
|
+
this.viewer.addHandler("open", disableSmoothing);
|
|
1015
|
+
this.viewer.addHandler("resize", disableSmoothing);
|
|
1016
|
+
|
|
1017
|
+
this.viewer.addHandler("tile-unloaded", (e) => {
|
|
1018
|
+
delete e.tile._array;
|
|
1019
|
+
// The per-layer canvases are the larger half of a label tile's
|
|
1020
|
+
// footprint -- one 4 MB RGBA canvas per visible layer against the
|
|
1021
|
+
// 4 MB decoded array -- and they used to be left behind here
|
|
1022
|
+
// entirely, so panning a slide with the mask on grew the heap for
|
|
1023
|
+
// as long as the session lasted.
|
|
1024
|
+
e.tile._layerContexts?.clear();
|
|
1025
|
+
delete e.tile._layerContexts;
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
// GL initialization: on 'open', size the GL canvas, compile shaders,
|
|
1029
|
+
// then wire the real tile-loaded/tile-drawing handlers and force
|
|
1030
|
+
// existing items to redraw. viewerManager.js manually re-raises 'open'
|
|
1031
|
+
// after adding the label tiled image, so this runs more than once by
|
|
1032
|
+
// design.
|
|
1033
|
+
const initGL = () => {
|
|
1034
|
+
renderer.width = renderer.width || this.config.tileWidth;
|
|
1035
|
+
renderer.height = renderer.height || this.config.tileHeight;
|
|
1036
|
+
renderer.updateShape(renderer.width, renderer.height);
|
|
1037
|
+
renderer.init().then(() => {
|
|
1038
|
+
this.viewer.addHandler("tile-loaded", handleTileLoaded);
|
|
1039
|
+
this.viewer.addHandler("tile-drawing", (e) => tileDrawingCustom(tileDrawingDefault, e));
|
|
1040
|
+
|
|
1041
|
+
const world = this.viewer.world;
|
|
1042
|
+
for (let i = 0; i < world.getItemCount(); i++) {
|
|
1043
|
+
world.getItemAt(i)._needsDraw = true;
|
|
1044
|
+
}
|
|
1045
|
+
world.update();
|
|
1046
|
+
});
|
|
1047
|
+
};
|
|
1048
|
+
this.viewer.addHandler("open", initGL);
|
|
1049
|
+
|
|
1050
|
+
// No tile-cache monitor here on purpose. OpenSeadragon 6 evicts against
|
|
1051
|
+
// maxImageCacheCount itself (TileCache._freeOldRecordRoutine) and does
|
|
1052
|
+
// it correctly; the interval that used to live here summed
|
|
1053
|
+
// item._tileCache._tilesLoaded.length across every item, but OSD hands
|
|
1054
|
+
// the SAME TileCache instance to every TiledImage, so the total was
|
|
1055
|
+
// multiplied by the channel count and the threshold tripped at roughly
|
|
1056
|
+
// 1000/N real tiles -- then called an evictor that freed nothing and
|
|
1057
|
+
// corrupted OSD's LRU bookkeeping (see clearTileCache).
|
|
1058
|
+
|
|
1059
|
+
this.viewer.scalebar({
|
|
1060
|
+
location: OpenSeadragon.ScalebarLocation.BOTTOM_RIGHT,
|
|
1061
|
+
minWidth: "100px",
|
|
1062
|
+
type: OpenSeadragon.ScalebarType.MICROSCOPY,
|
|
1063
|
+
stayInsideImage: true,
|
|
1064
|
+
pixelsPerMeter: this.getPixelsPerMeter(),
|
|
1065
|
+
fontColor: "rgb(255, 255, 255)",
|
|
1066
|
+
color: "rgb(255, 255, 255)",
|
|
1067
|
+
backgroundColor: "rgba(0, 0, 0, 0.45)",
|
|
1068
|
+
barThickness: 3,
|
|
1069
|
+
});
|
|
1070
|
+
this.styleScaleBar();
|
|
1071
|
+
|
|
1072
|
+
// Add event mouse handler (cell selection)
|
|
1073
|
+
this.viewer.addHandler("canvas-nonprimary-press", (e) => {
|
|
1074
|
+
// Right click (cell selection)
|
|
1075
|
+
if (event.button === 2) {
|
|
1076
|
+
const { numericData } = this;
|
|
1077
|
+
const { source } = e.eventSource;
|
|
1078
|
+
const tiledImage = this.viewer.world.getItemAt(0);
|
|
1079
|
+
const imageCoords = source.getImagePixel(tiledImage, e.position);
|
|
1080
|
+
return numericData.getNearestCell(...imageCoords).then((item) => {
|
|
1081
|
+
if (item !== null && item !== undefined) {
|
|
1082
|
+
// Check if user is doing multi-selection or not
|
|
1083
|
+
let clearPriors = true;
|
|
1084
|
+
if (e.originalEvent.ctrlKey) {
|
|
1085
|
+
clearPriors = false;
|
|
1086
|
+
}
|
|
1087
|
+
// Trigger event
|
|
1088
|
+
const imageClick = ImageViewer.events.imageClickedMultiSel;
|
|
1089
|
+
this.eventHandler.trigger(imageClick, { item, clearPriors });
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
let that = this;
|
|
1096
|
+
|
|
1097
|
+
let primaryTracker = new OpenSeadragon.MouseTracker({
|
|
1098
|
+
element: that.viewer.canvas,
|
|
1099
|
+
nonPrimaryReleaseHandler(event) {
|
|
1100
|
+
if (that.selectButton.classList.contains('selected') && !that.lassoing) {
|
|
1101
|
+
const webPoint = event.position;
|
|
1102
|
+
// Convert that to viewport coordinates, the lingua franca of OpenSeadragon coordinates.
|
|
1103
|
+
const viewportPoint = that.viewer.viewport.pointFromPixel(webPoint);
|
|
1104
|
+
// Convert from viewport coordinates to image coordinates.
|
|
1105
|
+
let imagePoint = that.viewer.world.getItemAt(0).viewportToImageCoordinates(viewportPoint);
|
|
1106
|
+
const zoomScale = 2 ** config.extraZoomLevels;
|
|
1107
|
+
imagePoint = { x: imagePoint.x / zoomScale, y: imagePoint.y / zoomScale }
|
|
1108
|
+
return that.dataLayer.getNearestCell(imagePoint.x, imagePoint.y)
|
|
1109
|
+
.then(selectedItem => {
|
|
1110
|
+
if (selectedItem !== null && selectedItem !== undefined) {
|
|
1111
|
+
// Check if user is doing multi-selection or not
|
|
1112
|
+
let clearPriors = true;
|
|
1113
|
+
if (event.originalEvent.ctrlKey) {
|
|
1114
|
+
clearPriors = false;
|
|
1115
|
+
}
|
|
1116
|
+
// Trigger event
|
|
1117
|
+
that.eventHandler.trigger(ImageViewer.events.imageClickedMultiSel, {
|
|
1118
|
+
selectedItem,
|
|
1119
|
+
clearPriors
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
})
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
})
|
|
1126
|
+
|
|
1127
|
+
this.canvasOverlay = new OpenSeadragon.CanvasOverlayHd(this.viewer, {
|
|
1128
|
+
onRedraw: function (opts) {
|
|
1129
|
+
const context = opts.context;
|
|
1130
|
+
//area selection polygon
|
|
1131
|
+
if (that.selectionPolygonToDraw && that.selectionPolygonToDraw.length > 0) {
|
|
1132
|
+
var d = that.selectionPolygonToDraw;
|
|
1133
|
+
context.globalAlpha = 0.7;
|
|
1134
|
+
context.strokeStyle = 'orange';
|
|
1135
|
+
context.lineWidth = 10;
|
|
1136
|
+
context.beginPath();
|
|
1137
|
+
d.forEach(function (xVal, i) {
|
|
1138
|
+
if (i === 0) {
|
|
1139
|
+
context.moveTo(d[i].x, d[i].y);
|
|
1140
|
+
} else {
|
|
1141
|
+
context.lineTo(d[i].x, d[i].y);
|
|
1142
|
+
}
|
|
1143
|
+
});
|
|
1144
|
+
context.closePath();
|
|
1145
|
+
context.stroke();
|
|
1146
|
+
// context.globalAlpha = 1.0;
|
|
1147
|
+
}
|
|
1148
|
+
if (that.shouldDrawCentroids()) {
|
|
1149
|
+
that.drawCentroids(context, opts.zoom);
|
|
1150
|
+
}
|
|
1151
|
+
},
|
|
1152
|
+
});
|
|
1153
|
+
this.viewer.addHandler("animation", () => this.scheduleCentroidTileUpdate());
|
|
1154
|
+
this.viewer.addHandler("animation-finish", () => this.scheduleCentroidTileUpdate(0));
|
|
1155
|
+
this.viewer.addHandler("resize", () => this.scheduleCentroidTileUpdate(0));
|
|
1156
|
+
this.viewer.addHandler("open", () => this.scheduleCentroidTileUpdate(0));
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* @function init - initializes OSD channel and selection-provider options
|
|
1161
|
+
* @param viewerManager - Viewer Manager Instance
|
|
1162
|
+
* @param channelList - ChannelList instance
|
|
1163
|
+
* @param cellLayer - optional plugin instance implementing
|
|
1164
|
+
* { getSelectedIds(filter), supportsColorCoding(), getColorCodedRanges() }.
|
|
1165
|
+
* Claims the cell layer if given; null for a plain viewer.
|
|
1166
|
+
* @param centers - List of image pixel coordinates per cell
|
|
1167
|
+
* @param ids - List of integer ids per cell
|
|
1168
|
+
*/
|
|
1169
|
+
async init(viewerManager, channelList, cellLayer, centers, ids) {
|
|
1170
|
+
this.channelList = channelList;
|
|
1171
|
+
if (cellLayer) {
|
|
1172
|
+
this.registerCellLayer(cellLayer.pluginName || "unknown", cellLayer);
|
|
1173
|
+
}
|
|
1174
|
+
this.centers = centers || [];
|
|
1175
|
+
this.ids = ids || [];
|
|
1176
|
+
// Instantiate viewer managers
|
|
1177
|
+
this.viewerManagerVMain = viewerManager;
|
|
1178
|
+
this.viewerManagers.push(this.viewerManagerVMain);
|
|
1179
|
+
this.setLoading(true);
|
|
1180
|
+
try {
|
|
1181
|
+
if (!this.noLabel) {
|
|
1182
|
+
await this.waitForGLReady();
|
|
1183
|
+
}
|
|
1184
|
+
const renderer = this.glRenderer;
|
|
1185
|
+
renderer.texture_mag = [renderer.gl.createTexture(), renderer.gl.createTexture(), renderer.gl.createTexture(), renderer.gl.createTexture()];
|
|
1186
|
+
renderer.texture_ids = renderer.gl.createTexture();
|
|
1187
|
+
renderer.texture_mask = renderer.gl.createTexture();
|
|
1188
|
+
renderer.texture_ranges = renderer.gl.createTexture();
|
|
1189
|
+
renderer.texture_centers = renderer.gl.createTexture();
|
|
1190
|
+
renderer.texture_pickings = renderer.gl.createTexture();
|
|
1191
|
+
this.bindPickings(renderer, []);
|
|
1192
|
+
this.ready = true;
|
|
1193
|
+
if (ids.length && centers.length) {
|
|
1194
|
+
this.bindSegmentationBuffers(ids, centers);
|
|
1195
|
+
this.clearTileCache(true);
|
|
1196
|
+
await this.forceRepaint();
|
|
1197
|
+
}
|
|
1198
|
+
} finally {
|
|
1199
|
+
this.setLoading(false);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
waitForGLReady(timeoutMs = 5000) {
|
|
1205
|
+
return Promise.race([
|
|
1206
|
+
this.glReady,
|
|
1207
|
+
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
|
1208
|
+
]);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* The provider of the ACTIVE cell layer, or null.
|
|
1214
|
+
*
|
|
1215
|
+
* "Active" is the layer the shared controls, picking and gating act on --
|
|
1216
|
+
* one at a time, or none. Every caller that asks "who holds the cell layer"
|
|
1217
|
+
* means this one; the other layers are still on screen, they just are not
|
|
1218
|
+
* the thing being worked on.
|
|
1219
|
+
*/
|
|
1220
|
+
get cellLayer() {
|
|
1221
|
+
return this._cellLayers.get(this._activeCellLayer)?.provider || null;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* The name of the active cell layer's plugin, or null.
|
|
1226
|
+
*/
|
|
1227
|
+
get cellLayerOwner() {
|
|
1228
|
+
return this._activeCellLayer;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/** One layer's record by name, or null. */
|
|
1232
|
+
getCellLayer(name) {
|
|
1233
|
+
return this._cellLayers.get(name) || null;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/** Every registered layer, bottom of the stack first. */
|
|
1237
|
+
cellLayers() {
|
|
1238
|
+
return this._cellLayerOrder
|
|
1239
|
+
.map((name) => this._cellLayers.get(name))
|
|
1240
|
+
.filter(Boolean);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* The layer core draws with when NO plugin has registered one.
|
|
1245
|
+
*
|
|
1246
|
+
* A stand-in with the same shape as a real record, so the renderers below
|
|
1247
|
+
* take one code path rather than branching on "is anything registered" at
|
|
1248
|
+
* every read. Mutated in place rather than rebuilt: this is called once per
|
|
1249
|
+
* label tile per frame.
|
|
1250
|
+
*/
|
|
1251
|
+
coreLayerView() {
|
|
1252
|
+
const view = this._coreLayerView;
|
|
1253
|
+
view.mode = this.cellDisplayMode;
|
|
1254
|
+
view.filterIds = this.segmentationFilterIds;
|
|
1255
|
+
return view;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* @function maskDrawList - which layers the label tiles draw, bottom first.
|
|
1260
|
+
*
|
|
1261
|
+
* The returned order IS the z-order: tileDrawingCustom blits them in this
|
|
1262
|
+
* sequence, so the last one wins wherever they overlap.
|
|
1263
|
+
*
|
|
1264
|
+
* With nothing registered this is core's own layer, which is what keeps a
|
|
1265
|
+
* plain viewer -- and one running only Thresholding -- pixel-identical to
|
|
1266
|
+
* what it drew before layers existed. Once a plugin registers, core's plain
|
|
1267
|
+
* white layer steps aside entirely: turning every plugin layer off then
|
|
1268
|
+
* draws nothing, rather than falling back to white outlines the user did
|
|
1269
|
+
* not ask for.
|
|
1270
|
+
*/
|
|
1271
|
+
maskDrawList() {
|
|
1272
|
+
if (!this._cellLayers.size) {
|
|
1273
|
+
return [this.coreLayerView()];
|
|
1274
|
+
}
|
|
1275
|
+
const list = [];
|
|
1276
|
+
for (const name of this._cellLayerOrder) {
|
|
1277
|
+
const layer = this._cellLayers.get(name);
|
|
1278
|
+
if (layer?.visible && ImageViewer.MASK_MODES.includes(layer.mode)) {
|
|
1279
|
+
list.push(layer);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
return list;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* @function centroidDrawList - which layers the point overlay draws.
|
|
1287
|
+
*
|
|
1288
|
+
* Same ordering rule, but a DIFFERENT stack: points are drawn onto core's
|
|
1289
|
+
* overlay canvas, which sits above every label tile whatever the sidebar
|
|
1290
|
+
* says. So dragging reorders points among themselves and masks among
|
|
1291
|
+
* themselves, and a centroid layer is always over a mask layer.
|
|
1292
|
+
*/
|
|
1293
|
+
centroidDrawList() {
|
|
1294
|
+
if (!this._cellLayers.size) {
|
|
1295
|
+
return [this.coreLayerView()];
|
|
1296
|
+
}
|
|
1297
|
+
return this._cellLayerOrder
|
|
1298
|
+
.map((name) => this._cellLayers.get(name))
|
|
1299
|
+
.filter((layer) => layer?.visible && layer.mode === "centroids");
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/**
|
|
1303
|
+
* @function registerCellLayer - add (or re-adopt) a plugin's cell layer.
|
|
1304
|
+
*
|
|
1305
|
+
* Registering the SAME name again keeps everything the layer already holds
|
|
1306
|
+
* -- colours, gate, mode, opacity -- which is what makes switching a tool
|
|
1307
|
+
* away and back instant rather than a reload. Only unregisterCellLayer
|
|
1308
|
+
* throws state away.
|
|
1309
|
+
*
|
|
1310
|
+
* A new layer goes on TOP of the stack: a tool the user just opened is the
|
|
1311
|
+
* one they are looking at.
|
|
1312
|
+
*
|
|
1313
|
+
* @param name - the plugin's name
|
|
1314
|
+
* @param provider - object implementing the selection-provider shape:
|
|
1315
|
+
* { getSelectedIds(filter), supportsColorCoding(), getColorCodedRanges(),
|
|
1316
|
+
* eval_mode }
|
|
1317
|
+
* @param options - { mode, opacity, visible, makeActive, supportedModes }
|
|
1318
|
+
* @returns the layer record
|
|
1319
|
+
*/
|
|
1320
|
+
registerCellLayer(name, provider, options = {}) {
|
|
1321
|
+
if (!name) return null;
|
|
1322
|
+
let layer = this._cellLayers.get(name);
|
|
1323
|
+
if (!layer) {
|
|
1324
|
+
layer = {
|
|
1325
|
+
name,
|
|
1326
|
+
provider: provider || null,
|
|
1327
|
+
// Per-cell colour, or null for the plain white cell layer.
|
|
1328
|
+
//
|
|
1329
|
+
// A lookup table rather than per-cell geometry on purpose:
|
|
1330
|
+
// changing a palette, hiding a category or moving a continuous
|
|
1331
|
+
// range then recolours without refetching anything or rebuilding
|
|
1332
|
+
// a single boundary. See renderLabelTile.
|
|
1333
|
+
//
|
|
1334
|
+
// Shape: { colors: Uint8Array of 4*(maxId+1) RGBA bytes, maxId }
|
|
1335
|
+
// dense, or { map: Map<cellId, [r,g,b,a]> } when the ids are too
|
|
1336
|
+
// sparse for that to be worth allocating. Alpha 0 means "do not
|
|
1337
|
+
// draw this cell" -- it is how both "hidden category" and "no
|
|
1338
|
+
// value for this cell" arrive, so neither needs its own channel.
|
|
1339
|
+
lut: null,
|
|
1340
|
+
//: "none" | "centroids" | "outlines" | "filled". Starts at none:
|
|
1341
|
+
//: nothing is drawn over the image until something asks, and the
|
|
1342
|
+
//: asking is viewerControls.enableCellLayer.
|
|
1343
|
+
mode: "none",
|
|
1344
|
+
//: Set once the user picks a mode for this layer themselves, so
|
|
1345
|
+
//: a plugin restoring a stored preference can tell "not chosen
|
|
1346
|
+
//: yet" from "chosen, and the choice was the same".
|
|
1347
|
+
userMode: null,
|
|
1348
|
+
//: Which of the four the plugin can actually draw, or null for
|
|
1349
|
+
//: "whatever the project can". The shared Cells control offers
|
|
1350
|
+
//: the intersection.
|
|
1351
|
+
supportedModes: null,
|
|
1352
|
+
opacity: ImageViewer.DEFAULT_CELL_LAYER_OPACITY,
|
|
1353
|
+
visible: true,
|
|
1354
|
+
//: This layer's own gate. Per layer so one tool's selection
|
|
1355
|
+
//: cannot subtract cells from another tool's colours.
|
|
1356
|
+
filterIds: null,
|
|
1357
|
+
filterRequest: 0,
|
|
1358
|
+
//: Distinct fill strings, memoized so the centroid path does not
|
|
1359
|
+
//: build a colour string per point per frame. Keyed on the packed
|
|
1360
|
+
//: RGB, so it is bounded by the number of colours in use rather
|
|
1361
|
+
//: than the cell count.
|
|
1362
|
+
styleCache: new Map(),
|
|
1363
|
+
};
|
|
1364
|
+
this._cellLayers.set(name, layer);
|
|
1365
|
+
this._cellLayerOrder.push(name);
|
|
1366
|
+
} else if (provider) {
|
|
1367
|
+
layer.provider = provider;
|
|
1368
|
+
}
|
|
1369
|
+
if (options.supportedModes) layer.supportedModes = [...options.supportedModes];
|
|
1370
|
+
if (options.mode) layer.mode = options.mode;
|
|
1371
|
+
if (options.opacity !== undefined) this.setLayerOpacity(name, options.opacity);
|
|
1372
|
+
if (options.visible !== undefined) layer.visible = Boolean(options.visible);
|
|
1373
|
+
if (options.makeActive !== false) this.setActiveCellLayer(name);
|
|
1374
|
+
return layer;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* @function unregisterCellLayer - drop a layer and everything it held.
|
|
1379
|
+
*
|
|
1380
|
+
* The teardown counterpart of register: colours, gate and per-tile canvases
|
|
1381
|
+
* all go. A no-op for a name that is not registered, so a plugin shutting
|
|
1382
|
+
* down cannot clear a layer someone else owns.
|
|
1383
|
+
*/
|
|
1384
|
+
unregisterCellLayer(name) {
|
|
1385
|
+
if (!this._cellLayers.has(name)) return false;
|
|
1386
|
+
this._cellLayers.delete(name);
|
|
1387
|
+
this._cellLayerOrder = this._cellLayerOrder.filter((entry) => entry !== name);
|
|
1388
|
+
if (this._activeCellLayer === name) {
|
|
1389
|
+
// The topmost survivor takes over rather than leaving nothing
|
|
1390
|
+
// active, so removing the tool being looked at does not strand the
|
|
1391
|
+
// shared controls while other layers are still on screen.
|
|
1392
|
+
this._activeCellLayer = this._cellLayerOrder.length
|
|
1393
|
+
? this._cellLayerOrder[this._cellLayerOrder.length - 1]
|
|
1394
|
+
: null;
|
|
1395
|
+
}
|
|
1396
|
+
this.applyCellColor();
|
|
1397
|
+
return true;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* @function setActiveCellLayer - which layer the shared controls act on.
|
|
1402
|
+
*
|
|
1403
|
+
* Pixels do not change: every layer that was visible stays visible. This is
|
|
1404
|
+
* about where the Cells control, the opacity slider, picking and the gate
|
|
1405
|
+
* flows point.
|
|
1406
|
+
*
|
|
1407
|
+
* @returns the displaced layer's name, or null
|
|
1408
|
+
*/
|
|
1409
|
+
setActiveCellLayer(name) {
|
|
1410
|
+
const next = name && this._cellLayers.has(name) ? name : null;
|
|
1411
|
+
const previous = this._activeCellLayer;
|
|
1412
|
+
if (previous === next) return null;
|
|
1413
|
+
this._activeCellLayer = next;
|
|
1414
|
+
return previous;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/**
|
|
1418
|
+
* @function setCellLayerVisible - draw this layer, or stop drawing it.
|
|
1419
|
+
*
|
|
1420
|
+
* Hiding DROPS the layer's per-tile canvases and KEEPS its lookup table.
|
|
1421
|
+
* That split is what makes a loaded-but-hidden plugin cheap enough that
|
|
1422
|
+
* there is no need to cap how many may be loaded: the canvases are ~4 MB
|
|
1423
|
+
* per label tile in view and rebuild in a few milliseconds from the decoded
|
|
1424
|
+
* array that is still on the tile, while the table is four bytes per cell
|
|
1425
|
+
* and is the expensive thing to recompute (a request, or a pass over a
|
|
1426
|
+
* column). A hidden layer also stops growing as the user pans, because it
|
|
1427
|
+
* renders nothing into newly loaded tiles.
|
|
1428
|
+
*/
|
|
1429
|
+
setCellLayerVisible(name, visible) {
|
|
1430
|
+
const layer = this._cellLayers.get(name);
|
|
1431
|
+
if (!layer) return false;
|
|
1432
|
+
const next = Boolean(visible);
|
|
1433
|
+
if (layer.visible === next) return false;
|
|
1434
|
+
layer.visible = next;
|
|
1435
|
+
if (next) {
|
|
1436
|
+
this.rerenderSegmentationTiles(name);
|
|
1437
|
+
} else {
|
|
1438
|
+
this.dropLayerContexts(name);
|
|
1439
|
+
}
|
|
1440
|
+
this.viewer?.forceRedraw?.();
|
|
1441
|
+
return true;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
* @function setCellLayerMode - how one layer draws its cells.
|
|
1446
|
+
*
|
|
1447
|
+
* "none" | "centroids" | "outlines" | "filled". Only the transitions that
|
|
1448
|
+
* change what the tile canvases HOLD cost a re-render: moving between
|
|
1449
|
+
* outlines and filled, and moving in or out of the mask stack at all.
|
|
1450
|
+
* Everything else is a redraw.
|
|
1451
|
+
*/
|
|
1452
|
+
setCellLayerMode(name, mode) {
|
|
1453
|
+
const layer = this._cellLayers.get(name);
|
|
1454
|
+
if (!layer) return false;
|
|
1455
|
+
const next = mode || "none";
|
|
1456
|
+
layer.userMode = next;
|
|
1457
|
+
if (next === layer.mode) return false;
|
|
1458
|
+
const wasMask = ImageViewer.MASK_MODES.includes(layer.mode);
|
|
1459
|
+
const nowMask = ImageViewer.MASK_MODES.includes(next);
|
|
1460
|
+
const filledChanged = layer.mode === "filled" || next === "filled";
|
|
1461
|
+
layer.mode = next;
|
|
1462
|
+
if (wasMask !== nowMask || (nowMask && filledChanged)) {
|
|
1463
|
+
this.applyCellColor(nowMask ? name : null);
|
|
1464
|
+
} else {
|
|
1465
|
+
this.viewer?.forceRedraw?.();
|
|
1466
|
+
}
|
|
1467
|
+
return true;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* @function setLayerOpacity - how strongly one layer sits over what is
|
|
1472
|
+
* under it.
|
|
1473
|
+
*
|
|
1474
|
+
* Composite-time, so this is a redraw and never a re-render: the tile
|
|
1475
|
+
* canvases already hold the right pixels and only the blit's alpha changes.
|
|
1476
|
+
* That is what makes dragging the slider smooth on a mask with thousands of
|
|
1477
|
+
* cells in view.
|
|
1478
|
+
*/
|
|
1479
|
+
setLayerOpacity(name, value) {
|
|
1480
|
+
const layer = this._cellLayers.get(name);
|
|
1481
|
+
if (!layer) return false;
|
|
1482
|
+
const next = Math.max(0, Math.min(1, Number(value)));
|
|
1483
|
+
if (!Number.isFinite(next) || next === layer.opacity) return false;
|
|
1484
|
+
layer.opacity = next;
|
|
1485
|
+
this.viewer?.forceRedraw?.();
|
|
1486
|
+
return true;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/**
|
|
1490
|
+
* @function setCellLayerOrder - restack the layers, bottom first.
|
|
1491
|
+
*
|
|
1492
|
+
* Composite order only, so this costs one redraw however many cells are on
|
|
1493
|
+
* screen -- which is what lets the sidebar cards be dragged live. Names that
|
|
1494
|
+
* are not registered are ignored, and registered names the caller did not
|
|
1495
|
+
* mention keep their relative places underneath, so a partial order can
|
|
1496
|
+
* never drop a layer off the stack.
|
|
1497
|
+
*/
|
|
1498
|
+
setCellLayerOrder(names) {
|
|
1499
|
+
const wanted = (names || []).filter((name) => this._cellLayers.has(name));
|
|
1500
|
+
const mentioned = new Set(wanted);
|
|
1501
|
+
const rest = this._cellLayerOrder.filter((name) => !mentioned.has(name));
|
|
1502
|
+
const next = [...rest, ...wanted];
|
|
1503
|
+
const same = next.length === this._cellLayerOrder.length
|
|
1504
|
+
&& next.every((name, index) => name === this._cellLayerOrder[index]);
|
|
1505
|
+
if (same) return false;
|
|
1506
|
+
this._cellLayerOrder = next;
|
|
1507
|
+
this.viewer?.forceRedraw?.();
|
|
1508
|
+
return true;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* @function setCellColorLUT - colour one layer's cells by id.
|
|
1513
|
+
*
|
|
1514
|
+
* Gated on the layer existing, and silently so: a plugin that has been
|
|
1515
|
+
* removed may still have an in-flight request whose response lands
|
|
1516
|
+
* afterwards, and applying it would repaint cells for a tool that is no
|
|
1517
|
+
* longer there. Returning false lets the caller notice; ignoring the return
|
|
1518
|
+
* value is also correct, because doing nothing IS the right outcome.
|
|
1519
|
+
*
|
|
1520
|
+
* Note what is NOT checked: whether this layer is the active one. A tool the
|
|
1521
|
+
* user has switched away from but left visible goes on showing its own
|
|
1522
|
+
* colours, and goes on being allowed to update them.
|
|
1523
|
+
*
|
|
1524
|
+
* @param name - the layer's plugin name
|
|
1525
|
+
* @param lut - { colors: Uint8Array, maxId } | { map: Map } | null to clear
|
|
1526
|
+
* @returns whether the LUT was applied
|
|
1527
|
+
*/
|
|
1528
|
+
setCellColorLUT(name, lut) {
|
|
1529
|
+
const layer = this._cellLayers.get(name);
|
|
1530
|
+
if (!layer) return false;
|
|
1531
|
+
layer.lut = lut || null;
|
|
1532
|
+
layer.styleCache = new Map();
|
|
1533
|
+
this.applyCellColor(name);
|
|
1534
|
+
return true;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/**
|
|
1538
|
+
* @function setCellDisplayMode - core's own mode, for a viewer with no
|
|
1539
|
+
* plugin layers.
|
|
1540
|
+
*
|
|
1541
|
+
* Only "filled" changes what renderLabelTile produces, so only that
|
|
1542
|
+
* transition costs a re-render; switching between centroids and outlines is
|
|
1543
|
+
* a matter of which layer is showing, which viewerControls handles.
|
|
1544
|
+
*/
|
|
1545
|
+
setCellDisplayMode(mode) {
|
|
1546
|
+
const next = mode || "outlines";
|
|
1547
|
+
if (next === this.cellDisplayMode) return false;
|
|
1548
|
+
const wasFilled = this.cellDisplayMode === "filled";
|
|
1549
|
+
this.cellDisplayMode = next;
|
|
1550
|
+
if (wasFilled || next === "filled") {
|
|
1551
|
+
this.applyCellColor();
|
|
1552
|
+
}
|
|
1553
|
+
return true;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
/**
|
|
1557
|
+
* @function layerAlpha - the alpha one layer composites at.
|
|
1558
|
+
*
|
|
1559
|
+
* 1 whenever the layer has no colours. The opacity control belongs to the
|
|
1560
|
+
* plugin that owns the colours, so a plain viewer -- or one running only
|
|
1561
|
+
* Thresholding -- must not inherit its default and start drawing dimmer
|
|
1562
|
+
* outlines than it did before any of this existed.
|
|
1563
|
+
*/
|
|
1564
|
+
layerAlpha(layer) {
|
|
1565
|
+
return layer?.lut ? layer.opacity : 1;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/**
|
|
1569
|
+
* @function cellColorStyle - a canvas fill for one cell, or null to skip it.
|
|
1570
|
+
*
|
|
1571
|
+
* Alpha is treated as a yes/no here rather than blended in: the centroid
|
|
1572
|
+
* layer already carries its own globalAlpha, and stacking a second per-point
|
|
1573
|
+
* alpha on top of it makes a hidden cell "mostly invisible" instead of
|
|
1574
|
+
* absent. Strings are memoized because this runs per point per frame.
|
|
1575
|
+
*/
|
|
1576
|
+
cellColorStyle(cellId, layer) {
|
|
1577
|
+
const lut = layer?.lut;
|
|
1578
|
+
if (!lut) return null;
|
|
1579
|
+
let r;
|
|
1580
|
+
let g;
|
|
1581
|
+
let b;
|
|
1582
|
+
if (lut.colors) {
|
|
1583
|
+
if (cellId > lut.maxId) return null;
|
|
1584
|
+
const offset = cellId * 4;
|
|
1585
|
+
if (!lut.colors[offset + 3]) return null;
|
|
1586
|
+
r = lut.colors[offset];
|
|
1587
|
+
g = lut.colors[offset + 1];
|
|
1588
|
+
b = lut.colors[offset + 2];
|
|
1589
|
+
} else {
|
|
1590
|
+
const entry = lut.map?.get(cellId);
|
|
1591
|
+
if (!entry || !entry[3]) return null;
|
|
1592
|
+
[r, g, b] = entry;
|
|
1593
|
+
}
|
|
1594
|
+
const key = (r << 16) | (g << 8) | b;
|
|
1595
|
+
let style = layer.styleCache.get(key);
|
|
1596
|
+
if (style === undefined) {
|
|
1597
|
+
style = `rgb(${r},${g},${b})`;
|
|
1598
|
+
layer.styleCache.set(key, style);
|
|
1599
|
+
}
|
|
1600
|
+
return style;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
/**
|
|
1604
|
+
* @function applyCellColor - redraw the cell layer after a colour change.
|
|
1605
|
+
*
|
|
1606
|
+
* Re-renders the label tiles (their pixels carry the colours) and forces one
|
|
1607
|
+
* viewer redraw, which is also what repaints the centroid overlay. Geometry
|
|
1608
|
+
* is untouched: nothing is refetched and no boundary is re-derived for a
|
|
1609
|
+
* tile whose ids have not changed.
|
|
1610
|
+
*
|
|
1611
|
+
* @param name - re-render only this layer's canvases, leaving the rest of
|
|
1612
|
+
* the stack alone. That is what makes a gate edit over a phenotype map
|
|
1613
|
+
* cost the gate layer and nothing else.
|
|
1614
|
+
*/
|
|
1615
|
+
applyCellColor(name = null) {
|
|
1616
|
+
this.rerenderSegmentationTiles(name);
|
|
1617
|
+
this.viewer?.forceRedraw?.();
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
/**
|
|
1621
|
+
* @function indexOfTexture - return the texture unit for a named texture
|
|
1622
|
+
* @param label - the texture key label
|
|
1623
|
+
* @param scope - "M" for a marker magnitude texture, null for a constant
|
|
1624
|
+
* @returns number
|
|
1625
|
+
*
|
|
1626
|
+
* Tile textures are deliberately absent here: they are not assigned a unit
|
|
1627
|
+
* each (the shader's u_tile sampler is pinned to unit 0), they are keyed by
|
|
1628
|
+
* content in GLTileTextureCache instead.
|
|
1629
|
+
*/
|
|
1630
|
+
indexOfTexture(label, scope = null) {
|
|
1631
|
+
const renderer = this.glRenderer;
|
|
1632
|
+
// magnitudes
|
|
1633
|
+
if (scope == "M") {
|
|
1634
|
+
const index = renderer._markerTextures.indexOf(label);
|
|
1635
|
+
if (index > -1) {
|
|
1636
|
+
return index;
|
|
1637
|
+
}
|
|
1638
|
+
const newIndex = renderer._nextMarkerTexture;
|
|
1639
|
+
const maximum = renderer._markerTextures.length;
|
|
1640
|
+
renderer._nextMarkerTexture = (newIndex + 1) % maximum;
|
|
1641
|
+
renderer._markerTextures[newIndex] = label;
|
|
1642
|
+
return newIndex + renderer._markerOffset;
|
|
1643
|
+
}
|
|
1644
|
+
// other
|
|
1645
|
+
const index = renderer._constantTextures.indexOf(label);
|
|
1646
|
+
if (index > -1) {
|
|
1647
|
+
return index + renderer._otherOffset;
|
|
1648
|
+
}
|
|
1649
|
+
return -1;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
|
|
1653
|
+
/**
|
|
1654
|
+
* @function findMarkerTexture - Check if texture label is active.
|
|
1655
|
+
* @param label - marker texture label
|
|
1656
|
+
* @returns number
|
|
1657
|
+
*/
|
|
1658
|
+
findMarkerTexture(label) {
|
|
1659
|
+
const renderer = this.glRenderer;
|
|
1660
|
+
return renderer._markerTextures.indexOf(label);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
/**
|
|
1664
|
+
* @function findCurrentChannel - Return given channel for partial url
|
|
1665
|
+
* @param sub_url - partial url
|
|
1666
|
+
* @returns - current channel
|
|
1667
|
+
*/
|
|
1668
|
+
findCurrentChannel(sub_url) {
|
|
1669
|
+
const channels = Object.values(this.currentChannels);
|
|
1670
|
+
return channels.find((e) => e.sub_url == sub_url);
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
/**
|
|
1674
|
+
* Flag for webGL rendering.
|
|
1675
|
+
*
|
|
1676
|
+
* @type {boolean}
|
|
1677
|
+
*/
|
|
1678
|
+
get ready() {
|
|
1679
|
+
return this._ready || false;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
set ready(bool) {
|
|
1683
|
+
this._ready = bool;
|
|
1684
|
+
this.viewerManagers.forEach(({ viewer }) => {
|
|
1685
|
+
viewer.world._needsDraw = bool;
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
/**
|
|
1690
|
+
* Flags for mode of webGL rendering.
|
|
1691
|
+
*
|
|
1692
|
+
* @typedef {object} ModeFlags
|
|
1693
|
+
* @property {boolean} edge - render outlines
|
|
1694
|
+
* @property {boolean} or - render pie charts
|
|
1695
|
+
* @type {ModeFlags}
|
|
1696
|
+
*/
|
|
1697
|
+
get modeFlags() {
|
|
1698
|
+
return {
|
|
1699
|
+
edge: !!this.viewerManagerVMain?.sel_outlines,
|
|
1700
|
+
or: this.cellLayer?.supportsColorCoding?.() ? this.cellLayer.eval_mode == "or" : false,
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
/**
|
|
1705
|
+
* Color-coded (multi-range gate) keys for webGL rendering. Empty unless
|
|
1706
|
+
* the active selection provider supports color coding (see the scope
|
|
1707
|
+
* note on colorCodedRanges below) -- kept opt-in by design, not a
|
|
1708
|
+
* generalized concept every plugin needs to implement.
|
|
1709
|
+
*
|
|
1710
|
+
* @type {Array}
|
|
1711
|
+
*/
|
|
1712
|
+
get colorCodedKeys() {
|
|
1713
|
+
const keys = Object.keys(this.colorCodedRanges);
|
|
1714
|
+
return [...keys.sort()];
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
/**
|
|
1718
|
+
* Color-coded (multi-range gate) selections. This is deliberately not a
|
|
1719
|
+
* generalized "selection" concept -- it's the cell-layer owner's per-channel
|
|
1720
|
+
* threshold-range rendering path (u_cell_range_shape/texture_ranges), only
|
|
1721
|
+
* ever populated when the active provider opts in via supportsColorCoding().
|
|
1722
|
+
*
|
|
1723
|
+
* @type {Array}
|
|
1724
|
+
*/
|
|
1725
|
+
get colorCodedRanges() {
|
|
1726
|
+
if (!this.cellLayer?.supportsColorCoding?.()) {
|
|
1727
|
+
return {};
|
|
1728
|
+
}
|
|
1729
|
+
return this.cellLayer.getColorCodedRanges?.() || {};
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
/**
|
|
1733
|
+
* Channel selections.
|
|
1734
|
+
*
|
|
1735
|
+
* @type {Array}
|
|
1736
|
+
*/
|
|
1737
|
+
get currentChannels() {
|
|
1738
|
+
return this.channelList.currentChannels || {};
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
/**
|
|
1742
|
+
* @function toCacheKey - generate cache keys of gl properties
|
|
1743
|
+
* @param keys - active marker channels
|
|
1744
|
+
* @param markerLists - data for each marker
|
|
1745
|
+
* @returns string
|
|
1746
|
+
*/
|
|
1747
|
+
toCacheKey(keys, markerLists) {
|
|
1748
|
+
const precisions = [2 ** 25, 2 ** 25, 255, 255, 255];
|
|
1749
|
+
const tuples = keys.map((channel, i) => {
|
|
1750
|
+
const idx = 1 + this.selectMaskIndex(channel);
|
|
1751
|
+
const keyData = markerLists[i] || [];
|
|
1752
|
+
const hashes = keyData.map((r, j) => {
|
|
1753
|
+
// use precision for each item
|
|
1754
|
+
const integral = r * precisions[j];
|
|
1755
|
+
return parseInt(integral).toString(36);
|
|
1756
|
+
});
|
|
1757
|
+
return [idx, ...hashes].join("-");
|
|
1758
|
+
});
|
|
1759
|
+
return tuples.join("-");
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/**
|
|
1763
|
+
* Wrapper to set/get pickedIds
|
|
1764
|
+
*
|
|
1765
|
+
* @type {string}
|
|
1766
|
+
*/
|
|
1767
|
+
|
|
1768
|
+
get pickedIds() {
|
|
1769
|
+
return this._picking;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
set pickedIds(key) {
|
|
1773
|
+
this._picking = key;
|
|
1774
|
+
this.pickingChanged = true;
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
/**
|
|
1778
|
+
* Cache key for the per-cell range webGL buffer.
|
|
1779
|
+
*
|
|
1780
|
+
* @type {string}
|
|
1781
|
+
*/
|
|
1782
|
+
|
|
1783
|
+
get selectionCacheKey() {
|
|
1784
|
+
return this._cacheKeys.selection;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
set selectionCacheKey(key) {
|
|
1788
|
+
this._cacheKeys.selection = key;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
/**
|
|
1792
|
+
* Cache key for most webGL buffers.
|
|
1793
|
+
*
|
|
1794
|
+
* @type {string}
|
|
1795
|
+
*/
|
|
1796
|
+
|
|
1797
|
+
get markerCacheKey() {
|
|
1798
|
+
return this._cacheKeys.main;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
set markerCacheKey(key) {
|
|
1802
|
+
this._cacheKeys.main = key;
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
/**
|
|
1806
|
+
* @function loadBuffers - loads segmentation mask data to WebGL
|
|
1807
|
+
*/
|
|
1808
|
+
async loadBuffers() {
|
|
1809
|
+
const keys = this.colorCodedKeys;
|
|
1810
|
+
const rangeLists = this.selectRanges(keys);
|
|
1811
|
+
const changes = this.updateCache(keys, rangeLists);
|
|
1812
|
+
const { markersChanged, rangesChanged } = changes;
|
|
1813
|
+
|
|
1814
|
+
// Bind picked ids
|
|
1815
|
+
if (this.pickingChanged || rangesChanged) {
|
|
1816
|
+
this.bindPickings(this.glRenderer, this.pickedIds);
|
|
1817
|
+
this.pickingChanged = false;
|
|
1818
|
+
}
|
|
1819
|
+
// Bind buffers per-channel
|
|
1820
|
+
if (rangesChanged) {
|
|
1821
|
+
const ranges = [];
|
|
1822
|
+
for (const rangeTuple of rangeLists) {
|
|
1823
|
+
for (const value of rangeTuple) {
|
|
1824
|
+
ranges.push(value);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
this.bindRanges(this.glRenderer, ranges, 5);
|
|
1828
|
+
}
|
|
1829
|
+
// Bind or-mode buffers per-cell
|
|
1830
|
+
if (markersChanged) {
|
|
1831
|
+
const newKeys = keys.filter((k) => {
|
|
1832
|
+
return this.findMarkerTexture(k) == -1;
|
|
1833
|
+
});
|
|
1834
|
+
const m = await this.numericData.getAllFloat32Entries(newKeys);
|
|
1835
|
+
const nNew = newKeys.length;
|
|
1836
|
+
// Deinterleave in one linear pass instead of one full-array
|
|
1837
|
+
// .filter() per key (was O(nNew^2 * cellCount)).
|
|
1838
|
+
const perKey = newKeys.map(() => new Float32Array(m.length / nNew));
|
|
1839
|
+
for (let i = 0, o = 0; i < m.length; i += nNew, o++) {
|
|
1840
|
+
for (let ki = 0; ki < nNew; ki++) {
|
|
1841
|
+
perKey[ki][o] = m[i + ki];
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
newKeys.forEach((k, ki) => {
|
|
1845
|
+
const mk = perKey[ki];
|
|
1846
|
+
// Attempt to bind marker magnitude texture
|
|
1847
|
+
try {
|
|
1848
|
+
this.bindMagnitudes(this.glRenderer, mk, k);
|
|
1849
|
+
} catch (e) {
|
|
1850
|
+
if (e instanceof TypeError) {
|
|
1851
|
+
console.warn(`Unable to bind ${k} marker texture.`);
|
|
1852
|
+
} else {
|
|
1853
|
+
throw e;
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
/**
|
|
1861
|
+
* @function selectCenterProps - return cell centers properties
|
|
1862
|
+
* @param tile - openseadragon tile
|
|
1863
|
+
* @param source - openseadragon tile source
|
|
1864
|
+
* @typedef {object} CenterProps
|
|
1865
|
+
* @property {number} pie_radius_1f - radius of or-mode circles
|
|
1866
|
+
* @property {number} magnitude_2iv - the shape of each magnitude array
|
|
1867
|
+
* @property {number} id_end_1i - the last id in list of ids
|
|
1868
|
+
* @property {number} picked_end_1i - the last picked id index
|
|
1869
|
+
* @property {number} marker_sample_4iv - indices of magnitudes
|
|
1870
|
+
* @property {number} modes_2i - the currently active mode flags
|
|
1871
|
+
* @property {number} tile_fraction_1f - subtile fraction <=1
|
|
1872
|
+
* @property {number} tile_scale_1f - image tile scale >=1
|
|
1873
|
+
* @property {Array} x_bounds_2fv - subtile start/end in x
|
|
1874
|
+
* @property {Array} y_bounds_2fv - subtile start/end in y
|
|
1875
|
+
* @property {Array} origin_2fv - origin at texture resolution
|
|
1876
|
+
* @returns CenterProps
|
|
1877
|
+
*/
|
|
1878
|
+
selectCenterProps(tile, source) {
|
|
1879
|
+
const renderer = this.glRenderer;
|
|
1880
|
+
const modes = this.modeFlags;
|
|
1881
|
+
const w = this.config.tileWidth;
|
|
1882
|
+
const h = this.config.tileHeight;
|
|
1883
|
+
const lastId = (this.idCount || 0) - 1;
|
|
1884
|
+
const lastPick = this.pickedIds.length - 1;
|
|
1885
|
+
const tileArgs = [tile.level, tile.x, tile.y];
|
|
1886
|
+
const tl = source.toTileLevels(...tileArgs);
|
|
1887
|
+
const { outputTile, relativeImageScale } = tl;
|
|
1888
|
+
const origin = [outputTile.x * w, outputTile.y * h];
|
|
1889
|
+
const bounds = source.toMagnifiedBounds(...tileArgs);
|
|
1890
|
+
// Assume uniform shape of all magnitude buffers
|
|
1891
|
+
const magnitude_2iv = this.toTextureShape(renderer.gl, this.idCount);
|
|
1892
|
+
const markerSamples = [0, 1, 2, 3].map((i) => {
|
|
1893
|
+
const label = renderer._markerTextures[i];
|
|
1894
|
+
return this.colorCodedKeys.indexOf(label);
|
|
1895
|
+
});
|
|
1896
|
+
return {
|
|
1897
|
+
pie_radius_1f: 8.5,
|
|
1898
|
+
magnitude_2iv: magnitude_2iv,
|
|
1899
|
+
id_end_1i: Math.max(lastId, 0),
|
|
1900
|
+
picked_end_1i: Math.max(lastPick, -1),
|
|
1901
|
+
modes_2i: [modes.edge, modes.or],
|
|
1902
|
+
key_end_1i: this.colorCodedKeys.length,
|
|
1903
|
+
marker_sample_4iv: markerSamples,
|
|
1904
|
+
tile_scale_1f: Math.max(relativeImageScale, 1.0),
|
|
1905
|
+
tile_fraction_1f: Math.min(relativeImageScale, 1.0),
|
|
1906
|
+
x_bounds_2fv: new Float32Array(bounds.x),
|
|
1907
|
+
y_bounds_2fv: new Float32Array(bounds.y),
|
|
1908
|
+
origin_2fv: new Float32Array(origin),
|
|
1909
|
+
};
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
/**
|
|
1913
|
+
* @function selectRanges - per-marker threshold ranges for the shader
|
|
1914
|
+
* @param keys - active marker channels
|
|
1915
|
+
* @returns - lists of min, max, r, g, b values
|
|
1916
|
+
*/
|
|
1917
|
+
selectRanges(keys) {
|
|
1918
|
+
const rangeLists = [];
|
|
1919
|
+
const selections = this.colorCodedRanges;
|
|
1920
|
+
for (const key of keys) {
|
|
1921
|
+
const range = selections[key].map((x) => parseFloat(x));
|
|
1922
|
+
const color = this.selectMaskColor(key);
|
|
1923
|
+
const floatColor = toFloatColor(color);
|
|
1924
|
+
const rangeTuple = range.concat(floatColor);
|
|
1925
|
+
rangeLists.push(rangeTuple);
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
return rangeLists;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
/**
|
|
1932
|
+
* @function updateCache - update cache keys
|
|
1933
|
+
* @param keys - active marker channels
|
|
1934
|
+
* @param rangeLists - lists of min, max, r, g, b values
|
|
1935
|
+
* @typedef {object} Changes
|
|
1936
|
+
* @property {boolean} markersChanged - if marker lists have changed
|
|
1937
|
+
* @property {boolean} rangesChanged - if the range table changed
|
|
1938
|
+
* @returns Changes
|
|
1939
|
+
*/
|
|
1940
|
+
updateCache(keys, rangeLists) {
|
|
1941
|
+
const markerCacheKey = this.toCacheKey(keys, []);
|
|
1942
|
+
const markersChanged = this.markerCacheKey !== markerCacheKey;
|
|
1943
|
+
if (markersChanged) {
|
|
1944
|
+
this.markerCacheKey = markerCacheKey;
|
|
1945
|
+
}
|
|
1946
|
+
const selectionCacheKey = this.toCacheKey(keys, rangeLists);
|
|
1947
|
+
const rangesChanged = this.selectionCacheKey !== selectionCacheKey;
|
|
1948
|
+
if (rangesChanged) {
|
|
1949
|
+
this.selectionCacheKey = selectionCacheKey;
|
|
1950
|
+
}
|
|
1951
|
+
return { markersChanged, rangesChanged };
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
/**
|
|
1955
|
+
* @function selectMaskColor - select color for mask
|
|
1956
|
+
* @param channel - the channel label
|
|
1957
|
+
* @typedef {object} Color
|
|
1958
|
+
* @property {number} r - 0-255
|
|
1959
|
+
* @property {number} g - 0-255
|
|
1960
|
+
* @property {number} b - 0-255
|
|
1961
|
+
* @returns Color
|
|
1962
|
+
*/
|
|
1963
|
+
selectMaskColor(channel) {
|
|
1964
|
+
const white = {
|
|
1965
|
+
r: 255,
|
|
1966
|
+
g: 255,
|
|
1967
|
+
b: 255,
|
|
1968
|
+
};
|
|
1969
|
+
if (!channel) {
|
|
1970
|
+
return white;
|
|
1971
|
+
}
|
|
1972
|
+
const channels = this.currentChannels;
|
|
1973
|
+
const idxString = (this.selectMaskIndex(channel) + 1).toString();
|
|
1974
|
+
if (idxString == "0" || !Object.keys(channels).includes(idxString)) {
|
|
1975
|
+
return white;
|
|
1976
|
+
}
|
|
1977
|
+
const data = channels[idxString];
|
|
1978
|
+
return data.color;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
/**
|
|
1982
|
+
* @function selectMaskIndex - select index for mask
|
|
1983
|
+
* @param channel - the channel label
|
|
1984
|
+
* @returns number
|
|
1985
|
+
*/
|
|
1986
|
+
selectMaskIndex(channel) {
|
|
1987
|
+
const columns = this.channelList?.columns || [];
|
|
1988
|
+
return columns.indexOf(channel);
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
/**
|
|
1992
|
+
* @function selectTexture - activate a WebGL texture
|
|
1993
|
+
* @param gl - the WebGL2 context
|
|
1994
|
+
* @param texture - the WebGL2 texture
|
|
1995
|
+
* @param idx - the texture index
|
|
1996
|
+
*/
|
|
1997
|
+
selectTexture(gl, texture, idx) {
|
|
1998
|
+
if (texture === undefined) {
|
|
1999
|
+
throw new TypeError(`Cannot bind undefined to texture ${idx}.`);
|
|
2000
|
+
}
|
|
2001
|
+
// Set texture for GLSL
|
|
2002
|
+
gl.activeTexture(gl["TEXTURE" + idx]);
|
|
2003
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
2004
|
+
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
|
|
2005
|
+
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
|
|
2006
|
+
|
|
2007
|
+
// Assign texture parameters
|
|
2008
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
2009
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
2010
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
2011
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
/**
|
|
2015
|
+
* @function toTextureShape - shape of texture data
|
|
2016
|
+
* @param gl - the WebGL2 context
|
|
2017
|
+
* @param length - the 1D length of the data
|
|
2018
|
+
* @returns Array
|
|
2019
|
+
*/
|
|
2020
|
+
toTextureShape(gl, length) {
|
|
2021
|
+
// MAX_TEXTURE_SIZE is constant for the context, but gl.getParameter is a
|
|
2022
|
+
// synchronous round trip to the driver that stalls the GL pipeline --
|
|
2023
|
+
// and selectCenterProps() calls this once per tile per frame, so it was
|
|
2024
|
+
// running tens of times a frame purely to re-read a constant.
|
|
2025
|
+
if (this._maxTextureSize === undefined) {
|
|
2026
|
+
this._maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
|
|
2027
|
+
}
|
|
2028
|
+
const width = this._maxTextureSize;
|
|
2029
|
+
const height = Math.max(1, Math.ceil(length / width));
|
|
2030
|
+
return [width, height];
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
/**
|
|
2034
|
+
* @function packFloat32 - pack Float32 Texture
|
|
2035
|
+
* @param a - the texture data as an array
|
|
2036
|
+
* @param width - the texture width
|
|
2037
|
+
* @param height - the texture height
|
|
2038
|
+
* @returns array
|
|
2039
|
+
*/
|
|
2040
|
+
packFloat32(a, width, height) {
|
|
2041
|
+
// Create 2D array of pixels
|
|
2042
|
+
const full_size = width * height;
|
|
2043
|
+
const arr = new ArrayBuffer(4 * full_size);
|
|
2044
|
+
const view = new DataView(arr);
|
|
2045
|
+
for (const i in a) {
|
|
2046
|
+
view.setFloat32(4 * i, a[i], true);
|
|
2047
|
+
}
|
|
2048
|
+
return new Float32Array(arr);
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
/**
|
|
2052
|
+
* @function packUint32 - pack Uint32 Texture
|
|
2053
|
+
* @param a - the texture data as an array
|
|
2054
|
+
* @param width - the texture width
|
|
2055
|
+
* @param height - the texture height
|
|
2056
|
+
* @returns array
|
|
2057
|
+
*/
|
|
2058
|
+
packUint32(a, width, height) {
|
|
2059
|
+
// Create 2D array of pixels
|
|
2060
|
+
const full_size = width * height;
|
|
2061
|
+
const arr = new ArrayBuffer(4 * full_size);
|
|
2062
|
+
const view = new DataView(arr);
|
|
2063
|
+
for (const i in a) {
|
|
2064
|
+
view.setUint32(4 * i, a[i], true);
|
|
2065
|
+
}
|
|
2066
|
+
return new Uint8Array(arr);
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
/**
|
|
2070
|
+
* @function setIntegerTexture - set an integer texture
|
|
2071
|
+
* @param gl - the WebGL2 context
|
|
2072
|
+
* @param idx - texture index
|
|
2073
|
+
* @param texture - the WebGL2 texture
|
|
2074
|
+
* @param values - the texture data as 2d array
|
|
2075
|
+
*/
|
|
2076
|
+
setIntegerTexture(gl, idx, texture, values) {
|
|
2077
|
+
const [width, height] = this.toTextureShape(gl, values.length);
|
|
2078
|
+
const pixels = this.packUint32(values, width, height);
|
|
2079
|
+
// Set texture for GLSL
|
|
2080
|
+
this.selectTexture(gl, texture, idx);
|
|
2081
|
+
// Send an empty array to the texture
|
|
2082
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8UI, width, height, 0, gl.RGBA_INTEGER, gl.UNSIGNED_BYTE, pixels);
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
/**
|
|
2086
|
+
* @function setFloatTexture - set a floating point texture
|
|
2087
|
+
* @param gl - the WebGL2 context
|
|
2088
|
+
* @param idx - texture index
|
|
2089
|
+
* @param texture - the WebGL2 texture
|
|
2090
|
+
* @param values - the texture data as 2d array
|
|
2091
|
+
* @param width - the texture width
|
|
2092
|
+
* @param height - the texture height
|
|
2093
|
+
*/
|
|
2094
|
+
setFloatTexture(gl, idx, texture, values, width, height) {
|
|
2095
|
+
this.selectTexture(gl, texture, idx);
|
|
2096
|
+
const pixels = this.packFloat32(values, width, height);
|
|
2097
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.R32F, width, height, 0, gl.RED, gl.FLOAT, pixels);
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
/**
|
|
2101
|
+
* @function bindLabels - bind segmentation mask ids
|
|
2102
|
+
* @param renderer - the GL renderer
|
|
2103
|
+
* @param values - the texture data as 2d array
|
|
2104
|
+
*/
|
|
2105
|
+
bindLabels(renderer, values) {
|
|
2106
|
+
// Add id mask map
|
|
2107
|
+
const idx = this.indexOfTexture("ids", null);
|
|
2108
|
+
const ids_2iv = this.toTextureShape(renderer.gl, values.length);
|
|
2109
|
+
renderer.gl.uniform2iv(renderer.u_ids_shape, ids_2iv);
|
|
2110
|
+
this.setIntegerTexture(renderer.gl, idx, renderer.texture_ids, values);
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
/**
|
|
2114
|
+
* @function bindMagnitudes - bind segmentation mask magnitudes
|
|
2115
|
+
* @param renderer - the GL renderer
|
|
2116
|
+
* @param values - the texture data as 2d array
|
|
2117
|
+
* @param key - the marker label
|
|
2118
|
+
*/
|
|
2119
|
+
bindMagnitudes(renderer, values, key) {
|
|
2120
|
+
// Add a mask magnitude map
|
|
2121
|
+
const idx = this.indexOfTexture(key, "M");
|
|
2122
|
+
const texture = renderer.texture_mag[idx - renderer._markerOffset];
|
|
2123
|
+
const [width, height] = this.toTextureShape(renderer.gl, values.length);
|
|
2124
|
+
this.setFloatTexture(renderer.gl, idx, texture, values, width, height);
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
/**
|
|
2128
|
+
* @function bindCenters - bind segmentation mask centers
|
|
2129
|
+
* @param renderer - the GL renderer
|
|
2130
|
+
* @param values - the texture data as 2d array
|
|
2131
|
+
*/
|
|
2132
|
+
bindCenters(renderer, values) {
|
|
2133
|
+
// Add a mask center map
|
|
2134
|
+
const idx = this.indexOfTexture("centers", null);
|
|
2135
|
+
const [width, height] = this.toTextureShape(renderer.gl, values.length);
|
|
2136
|
+
renderer.gl.uniform3iv(renderer.u_center_shape, [width, height, 2]);
|
|
2137
|
+
this.setFloatTexture(renderer.gl, idx, renderer.texture_centers, values, width, height);
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
/**
|
|
2141
|
+
* @function bindRanges - bind the per-cell range table
|
|
2142
|
+
* @param renderer - the GL renderer
|
|
2143
|
+
* @param values - the texture data as 2d array
|
|
2144
|
+
* @param width - the texture width
|
|
2145
|
+
*/
|
|
2146
|
+
bindRanges(renderer, values, width) {
|
|
2147
|
+
// Add a mask range map
|
|
2148
|
+
const idx = this.indexOfTexture("ranges", null);
|
|
2149
|
+
const height = Math.floor(values.length / width);
|
|
2150
|
+
const range_2iv = [width, height];
|
|
2151
|
+
renderer.gl.uniform2iv(renderer.u_cell_range_shape, range_2iv);
|
|
2152
|
+
this.setFloatTexture(renderer.gl, idx, renderer.texture_ranges, values, width, height);
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
/**
|
|
2156
|
+
* @function bindPickings - bind segmentation mask pickings
|
|
2157
|
+
* @param renderer - the GL renderer
|
|
2158
|
+
* @param values - the texture data as 2d array
|
|
2159
|
+
*/
|
|
2160
|
+
bindPickings(renderer, values) {
|
|
2161
|
+
// Add a mask gating map
|
|
2162
|
+
const idx = this.indexOfTexture("pickings", null);
|
|
2163
|
+
const picking_2iv = this.toTextureShape(renderer.gl, values.length);
|
|
2164
|
+
renderer.gl.uniform2iv(renderer.u_picking_shape, picking_2iv);
|
|
2165
|
+
this.setIntegerTexture(renderer.gl, idx, renderer.texture_pickings, values);
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
// =================================================================================================================
|
|
2169
|
+
// Tile cache management
|
|
2170
|
+
// =================================================================================================================
|
|
2171
|
+
|
|
2172
|
+
/**
|
|
2173
|
+
* @function createTFArray - creates an array of colors as a transfer/lookup table for pixel values.
|
|
2174
|
+
* @param min - the minimum value
|
|
2175
|
+
* @param max - the maximum value
|
|
2176
|
+
* @param rgb1 - the start color (min)
|
|
2177
|
+
* @param rgb2 - the end color (max)
|
|
2178
|
+
* @param numBins - the bins for the color interpolation steps
|
|
2179
|
+
* @typedef {object} TF
|
|
2180
|
+
* @property {Array} tf - color list
|
|
2181
|
+
* @property {number} min - min cutoff
|
|
2182
|
+
* @property {number} max - max cutoff
|
|
2183
|
+
* @property {number} num_bins - number of bins
|
|
2184
|
+
* @property {object} start_color - lower limit color
|
|
2185
|
+
* @property {object} end_color - upper limit color
|
|
2186
|
+
* @returns TF
|
|
2187
|
+
*/
|
|
2188
|
+
createTFArray(min, max, rgb1, rgb2, numBins) {
|
|
2189
|
+
const tfArray = [];
|
|
2190
|
+
|
|
2191
|
+
const numBinsF = parseFloat(numBins);
|
|
2192
|
+
const col1 = d3.rgb(rgb1);
|
|
2193
|
+
const col2 = d3.rgb(rgb2);
|
|
2194
|
+
|
|
2195
|
+
for (let i = 0; i < numBins; i++) {
|
|
2196
|
+
const rgbTupel = {};
|
|
2197
|
+
const lerpFactor = i / (numBinsF - 1.0);
|
|
2198
|
+
|
|
2199
|
+
rgbTupel.r = col1.r + (col2.r - col1.r) * lerpFactor;
|
|
2200
|
+
rgbTupel.g = col1.g + (col2.g - col1.g) * lerpFactor;
|
|
2201
|
+
rgbTupel.b = col1.b + (col2.b - col1.b) * lerpFactor;
|
|
2202
|
+
|
|
2203
|
+
const lerpCol = d3.rgb(rgbTupel.r, rgbTupel.g, rgbTupel.b);
|
|
2204
|
+
tfArray.push(lerpCol);
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
return {
|
|
2208
|
+
min: min,
|
|
2209
|
+
max: max,
|
|
2210
|
+
start_color: rgb1,
|
|
2211
|
+
end_color: rgb2,
|
|
2212
|
+
num_bins: numBins,
|
|
2213
|
+
tf: tfArray,
|
|
2214
|
+
};
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
/**
|
|
2218
|
+
* @function forceRepaint - for all active viewers repaint the canvas
|
|
2219
|
+
*/
|
|
2220
|
+
async forceRepaint() {
|
|
2221
|
+
if (!this.ready) {
|
|
2222
|
+
return;
|
|
2223
|
+
}
|
|
2224
|
+
this.ready = false;
|
|
2225
|
+
if (this.idCount) {
|
|
2226
|
+
await this.loadBuffers();
|
|
2227
|
+
}
|
|
2228
|
+
this.ready = true;
|
|
2229
|
+
// Trigger change of full cache
|
|
2230
|
+
this.viewerManagers.forEach(({ viewer }) => {
|
|
2231
|
+
viewer.forceRedraw();
|
|
2232
|
+
});
|
|
2233
|
+
}
|
|
2234
|
+
/**
|
|
2235
|
+
* @function scheduleRepaint - coalesce repaints onto the next animation frame
|
|
2236
|
+
*
|
|
2237
|
+
* For anything driven by a continuous gesture. The contrast sliders emit a
|
|
2238
|
+
* BRUSH_MOVE per input event (viewerSidebar.js -> main.js ->
|
|
2239
|
+
* updateChannelRange), and each one used to run a full forceRepaint, so a
|
|
2240
|
+
* single drag queued dozens of complete redraws of every visible tile of
|
|
2241
|
+
* every channel. Collapsing them to one repaint per frame keeps the drag
|
|
2242
|
+
* responsive without changing what is ultimately drawn.
|
|
2243
|
+
*/
|
|
2244
|
+
scheduleRepaint() {
|
|
2245
|
+
if (this._repaintScheduled) {
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
this._repaintScheduled = true;
|
|
2249
|
+
requestAnimationFrame(() => {
|
|
2250
|
+
this._repaintScheduled = false;
|
|
2251
|
+
this.forceRepaint();
|
|
2252
|
+
});
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
|
|
2256
|
+
/**
|
|
2257
|
+
* @function updateActiveChannels
|
|
2258
|
+
* @param name - image channel name
|
|
2259
|
+
* @param action - "add" or "remove"
|
|
2260
|
+
*/
|
|
2261
|
+
updateActiveChannels(name, action) {
|
|
2262
|
+
const channelIdx = imageChannels[name];
|
|
2263
|
+
|
|
2264
|
+
if (action == "add") {
|
|
2265
|
+
this.viewerManagers.forEach((vM) => {
|
|
2266
|
+
vM.channel_add(channelIdx);
|
|
2267
|
+
});
|
|
2268
|
+
} else {
|
|
2269
|
+
this.viewerManagers.forEach((vM) => {
|
|
2270
|
+
vM.channel_remove(channelIdx);
|
|
2271
|
+
});
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
// A no-op while the lens is closed, which is what lets this sit on a
|
|
2275
|
+
// channel mutator at all. Only this one may need to fetch: a colour or
|
|
2276
|
+
// range change is served from the mini-map's cached greyscale.
|
|
2277
|
+
this.miniMap?.invalidate({ refetch: true });
|
|
2278
|
+
this.forceRepaint();
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
/**
|
|
2282
|
+
* @function updateChannelRange
|
|
2283
|
+
* @param name - image channel name
|
|
2284
|
+
* @param tfmin - minimum
|
|
2285
|
+
* @param tfmax - maximum
|
|
2286
|
+
*/
|
|
2287
|
+
updateChannelRange(name, tfmin, tfmax) {
|
|
2288
|
+
// In the default (non-HD) mode tfmin/tfmax already arrive in [0, 255]
|
|
2289
|
+
// byte units (see viewerSidebar.js) -- divide by 255, not the full
|
|
2290
|
+
// 16-bit range, so this matches what the shader's u8 path now reads
|
|
2291
|
+
// u_tile_range as directly (see frag.glsl's u8_r_range). HD mode
|
|
2292
|
+
// keeps the original raw-16-bit-unit behavior.
|
|
2293
|
+
const hd = Boolean(this.viewerManagerVMain?.isHdMode?.());
|
|
2294
|
+
const range = hd ? this.numericData.intRange : [0, 255];
|
|
2295
|
+
const channelIdx = imageChannels[name];
|
|
2296
|
+
if (this.currentChannels[channelIdx]) {
|
|
2297
|
+
let channelRange = [tfmin / range[1], tfmax / range[1]];
|
|
2298
|
+
this.currentChannels[channelIdx].range = channelRange;
|
|
2299
|
+
this.channelList.rangeConnector[channelIdx] = channelRange;
|
|
2300
|
+
}
|
|
2301
|
+
this.miniMap?.invalidate();
|
|
2302
|
+
this.scheduleRepaint();
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
/**
|
|
2306
|
+
* @function updateChannelColors
|
|
2307
|
+
* @param name - image channel name
|
|
2308
|
+
* @param color - rgb object with values 0-255
|
|
2309
|
+
*/
|
|
2310
|
+
updateChannelColors(name, color) {
|
|
2311
|
+
const channelIdx = imageChannels[name];
|
|
2312
|
+
if (this.currentChannels[channelIdx]) {
|
|
2313
|
+
this.channelList.colorConnector[channelIdx] = { color: color };
|
|
2314
|
+
this.currentChannels[channelIdx].color = color;
|
|
2315
|
+
}
|
|
2316
|
+
// Also gesture-driven (dragging in the colour picker), so coalesce.
|
|
2317
|
+
this.miniMap?.invalidate();
|
|
2318
|
+
this.scheduleRepaint();
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
/**
|
|
2322
|
+
* @function updateRenderingMode
|
|
2323
|
+
* @param mode - subset or selection
|
|
2324
|
+
*/
|
|
2325
|
+
updateRenderingMode(mode) {
|
|
2326
|
+
// Mode is a string: 'show-subset', 'show-selection'
|
|
2327
|
+
if (mode === "show-subset") {
|
|
2328
|
+
this.show_subset = !this.show_subset;
|
|
2329
|
+
}
|
|
2330
|
+
if (mode === "show-selection") {
|
|
2331
|
+
this.show_selection = !this.show_selection;
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
this.forceRepaint();
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
async updateCentroidVisibility(isVisible) {
|
|
2338
|
+
this.show_centroids = isVisible;
|
|
2339
|
+
if (isVisible) {
|
|
2340
|
+
await this.ensureCentroidsReady(true);
|
|
2341
|
+
this.scheduleCentroidTileUpdate(0, true);
|
|
2342
|
+
}
|
|
2343
|
+
this.refreshCentroidOverlay();
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
async updateCentroidFallback(isFallback) {
|
|
2347
|
+
// Only ever called with true (turn centroids on as a one-time default/
|
|
2348
|
+
// fallback, e.g. no segmentation registered or outlines failed to load).
|
|
2349
|
+
// Routes through updateCentroidVisibility so show_centroids and the
|
|
2350
|
+
// Cells control agree from the start -- previously this set a separate
|
|
2351
|
+
// force_centroids flag that shouldDrawCentroids() OR'd in permanently,
|
|
2352
|
+
// so turning centroids off afterward couldn't actually do it.
|
|
2353
|
+
// Remembered so a mask that lands later can tell "centroids because
|
|
2354
|
+
// there was nothing else" from "centroids because the user asked", and
|
|
2355
|
+
// take the drawing over only in the first case.
|
|
2356
|
+
this.centroidsFromFallback = Boolean(isFallback);
|
|
2357
|
+
if (!isFallback) return;
|
|
2358
|
+
await this.updateCentroidVisibility(true);
|
|
2359
|
+
// The control has to show what is actually drawn. Through adoptMode
|
|
2360
|
+
// rather than the ordinary selection path: the drawing has already been
|
|
2361
|
+
// switched above, and re-entering that path would recurse -- this method
|
|
2362
|
+
// is called from inside it, when a mask fails to load.
|
|
2363
|
+
window.__plexora?.viewerControls?.adoptMode?.("centroids");
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
shouldDrawCentroids() {
|
|
2367
|
+
return this.show_centroids;
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
updateCentroidIds() {
|
|
2371
|
+
this.scheduleCentroidTileUpdate(0, true);
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
updateCentroidFilter(filter = {}, showSpinner = false) {
|
|
2375
|
+
this.centroidFilter = filter || {};
|
|
2376
|
+
const signature = JSON.stringify(this.centroidFilter);
|
|
2377
|
+
if (signature !== this.centroidFilterSignature) {
|
|
2378
|
+
this.centroidFilterSignature = signature;
|
|
2379
|
+
this.centroidTiles.clear();
|
|
2380
|
+
this.centroidTileRequest += 1;
|
|
2381
|
+
}
|
|
2382
|
+
if (this.centroidMode === "legacy") {
|
|
2383
|
+
const hasFilter = !!(this.centroidFilter && Object.keys(this.centroidFilter).length);
|
|
2384
|
+
const provider = this.cellLayer;
|
|
2385
|
+
const idsPromise = (hasFilter && provider)
|
|
2386
|
+
? provider.getSelectedIds(this.centroidFilter)
|
|
2387
|
+
: Promise.resolve(null);
|
|
2388
|
+
idsPromise.then((ids) => {
|
|
2389
|
+
this.centroidIdSet = hasFilter ? (ids instanceof Set ? ids : new Set()) : null;
|
|
2390
|
+
this.refreshCentroidOverlay();
|
|
2391
|
+
});
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
this.scheduleCentroidTileUpdate(0, showSpinner);
|
|
2395
|
+
this.refreshCentroidOverlay();
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
refreshCentroidOverlay() {
|
|
2399
|
+
if (this.canvasOverlay) {
|
|
2400
|
+
this.canvasOverlay.resize();
|
|
2401
|
+
this.canvasOverlay.clear();
|
|
2402
|
+
this.canvasOverlay._updateCanvas();
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
async ensureCentroidsReady(showSpinner = false) {
|
|
2407
|
+
if (this.centroidsReady) return;
|
|
2408
|
+
if (this.centroidsLoading) {
|
|
2409
|
+
await this.centroidsLoading;
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
this.centroidsLoading = (async () => {
|
|
2413
|
+
if (showSpinner) {
|
|
2414
|
+
this.setLoading(true);
|
|
2415
|
+
}
|
|
2416
|
+
try {
|
|
2417
|
+
this.centroidManifest = await this.dataLayer.getCentroidManifest();
|
|
2418
|
+
if (!this.centroidManifest) {
|
|
2419
|
+
const { ids, centers } = await this.numericData.loadCells();
|
|
2420
|
+
this.ids = ids || [];
|
|
2421
|
+
this.centers = centers || [];
|
|
2422
|
+
this.prepareLegacyCentroidCache();
|
|
2423
|
+
this.centroidMode = "legacy";
|
|
2424
|
+
} else {
|
|
2425
|
+
this.centroidMode = "tiled";
|
|
2426
|
+
}
|
|
2427
|
+
this.centroidsReady = true;
|
|
2428
|
+
} finally {
|
|
2429
|
+
if (showSpinner) {
|
|
2430
|
+
this.setLoading(false);
|
|
2431
|
+
}
|
|
2432
|
+
this.centroidsLoading = null;
|
|
2433
|
+
}
|
|
2434
|
+
})();
|
|
2435
|
+
await this.centroidsLoading;
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
scheduleCentroidTileUpdate(delay = 100, showSpinner = false) {
|
|
2439
|
+
if (!this.shouldDrawCentroids() || !this.centroidsReady) return;
|
|
2440
|
+
if (this.centroidMode === "legacy") {
|
|
2441
|
+
this.refreshCentroidOverlay();
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
if (this.centroidTileTimer) {
|
|
2445
|
+
clearTimeout(this.centroidTileTimer);
|
|
2446
|
+
}
|
|
2447
|
+
this.centroidTileTimer = setTimeout(() => {
|
|
2448
|
+
this.updateVisibleCentroidTiles(showSpinner);
|
|
2449
|
+
}, delay);
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
async updateVisibleCentroidTiles(showSpinner = false) {
|
|
2453
|
+
if (!this.shouldDrawCentroids() || !this.centroidManifest || !this.viewer?.viewport) return;
|
|
2454
|
+
const tileState = this.getVisibleCentroidTileState();
|
|
2455
|
+
if (!tileState) return;
|
|
2456
|
+
const { level, tiles, keepKeys } = tileState;
|
|
2457
|
+
for (const key of this.centroidTiles.keys()) {
|
|
2458
|
+
if (!keepKeys.has(key)) {
|
|
2459
|
+
this.centroidTiles.delete(key);
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
const missing = tiles.filter((tile) => !this.centroidTiles.has(tile.key));
|
|
2463
|
+
if (!missing.length) {
|
|
2464
|
+
this.refreshCentroidOverlay();
|
|
2465
|
+
return;
|
|
2466
|
+
}
|
|
2467
|
+
const requestId = ++this.centroidTileRequest;
|
|
2468
|
+
const shouldSpin = showSpinner || this.centroidFirstLoad;
|
|
2469
|
+
if (shouldSpin) {
|
|
2470
|
+
this.setLoading(true);
|
|
2471
|
+
}
|
|
2472
|
+
try {
|
|
2473
|
+
const payload = missing.map(({ x, y }) => ({ x, y }));
|
|
2474
|
+
const buffer = await this.dataLayer.getCentroidTiles(level, payload, this.centroidFilter, 50000);
|
|
2475
|
+
if (requestId !== this.centroidTileRequest || !buffer) return;
|
|
2476
|
+
const grouped = this.decodeCentroidTileBuffer(buffer, level);
|
|
2477
|
+
for (const tile of missing) {
|
|
2478
|
+
this.centroidTiles.set(tile.key, grouped.get(tile.key) || {
|
|
2479
|
+
ids: new Uint32Array(0),
|
|
2480
|
+
centers: new Float32Array(0),
|
|
2481
|
+
});
|
|
2482
|
+
}
|
|
2483
|
+
this.centroidFirstLoad = false;
|
|
2484
|
+
this.refreshCentroidOverlay();
|
|
2485
|
+
} finally {
|
|
2486
|
+
if (shouldSpin) {
|
|
2487
|
+
this.setLoading(false);
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
prepareLegacyCentroidCache() {
|
|
2493
|
+
const centers = this.centers || [];
|
|
2494
|
+
const ids = this.ids || [];
|
|
2495
|
+
const zoomScale = 2 ** (this.config.extraZoomLevels || 0);
|
|
2496
|
+
this.fullResolutionCenters = new Float32Array(centers.length);
|
|
2497
|
+
this.idToCenterOffset = new Map();
|
|
2498
|
+
// Coarse spatial bucket index (same floor-by-tile-span idea as
|
|
2499
|
+
// centroid_tiles.py's tile bucketing) so a full redraw only walks
|
|
2500
|
+
// points near the current viewport instead of every cell.
|
|
2501
|
+
this.legacyCentroidBucketSpan = Math.max(1, this.config.tileWidth || 512);
|
|
2502
|
+
this.legacyCentroidBuckets = new Map();
|
|
2503
|
+
for (let i = 0; i < centers.length; i += 2) {
|
|
2504
|
+
const x = centers[i] * zoomScale;
|
|
2505
|
+
const y = centers[i + 1] * zoomScale;
|
|
2506
|
+
this.fullResolutionCenters[i] = x;
|
|
2507
|
+
this.fullResolutionCenters[i + 1] = y;
|
|
2508
|
+
if (ids[i / 2] !== undefined) {
|
|
2509
|
+
this.idToCenterOffset.set(Number(ids[i / 2]), i);
|
|
2510
|
+
}
|
|
2511
|
+
const bucketKey = this.legacyCentroidBucketKey(x, y);
|
|
2512
|
+
let bucket = this.legacyCentroidBuckets.get(bucketKey);
|
|
2513
|
+
if (!bucket) {
|
|
2514
|
+
bucket = [];
|
|
2515
|
+
this.legacyCentroidBuckets.set(bucketKey, bucket);
|
|
2516
|
+
}
|
|
2517
|
+
bucket.push(i);
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
legacyCentroidBucketKey(x, y) {
|
|
2522
|
+
const span = this.legacyCentroidBucketSpan;
|
|
2523
|
+
return `${Math.floor(x / span)}_${Math.floor(y / span)}`;
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
getVisibleCentroidTileState() {
|
|
2527
|
+
const item = this.viewer.world.getItemAt(0);
|
|
2528
|
+
if (!item) return null;
|
|
2529
|
+
const bounds = this.viewer.viewport.getBounds(true);
|
|
2530
|
+
const imageBounds = item.viewportToImageRectangle(bounds);
|
|
2531
|
+
const coordinateScale = 2 ** (this.config.extraZoomLevels || 0);
|
|
2532
|
+
const sourceBounds = {
|
|
2533
|
+
x: imageBounds.x / coordinateScale,
|
|
2534
|
+
y: imageBounds.y / coordinateScale,
|
|
2535
|
+
width: imageBounds.width / coordinateScale,
|
|
2536
|
+
height: imageBounds.height / coordinateScale,
|
|
2537
|
+
};
|
|
2538
|
+
const level = this.getCentroidLevel();
|
|
2539
|
+
const tileSpan = this.centroidManifest.tile_size * (2 ** level);
|
|
2540
|
+
const maxTx = Math.max(0, Math.ceil(this.centroidManifest.width / tileSpan) - 1);
|
|
2541
|
+
const maxTy = Math.max(0, Math.ceil(this.centroidManifest.height / tileSpan) - 1);
|
|
2542
|
+
const minX = Math.max(0, Math.floor(sourceBounds.x / tileSpan) - 1);
|
|
2543
|
+
const minY = Math.max(0, Math.floor(sourceBounds.y / tileSpan) - 1);
|
|
2544
|
+
const maxX = Math.min(maxTx, Math.floor((sourceBounds.x + sourceBounds.width) / tileSpan) + 1);
|
|
2545
|
+
const maxY = Math.min(maxTy, Math.floor((sourceBounds.y + sourceBounds.height) / tileSpan) + 1);
|
|
2546
|
+
const tiles = [];
|
|
2547
|
+
const keepKeys = new Set();
|
|
2548
|
+
for (let y = minY; y <= maxY; y += 1) {
|
|
2549
|
+
for (let x = minX; x <= maxX; x += 1) {
|
|
2550
|
+
const key = this.centroidTileKey(level, x, y);
|
|
2551
|
+
tiles.push({ level, x, y, key });
|
|
2552
|
+
keepKeys.add(key);
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
return { level, tiles, keepKeys };
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
getCentroidLevel() {
|
|
2559
|
+
const item = this.viewer.world.getItemAt(0);
|
|
2560
|
+
let imageZoom = 1;
|
|
2561
|
+
try {
|
|
2562
|
+
imageZoom = item.viewportToImageZoom(this.viewer.viewport.getZoom(true));
|
|
2563
|
+
} catch (e) {
|
|
2564
|
+
imageZoom = 1;
|
|
2565
|
+
}
|
|
2566
|
+
const level = Math.floor(Math.max(0, Math.log2(1 / Math.max(imageZoom, 0.0001))));
|
|
2567
|
+
return Math.min(Math.max(0, level), Math.max(0, (this.centroidManifest?.level_count || 1) - 1));
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
centroidTileKey(level, x, y) {
|
|
2571
|
+
return `${level}/${x}/${y}`;
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
decodeCentroidTileBuffer(buffer, level) {
|
|
2575
|
+
const view = new DataView(buffer);
|
|
2576
|
+
const groups = new Map();
|
|
2577
|
+
const tileSpan = this.centroidManifest.tile_size * (2 ** level);
|
|
2578
|
+
const coordinateScale = 2 ** (this.config.extraZoomLevels || 0);
|
|
2579
|
+
for (let offset = 0; offset + 12 <= view.byteLength; offset += 12) {
|
|
2580
|
+
const id = view.getUint32(offset, true);
|
|
2581
|
+
const x = view.getFloat32(offset + 4, true);
|
|
2582
|
+
const y = view.getFloat32(offset + 8, true);
|
|
2583
|
+
const tx = Math.max(0, Math.floor(x / tileSpan));
|
|
2584
|
+
const ty = Math.max(0, Math.floor(y / tileSpan));
|
|
2585
|
+
const key = this.centroidTileKey(level, tx, ty);
|
|
2586
|
+
if (!groups.has(key)) {
|
|
2587
|
+
groups.set(key, { ids: [], centers: [] });
|
|
2588
|
+
}
|
|
2589
|
+
const group = groups.get(key);
|
|
2590
|
+
group.ids.push(id);
|
|
2591
|
+
group.centers.push(x * coordinateScale, y * coordinateScale);
|
|
2592
|
+
}
|
|
2593
|
+
const typedGroups = new Map();
|
|
2594
|
+
groups.forEach((group, key) => {
|
|
2595
|
+
typedGroups.set(key, {
|
|
2596
|
+
ids: new Uint32Array(group.ids),
|
|
2597
|
+
centers: new Float32Array(group.centers),
|
|
2598
|
+
});
|
|
2599
|
+
});
|
|
2600
|
+
return typedGroups;
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
async ensureSegmentationReady(showSpinner = false) {
|
|
2604
|
+
if (this.segmentationReady || this.noLabel) return;
|
|
2605
|
+
if (this.segmentationLoading) {
|
|
2606
|
+
await this.segmentationLoading;
|
|
2607
|
+
return;
|
|
2608
|
+
}
|
|
2609
|
+
this.segmentationLoading = (async () => {
|
|
2610
|
+
if (showSpinner) {
|
|
2611
|
+
this.setLoading(true);
|
|
2612
|
+
}
|
|
2613
|
+
try {
|
|
2614
|
+
if (!this.centers.length || !this.ids.length) {
|
|
2615
|
+
const { ids, centers } = await this.numericData.loadCells();
|
|
2616
|
+
this.ids = ids || [];
|
|
2617
|
+
this.centers = centers || [];
|
|
2618
|
+
}
|
|
2619
|
+
this.bindSegmentationBuffers(this.ids, this.centers);
|
|
2620
|
+
this.viewerManagerVMain.load_label_image();
|
|
2621
|
+
this.segmentationReady = true;
|
|
2622
|
+
this.clearTileCache(true);
|
|
2623
|
+
await this.forceRepaint();
|
|
2624
|
+
} finally {
|
|
2625
|
+
if (showSpinner) {
|
|
2626
|
+
this.setLoading(false);
|
|
2627
|
+
}
|
|
2628
|
+
this.segmentationLoading = null;
|
|
2629
|
+
}
|
|
2630
|
+
})();
|
|
2631
|
+
await this.segmentationLoading;
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
/**
|
|
2635
|
+
* @function updateSegmentationFilter - restrict one layer to a set of cells.
|
|
2636
|
+
*
|
|
2637
|
+
* @param name - which layer's gate to write. Defaults to the active layer;
|
|
2638
|
+
* pass a name explicitly from a plugin's own gate flow so a tool the user
|
|
2639
|
+
* has switched away from still updates its OWN cells rather than whichever
|
|
2640
|
+
* layer happens to be active. Falls back to core's gate when nothing is
|
|
2641
|
+
* registered at all.
|
|
2642
|
+
*/
|
|
2643
|
+
async updateSegmentationFilter(filter = {}, showSpinner = false, name = undefined) {
|
|
2644
|
+
if (this.noLabel || !this.viewerManagerVMain?.sel_outlines) return;
|
|
2645
|
+
const target = name === undefined ? this._activeCellLayer : name;
|
|
2646
|
+
const layer = target ? this._cellLayers.get(target) : null;
|
|
2647
|
+
// A named layer that has since been removed: nothing to filter, and
|
|
2648
|
+
// nothing to fall through to -- writing core's gate here would apply a
|
|
2649
|
+
// dead tool's selection to every other layer on screen.
|
|
2650
|
+
if (target && !layer) return;
|
|
2651
|
+
const requestId = layer
|
|
2652
|
+
? (layer.filterRequest += 1)
|
|
2653
|
+
: (this.segmentationFilterRequest += 1);
|
|
2654
|
+
const current = () => (layer ? layer.filterRequest : this.segmentationFilterRequest);
|
|
2655
|
+
const gates = filter || {};
|
|
2656
|
+
const hasGates = Object.keys(gates).length > 0;
|
|
2657
|
+
const provider = layer ? layer.provider : null;
|
|
2658
|
+
if (showSpinner) {
|
|
2659
|
+
this.setLoading(true);
|
|
2660
|
+
}
|
|
2661
|
+
try {
|
|
2662
|
+
await this.ensureSegmentationReady(false);
|
|
2663
|
+
let ids = null;
|
|
2664
|
+
if (hasGates && provider?.getSelectedIds) {
|
|
2665
|
+
const selected = await provider.getSelectedIds(gates);
|
|
2666
|
+
if (requestId !== current()) return;
|
|
2667
|
+
ids = selected instanceof Set ? selected : null;
|
|
2668
|
+
}
|
|
2669
|
+
if (layer) {
|
|
2670
|
+
layer.filterIds = ids;
|
|
2671
|
+
} else {
|
|
2672
|
+
this.segmentationFilterIds = ids;
|
|
2673
|
+
}
|
|
2674
|
+
this.rerenderSegmentationTiles(layer ? layer.name : null);
|
|
2675
|
+
this.viewer.forceRedraw();
|
|
2676
|
+
} finally {
|
|
2677
|
+
if (showSpinner) {
|
|
2678
|
+
this.setLoading(false);
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
/** Walk every loaded label tile. The tilesMatrix nesting is OpenSeadragon's,
|
|
2684
|
+
* and three call sites needed the same four loops to reach through it. */
|
|
2685
|
+
forEachLabelTile(visit) {
|
|
2686
|
+
if (!this.viewer?.world) return;
|
|
2687
|
+
for (let i = 0; i < this.viewer.world.getItemCount(); i += 1) {
|
|
2688
|
+
const item = this.viewer.world.getItemAt(i);
|
|
2689
|
+
if (item?.source?.tileFormat != 32) continue;
|
|
2690
|
+
const matrix = item.tilesMatrix || {};
|
|
2691
|
+
Object.keys(matrix).forEach((level) => {
|
|
2692
|
+
Object.keys(matrix[level] || {}).forEach((x) => {
|
|
2693
|
+
Object.keys(matrix[level][x] || {}).forEach((y) => {
|
|
2694
|
+
const tile = matrix[level][x][y];
|
|
2695
|
+
if (tile) visit(tile);
|
|
2696
|
+
});
|
|
2697
|
+
});
|
|
2698
|
+
});
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
/**
|
|
2703
|
+
* @function renderTileLayers - build one tile's canvas per drawn layer.
|
|
2704
|
+
*
|
|
2705
|
+
* One decoded array, N passes over it, N cached canvases -- which is the
|
|
2706
|
+
* whole reason several layers can be stacked without refetching a thing.
|
|
2707
|
+
*
|
|
2708
|
+
* @param tile - an OpenSeadragon label tile carrying `_array`
|
|
2709
|
+
* @param draw - the layers to draw, from maskDrawList()
|
|
2710
|
+
* @param only - rebuild just this layer's canvas, and CREATE any layer that
|
|
2711
|
+
* has none yet (that is what repopulates a layer the user turned back on).
|
|
2712
|
+
* Null rebuilds everything.
|
|
2713
|
+
*/
|
|
2714
|
+
renderTileLayers(tile, draw = null, only = null) {
|
|
2715
|
+
if (!this.renderLabelTile || !tile?._array) return null;
|
|
2716
|
+
const width = tile._labelWidth;
|
|
2717
|
+
const height = tile._labelHeight;
|
|
2718
|
+
if (!width || !height) return tile._layerContexts || null;
|
|
2719
|
+
let contexts = tile._layerContexts;
|
|
2720
|
+
if (!contexts) {
|
|
2721
|
+
contexts = new Map();
|
|
2722
|
+
tile._layerContexts = contexts;
|
|
2723
|
+
}
|
|
2724
|
+
const layers = draw || this.maskDrawList();
|
|
2725
|
+
const wanted = new Set();
|
|
2726
|
+
for (const layer of layers) {
|
|
2727
|
+
wanted.add(layer.name);
|
|
2728
|
+
if (only && layer.name !== only && contexts.has(layer.name)) continue;
|
|
2729
|
+
contexts.set(layer.name,
|
|
2730
|
+
this.renderLabelTile(tile._array, width, height, layer));
|
|
2731
|
+
}
|
|
2732
|
+
// A layer that has left the stack releases its canvas now rather than
|
|
2733
|
+
// waiting for the tile itself to be evicted.
|
|
2734
|
+
for (const name of [...contexts.keys()]) {
|
|
2735
|
+
if (!wanted.has(name)) contexts.delete(name);
|
|
2736
|
+
}
|
|
2737
|
+
return contexts;
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
/** Release one layer's cached canvases across every loaded label tile. Its
|
|
2741
|
+
* lookup table is deliberately kept -- see setCellLayerVisible. */
|
|
2742
|
+
dropLayerContexts(name) {
|
|
2743
|
+
this.forEachLabelTile((tile) => {
|
|
2744
|
+
tile._layerContexts?.delete(name);
|
|
2745
|
+
});
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
/**
|
|
2749
|
+
* @function rerenderSegmentationTiles - rebuild the label tiles' pixels.
|
|
2750
|
+
*
|
|
2751
|
+
* @param name - rebuild only this layer's canvases. Layers that are in the
|
|
2752
|
+
* stack but have no canvas for a tile are still created, so this doubles
|
|
2753
|
+
* as the "bring a re-shown layer back" path.
|
|
2754
|
+
*/
|
|
2755
|
+
rerenderSegmentationTiles(name = null) {
|
|
2756
|
+
if (!this.renderLabelTile) return;
|
|
2757
|
+
const draw = this.maskDrawList();
|
|
2758
|
+
this.forEachLabelTile((tile) => {
|
|
2759
|
+
if (!tile._array || !tile._layerContexts) return;
|
|
2760
|
+
this.renderTileLayers(tile, draw, name);
|
|
2761
|
+
});
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
bindSegmentationBuffers(ids, centers) {
|
|
2765
|
+
if (!ids?.length || !centers?.length) return;
|
|
2766
|
+
const renderer = this.glRenderer;
|
|
2767
|
+
renderer.texture_mag = renderer.texture_mag || [renderer.gl.createTexture(), renderer.gl.createTexture(), renderer.gl.createTexture(), renderer.gl.createTexture()];
|
|
2768
|
+
renderer.texture_ids = renderer.texture_ids || renderer.gl.createTexture();
|
|
2769
|
+
renderer.texture_centers = renderer.texture_centers || renderer.gl.createTexture();
|
|
2770
|
+
renderer.texture_pickings = renderer.texture_pickings || renderer.gl.createTexture();
|
|
2771
|
+
renderer.texture_ranges = renderer.texture_ranges || renderer.gl.createTexture();
|
|
2772
|
+
this.bindCenters(renderer, centers);
|
|
2773
|
+
this.bindPickings(renderer, this.pickedIds || []);
|
|
2774
|
+
this.bindLabels(renderer, ids);
|
|
2775
|
+
this.idCount = ids.length;
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
initProjectLabel() {
|
|
2779
|
+
const wrapper = document.getElementById("openseadragon_wrapper");
|
|
2780
|
+
if (!wrapper || document.getElementById("viewer_project_label")) return;
|
|
2781
|
+
const label = document.createElement("div");
|
|
2782
|
+
label.id = "viewer_project_label";
|
|
2783
|
+
label.className = "viewer-project-label";
|
|
2784
|
+
label.textContent = datasource || "";
|
|
2785
|
+
wrapper.appendChild(label);
|
|
2786
|
+
}
|
|
2787
|
+
initLegend() {
|
|
2788
|
+
const wrapper = document.getElementById("openseadragon_wrapper");
|
|
2789
|
+
if (!wrapper || document.getElementById("viewer_channel_legend")) return;
|
|
2790
|
+
const legend = document.createElement("div");
|
|
2791
|
+
legend.id = "viewer_channel_legend";
|
|
2792
|
+
legend.className = "viewer-channel-legend";
|
|
2793
|
+
wrapper.appendChild(legend);
|
|
2794
|
+
this.eventHandler.bind(ChannelList.events.COLOR_TRANSFER_CHANGE, () => this.updateLegend());
|
|
2795
|
+
this.eventHandler.bind(ChannelList.events.CHANNELS_CHANGE, () => this.updateLegend());
|
|
2796
|
+
this.updateLegend();
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
/**
|
|
2800
|
+
* The overview lens in the bottom-left corner. Built here alongside the
|
|
2801
|
+
* project label and the channel legend because it is the same kind of
|
|
2802
|
+
* thing: a DOM overlay on #openseadragon_wrapper that core owns.
|
|
2803
|
+
*
|
|
2804
|
+
* Guarded on `typeof` rather than window.MiniMap -- `class` in a classic
|
|
2805
|
+
* script creates a global LEXICAL binding, which never becomes a property
|
|
2806
|
+
* of window, so window.MiniMap is undefined even when the script loaded.
|
|
2807
|
+
*
|
|
2808
|
+
* Everything expensive about the mini-map is deferred to the first time
|
|
2809
|
+
* the user opens it; constructing it here costs a few DOM nodes.
|
|
2810
|
+
*/
|
|
2811
|
+
initMiniMap() {
|
|
2812
|
+
if (typeof MiniMap === "undefined") {
|
|
2813
|
+
return;
|
|
2814
|
+
}
|
|
2815
|
+
this.miniMap = new MiniMap(this);
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
getActiveLegendChannels() {
|
|
2819
|
+
return (window.__plexora?.viewerSidebar?.channelSlots || []).filter((slot) => slot.enabled && slot.name);
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
updateLegend() {
|
|
2823
|
+
const legend = document.getElementById("viewer_channel_legend");
|
|
2824
|
+
if (!legend) return;
|
|
2825
|
+
const active = this.getActiveLegendChannels();
|
|
2826
|
+
legend.innerHTML = active.map((slot) => '<span class="legend-row"><span class="legend-swatch" style="background:' + slot.colorHex + '"></span><span class="legend-name">' + slot.name + '</span></span>').join("");
|
|
2827
|
+
legend.style.display = active.length ? "flex" : "none";
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
drawLegendOnCanvas(ctx, width, height) {
|
|
2831
|
+
const active = this.getActiveLegendChannels();
|
|
2832
|
+
if (!active.length) return;
|
|
2833
|
+
const rowH = 26;
|
|
2834
|
+
const padding = 12;
|
|
2835
|
+
const swatchSize = 15;
|
|
2836
|
+
ctx.font = "15px sans-serif";
|
|
2837
|
+
const textWidths = active.map((slot) => ctx.measureText(slot.name).width);
|
|
2838
|
+
const boxW = Math.max.apply(null, textWidths) + swatchSize + padding * 2 + 8;
|
|
2839
|
+
const boxH = active.length * rowH + padding * 2;
|
|
2840
|
+
const x = width - boxW - 16;
|
|
2841
|
+
const y = height - boxH - 60;
|
|
2842
|
+
ctx.fillStyle = "rgba(17, 24, 39, 0.86)";
|
|
2843
|
+
ctx.fillRect(x, y, boxW, boxH);
|
|
2844
|
+
active.forEach((slot, i) => {
|
|
2845
|
+
const rowY = y + padding + i * rowH;
|
|
2846
|
+
ctx.fillStyle = slot.colorHex;
|
|
2847
|
+
ctx.fillRect(x + padding, rowY, swatchSize, swatchSize);
|
|
2848
|
+
ctx.fillStyle = "#f1f5f9";
|
|
2849
|
+
ctx.fillText(slot.name, x + padding + swatchSize + 8, rowY + swatchSize);
|
|
2850
|
+
});
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
// Maps a DOM overlay element's on-screen box into the coordinate space
|
|
2854
|
+
// of the full-resolution OSD drawer canvas, so PDF export can place
|
|
2855
|
+
// vector shapes/text exactly where the same overlay appears on screen
|
|
2856
|
+
// (canvas.width/height is the backing-store pixel size; getBoundingClientRect
|
|
2857
|
+
// is CSS layout size -- the ratio between them is the scale factor).
|
|
2858
|
+
getOverlayRectInCanvasSpace(el) {
|
|
2859
|
+
const canvasEl = this.viewer?.drawer?.canvas;
|
|
2860
|
+
if (!canvasEl || !el) return null;
|
|
2861
|
+
const canvasRect = canvasEl.getBoundingClientRect();
|
|
2862
|
+
const elRect = el.getBoundingClientRect();
|
|
2863
|
+
if (!canvasRect.width || !canvasRect.height) return null;
|
|
2864
|
+
const scaleX = canvasEl.width / canvasRect.width;
|
|
2865
|
+
const scaleY = canvasEl.height / canvasRect.height;
|
|
2866
|
+
return {
|
|
2867
|
+
x: (elRect.left - canvasRect.left) * scaleX,
|
|
2868
|
+
y: (elRect.top - canvasRect.top) * scaleY,
|
|
2869
|
+
width: elRect.width * scaleX,
|
|
2870
|
+
height: elRect.height * scaleY,
|
|
2871
|
+
scale: scaleX,
|
|
2872
|
+
};
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
// Vector (line + real text, not a rasterized image) redraw of the
|
|
2876
|
+
// on-screen scale bar for PDF export -- positioned/sized from the live
|
|
2877
|
+
// DOM element rather than recomputing the microscopy scalebar plugin's
|
|
2878
|
+
// own round-number sizing logic a second time.
|
|
2879
|
+
drawScalebarVector(pdf) {
|
|
2880
|
+
const instance = this.viewer?.scalebarInstance;
|
|
2881
|
+
const element = instance?.divElt;
|
|
2882
|
+
if (!element || element.style.display === "none") return;
|
|
2883
|
+
const rect = this.getOverlayRectInCanvasSpace(element);
|
|
2884
|
+
if (!rect || !rect.width) return;
|
|
2885
|
+
const barY = rect.y + rect.height;
|
|
2886
|
+
pdf.setDrawColor(255, 255, 255);
|
|
2887
|
+
pdf.setLineWidth(Math.max(1, (instance.barThickness || 3) * rect.scale));
|
|
2888
|
+
pdf.line(rect.x, barY, rect.x + rect.width, barY);
|
|
2889
|
+
pdf.setFont("helvetica", "normal");
|
|
2890
|
+
pdf.setFontSize(Math.max(6, (parseFloat(instance.fontSize) || 12) * rect.scale));
|
|
2891
|
+
pdf.setTextColor(255, 255, 255);
|
|
2892
|
+
pdf.text(element.textContent || "", rect.x + rect.width / 2, rect.y + rect.height / 2, {
|
|
2893
|
+
align: "center",
|
|
2894
|
+
baseline: "middle",
|
|
2895
|
+
});
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
// Vector redraw of the channel legend for PDF export: a real filled
|
|
2899
|
+
// rect per swatch and real text per name (editable in Illustrator),
|
|
2900
|
+
// positioned from the on-screen legend DOM so it matches item-for-item.
|
|
2901
|
+
drawLegendVector(pdf) {
|
|
2902
|
+
const legendEl = document.getElementById("viewer_channel_legend");
|
|
2903
|
+
const active = this.getActiveLegendChannels();
|
|
2904
|
+
if (!legendEl || !active.length || legendEl.style.display === "none") return;
|
|
2905
|
+
const rect = this.getOverlayRectInCanvasSpace(legendEl);
|
|
2906
|
+
if (!rect || !rect.width) return;
|
|
2907
|
+
|
|
2908
|
+
pdf.saveGraphicsState();
|
|
2909
|
+
pdf.setGState(new pdf.GState({ opacity: 0.86 }));
|
|
2910
|
+
pdf.setFillColor(17, 24, 39);
|
|
2911
|
+
pdf.roundedRect(rect.x, rect.y, rect.width, rect.height, 3 * rect.scale, 3 * rect.scale, "F");
|
|
2912
|
+
pdf.restoreGraphicsState();
|
|
2913
|
+
|
|
2914
|
+
const rows = legendEl.querySelectorAll(".legend-row");
|
|
2915
|
+
pdf.setFont("helvetica", "normal");
|
|
2916
|
+
rows.forEach((row, i) => {
|
|
2917
|
+
const slot = active[i];
|
|
2918
|
+
const swatchRect = this.getOverlayRectInCanvasSpace(row.querySelector(".legend-swatch"));
|
|
2919
|
+
if (!slot || !swatchRect) return;
|
|
2920
|
+
const { r, g, b } = hexToRgb(slot.colorHex);
|
|
2921
|
+
pdf.setFillColor(r, g, b);
|
|
2922
|
+
pdf.rect(swatchRect.x, swatchRect.y, swatchRect.width, swatchRect.height, "F");
|
|
2923
|
+
pdf.setFontSize(Math.max(6, 15 * rect.scale));
|
|
2924
|
+
pdf.setTextColor(241, 245, 249);
|
|
2925
|
+
pdf.text(slot.name, swatchRect.x + swatchRect.width + 8 * rect.scale, swatchRect.y + swatchRect.height / 2, {
|
|
2926
|
+
baseline: "middle",
|
|
2927
|
+
});
|
|
2928
|
+
});
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
// Vector redraw of the top-left project-name label for PDF export.
|
|
2932
|
+
drawProjectLabelVector(pdf) {
|
|
2933
|
+
const labelEl = document.getElementById("viewer_project_label");
|
|
2934
|
+
if (!labelEl) return;
|
|
2935
|
+
const rect = this.getOverlayRectInCanvasSpace(labelEl);
|
|
2936
|
+
if (!rect || !rect.width) return;
|
|
2937
|
+
|
|
2938
|
+
pdf.saveGraphicsState();
|
|
2939
|
+
pdf.setGState(new pdf.GState({ opacity: 0.86 }));
|
|
2940
|
+
pdf.setFillColor(17, 24, 39);
|
|
2941
|
+
pdf.roundedRect(rect.x, rect.y, rect.width, rect.height, 3 * rect.scale, 3 * rect.scale, "F");
|
|
2942
|
+
pdf.restoreGraphicsState();
|
|
2943
|
+
|
|
2944
|
+
pdf.setFont("helvetica", "bold");
|
|
2945
|
+
pdf.setFontSize(Math.max(6, 13 * rect.scale));
|
|
2946
|
+
pdf.setTextColor(241, 245, 249);
|
|
2947
|
+
pdf.text(labelEl.textContent || "", rect.x + rect.width / 2, rect.y + rect.height / 2, {
|
|
2948
|
+
align: "center",
|
|
2949
|
+
baseline: "middle",
|
|
2950
|
+
});
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
|
|
2954
|
+
addScaleBar() {
|
|
2955
|
+
let pixelsPerMeter;
|
|
2956
|
+
if (this.imgMetadata) {
|
|
2957
|
+
if (this.show_scalebar) {
|
|
2958
|
+
let unitConvert;
|
|
2959
|
+
if (this.imgMetadata.physical_size_x_unit === "µm" || this.imgMetadata.physical_size_x_unit === "um") {
|
|
2960
|
+
unitConvert = 1000000;
|
|
2961
|
+
} else if (this.imgMetadata.physical_size_x_unit === "nm") {
|
|
2962
|
+
unitConvert = 1000000000;
|
|
2963
|
+
} else if (this.imgMetadata.physical_size_x_unit === "cm") {
|
|
2964
|
+
unitConvert = 100;
|
|
2965
|
+
} else if (this.imgMetadata.physical_size_x_unit === "m") {
|
|
2966
|
+
unitConvert = 1;
|
|
2967
|
+
} else {
|
|
2968
|
+
unitConvert = 0;
|
|
2969
|
+
}
|
|
2970
|
+
pixelsPerMeter = unitConvert * this.imgMetadata.physical_size_x;
|
|
2971
|
+
} else {
|
|
2972
|
+
pixelsPerMeter = 0;
|
|
2973
|
+
}
|
|
2974
|
+
pixelsPerMeter = this.show_scalebar ? this.getPixelsPerMeter() : null;
|
|
2975
|
+
|
|
2976
|
+
this.viewer.scalebar({
|
|
2977
|
+
location: OpenSeadragon.ScalebarLocation.BOTTOM_RIGHT,
|
|
2978
|
+
minWidth: "100px",
|
|
2979
|
+
type: OpenSeadragon.ScalebarType.MICROSCOPY,
|
|
2980
|
+
stayInsideImage: false,
|
|
2981
|
+
pixelsPerMeter: pixelsPerMeter,
|
|
2982
|
+
fontColor: "rgb(255, 255, 255)",
|
|
2983
|
+
color: "rgb(255, 255, 255)",
|
|
2984
|
+
backgroundColor: "rgba(0, 0, 0, 0.45)",
|
|
2985
|
+
barThickness: 3,
|
|
2986
|
+
});
|
|
2987
|
+
this.styleScaleBar();
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
getPixelsPerMeter() {
|
|
2992
|
+
const physicalSizeX = Number(this.imgMetadata?.physical_size_x);
|
|
2993
|
+
if (!physicalSizeX) return null;
|
|
2994
|
+
const unitsPerMeter = {
|
|
2995
|
+
"\u00b5m": 1000000,
|
|
2996
|
+
"um": 1000000,
|
|
2997
|
+
"nm": 1000000000,
|
|
2998
|
+
"cm": 100,
|
|
2999
|
+
"m": 1,
|
|
3000
|
+
}[this.imgMetadata?.physical_size_x_unit];
|
|
3001
|
+
if (!unitsPerMeter) return null;
|
|
3002
|
+
return unitsPerMeter / physicalSizeX;
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
setScalebarVisible(visible) {
|
|
3006
|
+
this.show_scalebar = visible;
|
|
3007
|
+
if (!this.viewer?.scalebarInstance) return;
|
|
3008
|
+
this.viewer.scalebar({ pixelsPerMeter: visible ? this.getPixelsPerMeter() : null });
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
styleScaleBar() {
|
|
3012
|
+
const element = this.viewer?.scalebarInstance?.divElt;
|
|
3013
|
+
if (!element) return;
|
|
3014
|
+
element.style.zIndex = "220";
|
|
3015
|
+
element.style.position = "absolute";
|
|
3016
|
+
element.style.padding = "2px 4px";
|
|
3017
|
+
element.style.borderRadius = "3px";
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
/**
|
|
3021
|
+
* @function drawCentroids - the point representation of every visible layer.
|
|
3022
|
+
*
|
|
3023
|
+
* WHICH POINTS EXIST is not per layer, and cannot be with the tiled centroid
|
|
3024
|
+
* source: the gate is applied server-side when the tiles are fetched
|
|
3025
|
+
* (updateCentroidFilter), and there is one set of tiles. So a gating layer
|
|
3026
|
+
* that is visible but NOT active draws its colours over the point set the
|
|
3027
|
+
* ACTIVE layer asked for. Colour is per layer; membership is not. Splitting
|
|
3028
|
+
* that would mean a tile set per layer, which is a fetch per layer per pan.
|
|
3029
|
+
*/
|
|
3030
|
+
drawCentroids(context, imageZoom = 1) {
|
|
3031
|
+
if (this.centroidMode === "legacy") {
|
|
3032
|
+
this.drawLegacyCentroids(context, imageZoom);
|
|
3033
|
+
return;
|
|
3034
|
+
}
|
|
3035
|
+
if (!this.centroidTiles.size || !this.viewer?.viewport) return;
|
|
3036
|
+
const item = this.viewer.world.getItemAt(0);
|
|
3037
|
+
if (!item) return;
|
|
3038
|
+
const layers = this.centroidDrawList();
|
|
3039
|
+
if (!layers.length) return;
|
|
3040
|
+
const bounds = this.viewer.viewport.getBounds(true);
|
|
3041
|
+
const imageBounds = item.viewportToImageRectangle(bounds);
|
|
3042
|
+
const minX = imageBounds.x;
|
|
3043
|
+
const minY = imageBounds.y;
|
|
3044
|
+
const maxX = imageBounds.x + imageBounds.width;
|
|
3045
|
+
const maxY = imageBounds.y + imageBounds.height;
|
|
3046
|
+
const safeImageZoom = Math.max(imageZoom, 0.0001);
|
|
3047
|
+
const radius = this.getCentroidScreenRadius(safeImageZoom) / safeImageZoom;
|
|
3048
|
+
// One pass per layer, bottom of the stack first, so the same drag that
|
|
3049
|
+
// reorders the mask layers reorders these. They all land on the same
|
|
3050
|
+
// overlay canvas, which is above every mask tile -- see centroidDrawList.
|
|
3051
|
+
for (const layer of layers) {
|
|
3052
|
+
const colored = Boolean(layer.lut);
|
|
3053
|
+
context.save();
|
|
3054
|
+
context.globalAlpha = 0.9 * this.layerAlpha(layer);
|
|
3055
|
+
context.fillStyle = "#ffdd55";
|
|
3056
|
+
context.strokeStyle = "rgba(0, 0, 0, 0.8)";
|
|
3057
|
+
context.lineWidth = 1.6 / safeImageZoom;
|
|
3058
|
+
// Only reassigned when the colour actually changes. Canvas state
|
|
3059
|
+
// changes are the expensive part of this loop, and a categorical
|
|
3060
|
+
// variable draws long runs of one colour.
|
|
3061
|
+
let currentFill = "#ffdd55";
|
|
3062
|
+
const drawPoint = (x, y, cellId) => {
|
|
3063
|
+
if (x < minX || x > maxX || y < minY || y > maxY) {
|
|
3064
|
+
return;
|
|
3065
|
+
}
|
|
3066
|
+
if (colored) {
|
|
3067
|
+
const style = this.cellColorStyle(cellId, layer);
|
|
3068
|
+
if (!style) return;
|
|
3069
|
+
if (style !== currentFill) {
|
|
3070
|
+
context.fillStyle = style;
|
|
3071
|
+
currentFill = style;
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3074
|
+
context.beginPath();
|
|
3075
|
+
context.arc(x, y, radius, 0, Math.PI * 2);
|
|
3076
|
+
context.fill();
|
|
3077
|
+
context.stroke();
|
|
3078
|
+
};
|
|
3079
|
+
this.centroidTiles.forEach((tile) => {
|
|
3080
|
+
const centers = tile.centers || [];
|
|
3081
|
+
// Parallel to `centers`, two coordinates per id -- see
|
|
3082
|
+
// decodeCentroidTileBuffer, which builds both from the same record.
|
|
3083
|
+
const ids = tile.ids;
|
|
3084
|
+
for (let i = 0; i < centers.length; i += 2) {
|
|
3085
|
+
drawPoint(centers[i], centers[i + 1], ids ? ids[i >> 1] : 0);
|
|
3086
|
+
}
|
|
3087
|
+
});
|
|
3088
|
+
context.restore();
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
drawLegacyCentroids(context, imageZoom = 1) {
|
|
3093
|
+
const centers = this.fullResolutionCenters || [];
|
|
3094
|
+
if (!centers.length || !this.viewer?.viewport) return;
|
|
3095
|
+
const item = this.viewer.world.getItemAt(0);
|
|
3096
|
+
if (!item) return;
|
|
3097
|
+
const layers = this.centroidDrawList();
|
|
3098
|
+
if (!layers.length) return;
|
|
3099
|
+
const bounds = this.viewer.viewport.getBounds(true);
|
|
3100
|
+
const imageBounds = item.viewportToImageRectangle(bounds);
|
|
3101
|
+
const minX = imageBounds.x;
|
|
3102
|
+
const minY = imageBounds.y;
|
|
3103
|
+
const maxX = imageBounds.x + imageBounds.width;
|
|
3104
|
+
const maxY = imageBounds.y + imageBounds.height;
|
|
3105
|
+
const safeImageZoom = Math.max(imageZoom, 0.0001);
|
|
3106
|
+
const radius = this.getCentroidScreenRadius(safeImageZoom) / safeImageZoom;
|
|
3107
|
+
for (const layer of layers) {
|
|
3108
|
+
const colored = Boolean(layer.lut);
|
|
3109
|
+
context.save();
|
|
3110
|
+
context.globalAlpha = 0.9 * this.layerAlpha(layer);
|
|
3111
|
+
context.fillStyle = "#ffdd55";
|
|
3112
|
+
context.strokeStyle = "rgba(0, 0, 0, 0.8)";
|
|
3113
|
+
context.lineWidth = 1.6 / safeImageZoom;
|
|
3114
|
+
let currentFill = "#ffdd55";
|
|
3115
|
+
const drawAtOffset = (i) => {
|
|
3116
|
+
const x = centers[i];
|
|
3117
|
+
const y = centers[i + 1];
|
|
3118
|
+
if (x < minX || x > maxX || y < minY || y > maxY) {
|
|
3119
|
+
return;
|
|
3120
|
+
}
|
|
3121
|
+
if (colored) {
|
|
3122
|
+
// this.ids is parallel to `centers` at two coordinates per
|
|
3123
|
+
// id, the same relationship the tiled path has -- so one
|
|
3124
|
+
// colour lookup works for both without either knowing about
|
|
3125
|
+
// the other.
|
|
3126
|
+
const style = this.cellColorStyle(this.ids[i >> 1], layer);
|
|
3127
|
+
if (!style) return;
|
|
3128
|
+
if (style !== currentFill) {
|
|
3129
|
+
context.fillStyle = style;
|
|
3130
|
+
currentFill = style;
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
context.beginPath();
|
|
3134
|
+
context.arc(x, y, radius, 0, Math.PI * 2);
|
|
3135
|
+
context.fill();
|
|
3136
|
+
context.stroke();
|
|
3137
|
+
};
|
|
3138
|
+
if (this.centroidIdSet instanceof Set) {
|
|
3139
|
+
this.centroidIdSet.forEach((id) => {
|
|
3140
|
+
const offset = this.idToCenterOffset.get(Number(id));
|
|
3141
|
+
if (offset !== undefined) {
|
|
3142
|
+
drawAtOffset(offset);
|
|
3143
|
+
}
|
|
3144
|
+
});
|
|
3145
|
+
} else if (this.legacyCentroidBuckets) {
|
|
3146
|
+
// Only walk buckets overlapping the current viewport instead of
|
|
3147
|
+
// every cell in the dataset.
|
|
3148
|
+
const span = this.legacyCentroidBucketSpan;
|
|
3149
|
+
const minTx = Math.floor(minX / span);
|
|
3150
|
+
const maxTx = Math.floor(maxX / span);
|
|
3151
|
+
const minTy = Math.floor(minY / span);
|
|
3152
|
+
const maxTy = Math.floor(maxY / span);
|
|
3153
|
+
for (let tx = minTx; tx <= maxTx; tx += 1) {
|
|
3154
|
+
for (let ty = minTy; ty <= maxTy; ty += 1) {
|
|
3155
|
+
const bucket = this.legacyCentroidBuckets.get(`${tx}_${ty}`);
|
|
3156
|
+
if (!bucket) continue;
|
|
3157
|
+
for (const offset of bucket) {
|
|
3158
|
+
drawAtOffset(offset);
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
} else {
|
|
3163
|
+
for (let i = 0; i < centers.length; i += 2) {
|
|
3164
|
+
drawAtOffset(i);
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
context.restore();
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
getCentroidScreenRadius(imageZoom) {
|
|
3172
|
+
const overviewLevel = Math.max(0, Math.log2(1 / imageZoom));
|
|
3173
|
+
// The clamp is applied to the adaptive part and the user's multiplier
|
|
3174
|
+
// to the result, so asking for bigger dots stays bigger at every zoom
|
|
3175
|
+
// rather than being flattened by the ceiling at the wide end.
|
|
3176
|
+
return this.centroidPointScale
|
|
3177
|
+
* Math.max(2.5, Math.min(7, 3.5 + overviewLevel * 0.8));
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
/**
|
|
3181
|
+
* @function setCentroidPointScale - how big centroid dots are drawn.
|
|
3182
|
+
*
|
|
3183
|
+
* A redraw and nothing else: the points, their positions and their colours
|
|
3184
|
+
* are all unchanged, so this costs one canvas pass over what is already in
|
|
3185
|
+
* view. Dragging the slider is as cheap as panning.
|
|
3186
|
+
*
|
|
3187
|
+
* @param value - multiplier, clamped to [MIN, MAX]_CENTROID_SCALE
|
|
3188
|
+
* @returns whether anything changed
|
|
3189
|
+
*/
|
|
3190
|
+
setCentroidPointScale(value) {
|
|
3191
|
+
const asked = Number(value);
|
|
3192
|
+
if (!Number.isFinite(asked)) return false;
|
|
3193
|
+
const next = Math.max(ImageViewer.MIN_CENTROID_SCALE,
|
|
3194
|
+
Math.min(ImageViewer.MAX_CENTROID_SCALE, asked));
|
|
3195
|
+
if (next === this.centroidPointScale) return false;
|
|
3196
|
+
this.centroidPointScale = next;
|
|
3197
|
+
this.viewer?.forceRedraw?.();
|
|
3198
|
+
return true;
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3201
|
+
downloadCurrentView(format = "png") {
|
|
3202
|
+
if (format === "pdf") {
|
|
3203
|
+
this.exportPdf();
|
|
3204
|
+
return;
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
// PNG has no vector concept, so the scale bar and legend are baked
|
|
3208
|
+
// in as raster pixels here, same as before.
|
|
3209
|
+
const baseCanvas = this.viewer?.scalebarInstance && this.show_scalebar
|
|
3210
|
+
? this.viewer.scalebarInstance.getImageWithScalebarAsCanvas()
|
|
3211
|
+
: this.viewer?.drawer?.canvas;
|
|
3212
|
+
if (!baseCanvas) return;
|
|
3213
|
+
|
|
3214
|
+
const canvas = document.createElement("canvas");
|
|
3215
|
+
canvas.width = baseCanvas.width;
|
|
3216
|
+
canvas.height = baseCanvas.height;
|
|
3217
|
+
const ctx = canvas.getContext("2d");
|
|
3218
|
+
ctx.drawImage(baseCanvas, 0, 0);
|
|
3219
|
+
this.drawLegendOnCanvas(ctx, canvas.width, canvas.height);
|
|
3220
|
+
|
|
3221
|
+
const link = document.createElement("a");
|
|
3222
|
+
link.download = `${datasource || "plexora"}_current_view.png`;
|
|
3223
|
+
link.href = canvas.toDataURL("image/png");
|
|
3224
|
+
document.body.appendChild(link);
|
|
3225
|
+
link.click();
|
|
3226
|
+
document.body.removeChild(link);
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
// PDF export: the microscopy image itself stays a raster embed (it's a
|
|
3230
|
+
// bitmap by nature), but the scale bar, legend and project label are all
|
|
3231
|
+
// drawn as real vector shapes/text on top -- editable as separate
|
|
3232
|
+
// objects in Illustrator, not baked into the image pixels.
|
|
3233
|
+
exportPdf() {
|
|
3234
|
+
const baseCanvas = this.viewer?.drawer?.canvas;
|
|
3235
|
+
if (!baseCanvas) return;
|
|
3236
|
+
const width = baseCanvas.width;
|
|
3237
|
+
const height = baseCanvas.height;
|
|
3238
|
+
|
|
3239
|
+
const pdf = new jsPDF({
|
|
3240
|
+
orientation: width >= height ? "landscape" : "portrait",
|
|
3241
|
+
unit: "px",
|
|
3242
|
+
format: [width, height],
|
|
3243
|
+
});
|
|
3244
|
+
pdf.addImage(baseCanvas.toDataURL("image/png"), "PNG", 0, 0, width, height);
|
|
3245
|
+
|
|
3246
|
+
if (this.show_scalebar) this.drawScalebarVector(pdf);
|
|
3247
|
+
this.drawLegendVector(pdf);
|
|
3248
|
+
this.drawProjectLabelVector(pdf);
|
|
3249
|
+
|
|
3250
|
+
pdf.save(`${datasource || "plexora"}_current_view.pdf`);
|
|
3251
|
+
}
|
|
3252
|
+
|
|
3253
|
+
setLoading(isLoading) {
|
|
3254
|
+
const loader = document.getElementById("openseadragon_loader");
|
|
3255
|
+
if (loader) {
|
|
3256
|
+
loader.style.display = isLoading ? "flex" : "none";
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
|
|
3261
|
+
/**
|
|
3262
|
+
* @function clearTileCache - drop loaded tiles so they get refetched
|
|
3263
|
+
* @param onlySegmentation - if true, only clear the segmentation/label layer
|
|
3264
|
+
* (tileFormat 32) instead of every channel's tile pyramid
|
|
3265
|
+
*
|
|
3266
|
+
* Uses TiledImage.reset(), OpenSeadragon 6's supported entry point, which
|
|
3267
|
+
* delegates to TileCache.clearTilesFor() and properly unloads each tile and
|
|
3268
|
+
* decrements _cachesLoadedCount.
|
|
3269
|
+
*
|
|
3270
|
+
* The previous hand-rolled version (and its evictLeastRecentlyUsedTiles
|
|
3271
|
+
* sibling, plus a 30s interval that called it) was written against
|
|
3272
|
+
* OpenSeadragon 2.x, where _tilesLoaded held {tile: ...} records. OSD 6
|
|
3273
|
+
* stores Tile objects directly, so `tileRecord.tile` was always undefined:
|
|
3274
|
+
* tile.unload() never fired and nothing was actually freed -- yet the
|
|
3275
|
+
* splice()/= [] calls still tore real tiles out of OSD's LRU list while
|
|
3276
|
+
* leaving _cachesLoadedCount untouched. That left _freeOldRecordRoutine
|
|
3277
|
+
* with no eviction candidates, so the cache grew without bound and the
|
|
3278
|
+
* resulting GC pauses showed up as stutter during panning.
|
|
3279
|
+
*/
|
|
3280
|
+
clearTileCache(onlySegmentation = false) {
|
|
3281
|
+
if (!this.viewer || !this.viewer.world) {
|
|
3282
|
+
return;
|
|
3283
|
+
}
|
|
3284
|
+
for (let i = 0; i < this.viewer.world.getItemCount(); i++) {
|
|
3285
|
+
const item = this.viewer.world.getItemAt(i);
|
|
3286
|
+
if (onlySegmentation && item?.source?.tileFormat !== 32) continue;
|
|
3287
|
+
item?.reset?.();
|
|
3288
|
+
}
|
|
3289
|
+
this.viewer.forceRedraw();
|
|
3290
|
+
}
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
// Static vars
|
|
3294
|
+
ImageViewer.events = {
|
|
3295
|
+
imageClickedMultiSel: "image_clicked_multi_selection",
|
|
3296
|
+
renderingMode: "renderingMode"
|
|
3297
|
+
};
|
|
3298
|
+
|
|
3299
|
+
/**
|
|
3300
|
+
* @function toFloatColor - convert 0-255 rgb color to 0-1 float array
|
|
3301
|
+
* @param color - rgb object with values 0-255
|
|
3302
|
+
* @returns array
|
|
3303
|
+
*/
|
|
3304
|
+
function toFloatColor(color) {
|
|
3305
|
+
return [color.r / 255, color.g / 255, color.b / 255];
|
|
3306
|
+
}
|