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,142 @@
|
|
|
1
|
+
"""Raw channel pixels for Quick Edit, read straight off the pyramid.
|
|
2
|
+
|
|
3
|
+
Quick Edit shows a small live view of the image behind a figure panel so the
|
|
4
|
+
user can reframe it and adjust its channels without leaving the canvas. It
|
|
5
|
+
needs actual pixels, and there were two ways to get them.
|
|
6
|
+
|
|
7
|
+
**Not the main viewer's routes.** Those go through `data_model`, which holds ONE
|
|
8
|
+
loaded datasource behind a lock and loads the whole cell table, the segmentation
|
|
9
|
+
mask and the channel metadata with it ("this can take some time" is in its own
|
|
10
|
+
docstring). Quick Edit would pay that per panel, evict whatever the user had
|
|
11
|
+
open, and pay it again when they went back to the viewer -- and a figure
|
|
12
|
+
legitimately spans several images, so a four-panel figure would do it four
|
|
13
|
+
times. Export already refuses to touch that module for exactly this reason, and
|
|
14
|
+
`test_the_export_never_loads_a_datasource` pins it.
|
|
15
|
+
|
|
16
|
+
**So: the same reader export uses.** `render.SourceImage` opens the TIFF
|
|
17
|
+
directly, per request, and this hands back one channel of one region as a plain
|
|
18
|
+
array of numbers.
|
|
19
|
+
|
|
20
|
+
## One channel at a time, and no compositing
|
|
21
|
+
|
|
22
|
+
The response is a single channel's greyscale, uint16 little-endian, and the
|
|
23
|
+
browser composites. That is the whole performance argument for the feature:
|
|
24
|
+
changing a channel's colour or dragging its contrast slider is then arithmetic
|
|
25
|
+
over bytes already in memory and costs NO network at all. A route that returned
|
|
26
|
+
a composited RGB would make every contrast tweak a round trip, which is exactly
|
|
27
|
+
the interaction Quick Edit exists to make cheap. MiniMap already works this way.
|
|
28
|
+
|
|
29
|
+
Sixteen bits rather than eight because the windows are chosen in raw units: an
|
|
30
|
+
8-bit response would have to be pre-windowed, and then the slider could only
|
|
31
|
+
move within whatever window the server happened to pick.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import numpy as np
|
|
37
|
+
|
|
38
|
+
from plexora.plugins.figure_builder.server.render import (
|
|
39
|
+
MAX_SOURCE_PIXELS, RenderError, SourceImage, choose_level)
|
|
40
|
+
|
|
41
|
+
#: Biggest region a single request may hand back, per side. The mini viewer is
|
|
42
|
+
#: a few hundred pixels across; this is well past what it asks for and well
|
|
43
|
+
#: short of what would make a response worth megabytes.
|
|
44
|
+
MAX_OUT_PIXELS = 1024
|
|
45
|
+
|
|
46
|
+
#: How many pixels the coarsest level is sampled down to when computing the
|
|
47
|
+
#: intensity summary. Enough for a stable percentile, small enough to be
|
|
48
|
+
#: instant on a whole slide.
|
|
49
|
+
STATS_SAMPLE = 512
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_region(datasource, channel_key, box, out_size):
|
|
53
|
+
"""One channel of one region, resampled to `out_size`.
|
|
54
|
+
|
|
55
|
+
`box` is (x, y, w, h) in FULL-RESOLUTION image pixels -- the same
|
|
56
|
+
coordinate system a panel's `scene.viewport` is in, so a caller never has
|
|
57
|
+
to know which pyramid level was actually read.
|
|
58
|
+
|
|
59
|
+
Returns `(array, clipped_box)`: a 2-D uint16 array of exactly `out_size`,
|
|
60
|
+
and the part of `box` that was inside the image. The clipped box is
|
|
61
|
+
returned rather than silently applied, because a region that ran off the
|
|
62
|
+
edge of the slide has to be drawn in the right PLACE, and a caller given
|
|
63
|
+
only the pixels would centre the wrong thing.
|
|
64
|
+
"""
|
|
65
|
+
out_w, out_h = int(out_size[0]), int(out_size[1])
|
|
66
|
+
if out_w < 1 or out_h < 1:
|
|
67
|
+
raise RenderError("a region must be at least one pixel")
|
|
68
|
+
if out_w > MAX_OUT_PIXELS or out_h > MAX_OUT_PIXELS:
|
|
69
|
+
raise RenderError(
|
|
70
|
+
f"a region of {out_w}x{out_h} is past what one read returns "
|
|
71
|
+
f"(max {MAX_OUT_PIXELS} a side)")
|
|
72
|
+
|
|
73
|
+
with SourceImage(datasource) as source:
|
|
74
|
+
index = source.channel_index(channel_key)
|
|
75
|
+
if index is None:
|
|
76
|
+
raise RenderError(f"{channel_key!r} is not a channel of this image")
|
|
77
|
+
|
|
78
|
+
level = choose_level(source, box[2], out_w)
|
|
79
|
+
divisor = 2 ** level
|
|
80
|
+
plane, clipped = source.read(index, level, (
|
|
81
|
+
box[0] / divisor, box[1] / divisor,
|
|
82
|
+
(box[0] + box[2]) / divisor, (box[1] + box[3]) / divisor))
|
|
83
|
+
|
|
84
|
+
return _resample(plane, out_w, out_h), tuple(value * divisor for value in clipped)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _resample(plane, out_w, out_h):
|
|
88
|
+
"""To exactly the requested size, in uint16.
|
|
89
|
+
|
|
90
|
+
Pillow rather than a stride trick: LANCZOS is what the exporter uses, and a
|
|
91
|
+
mini viewer that resampled differently from the export would show the user
|
|
92
|
+
a slightly different picture from the one they are composing.
|
|
93
|
+
"""
|
|
94
|
+
from PIL import Image
|
|
95
|
+
|
|
96
|
+
array = np.asarray(plane)
|
|
97
|
+
if array.size == 0:
|
|
98
|
+
return np.zeros((out_h, out_w), dtype=np.uint16)
|
|
99
|
+
# Pillow's "I;16" mode cannot resize, so the arithmetic is done in float32
|
|
100
|
+
# and put back afterwards -- which is also what keeps a 16-bit source from
|
|
101
|
+
# being quantised to 8 bits on the way through.
|
|
102
|
+
image = Image.fromarray(array.astype(np.float32), mode="F")
|
|
103
|
+
if image.size != (out_w, out_h):
|
|
104
|
+
image = image.resize((out_w, out_h), Image.LANCZOS)
|
|
105
|
+
return np.clip(np.asarray(image), 0, 65535).astype(np.uint16)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def channel_stats(datasource, channel_key):
|
|
109
|
+
"""What a contrast slider needs to know about a channel.
|
|
110
|
+
|
|
111
|
+
Read from the COARSEST pyramid level: the percentiles of a whole slide are
|
|
112
|
+
the same to within noise at any level, and reading level 0 to draw a slider
|
|
113
|
+
would be a gigabyte for a number the user is about to drag past anyway.
|
|
114
|
+
"""
|
|
115
|
+
with SourceImage(datasource) as source:
|
|
116
|
+
index = source.channel_index(channel_key)
|
|
117
|
+
if index is None:
|
|
118
|
+
raise RenderError(f"{channel_key!r} is not a channel of this image")
|
|
119
|
+
|
|
120
|
+
level = max(0, source.levels - 1)
|
|
121
|
+
array = source.level(level)
|
|
122
|
+
plane = array[index] if array.ndim == 3 else array
|
|
123
|
+
height, width = plane.shape[-2], plane.shape[-1]
|
|
124
|
+
if height * width > MAX_SOURCE_PIXELS:
|
|
125
|
+
raise RenderError("this image has no level small enough to summarise")
|
|
126
|
+
sample = np.asarray(plane)
|
|
127
|
+
|
|
128
|
+
step = max(1, int(max(sample.shape) / STATS_SAMPLE))
|
|
129
|
+
sample = sample[::step, ::step].astype(np.float32)
|
|
130
|
+
if sample.size == 0:
|
|
131
|
+
return {"min": 0, "max": 1, "p01": 0, "p999": 1, "dtype": "uint16"}
|
|
132
|
+
|
|
133
|
+
low, high = np.percentile(sample, [1.0, 99.9])
|
|
134
|
+
return {
|
|
135
|
+
"min": float(sample.min()),
|
|
136
|
+
"max": float(sample.max()),
|
|
137
|
+
# The default window the viewer's auto-level lands on, so a channel
|
|
138
|
+
# switched on in Quick Edit arrives looking the way it would there.
|
|
139
|
+
"p01": float(low),
|
|
140
|
+
"p999": float(high),
|
|
141
|
+
"dtype": str(np.asarray(sample).dtype),
|
|
142
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""How a figure explains itself.
|
|
2
|
+
|
|
3
|
+
Two outputs from one function, because they are the same facts for two readers:
|
|
4
|
+
`manifest()` is the machine-readable record that ships beside the export, and
|
|
5
|
+
`lines()` is the page appended to a PDF for the human holding the printout.
|
|
6
|
+
|
|
7
|
+
The thing worth getting right is what counts as provenance. Not "this figure was
|
|
8
|
+
made in Plexora" -- that explains nothing. What a reader needs in order to
|
|
9
|
+
believe, or to reproduce, a panel:
|
|
10
|
+
|
|
11
|
+
* which image, and which REGION of it, in the image's own coordinates;
|
|
12
|
+
* how big that region is in microns, or an explicit statement that the scale is
|
|
13
|
+
unknown;
|
|
14
|
+
* which channels, with the display window and colour each was drawn with;
|
|
15
|
+
* which plugin drew which overlay, and at what version;
|
|
16
|
+
* what the export could NOT reproduce, named rather than omitted.
|
|
17
|
+
|
|
18
|
+
That last one is the point of this file existing rather than the export just
|
|
19
|
+
writing a title page. An export that silently leaves out the phenotype colouring
|
|
20
|
+
a figure was made to show is an export that misrepresents the figure, and the
|
|
21
|
+
only defence against that is to say so in writing, on the artefact itself.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
|
|
28
|
+
from plexora.plugins.figure_builder.server import compose
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def manifest(document, results, options):
|
|
32
|
+
"""The machine-readable record: `figure-provenance.json`.
|
|
33
|
+
|
|
34
|
+
Deliberately holds the scene snapshots verbatim. A manifest that summarised
|
|
35
|
+
them would be a second description of the same thing, and the summary is the
|
|
36
|
+
one that goes stale.
|
|
37
|
+
"""
|
|
38
|
+
return {
|
|
39
|
+
"manifest_version": 1,
|
|
40
|
+
"generated_at": _now(),
|
|
41
|
+
"figure": {
|
|
42
|
+
"figure_id": document["figure_id"],
|
|
43
|
+
"title": document["title"],
|
|
44
|
+
"revision": document["revision"],
|
|
45
|
+
"created_at": document["created_at"],
|
|
46
|
+
"updated_at": document["updated_at"],
|
|
47
|
+
},
|
|
48
|
+
"export": {
|
|
49
|
+
"format": options.get("format"),
|
|
50
|
+
"dpi": options.get("dpi"),
|
|
51
|
+
"pages": [page["page_id"] for page in document["pages"]],
|
|
52
|
+
},
|
|
53
|
+
"sources": [
|
|
54
|
+
{
|
|
55
|
+
"source_id": source["source_id"],
|
|
56
|
+
"kind": source["kind"],
|
|
57
|
+
"datasource": source["datasource"],
|
|
58
|
+
"image": source["image"],
|
|
59
|
+
"pixel_size": source["pixel_size"],
|
|
60
|
+
"fingerprint": source["fingerprint"],
|
|
61
|
+
}
|
|
62
|
+
for source in document["sources"].values()
|
|
63
|
+
],
|
|
64
|
+
"panels": [
|
|
65
|
+
{
|
|
66
|
+
"panel_id": panel["panel_id"],
|
|
67
|
+
"label": result.get("label"),
|
|
68
|
+
"source_id": panel["source_id"],
|
|
69
|
+
"placement": panel["placement"],
|
|
70
|
+
"scene": panel["scene"],
|
|
71
|
+
"derived_from": panel["derived_from"],
|
|
72
|
+
"render": result,
|
|
73
|
+
}
|
|
74
|
+
for panel, result in results
|
|
75
|
+
],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def lines(document, results, options):
|
|
80
|
+
"""The provenance page, as lines of text.
|
|
81
|
+
|
|
82
|
+
Text rather than a layout because it is appended as a plain page: a table
|
|
83
|
+
that has to fit is a table that eventually does not, and a figure's
|
|
84
|
+
provenance must never be the thing that fails to export.
|
|
85
|
+
"""
|
|
86
|
+
out = [
|
|
87
|
+
document["title"],
|
|
88
|
+
"",
|
|
89
|
+
f"Exported {_now()} · revision {document['revision']} · "
|
|
90
|
+
f"{options.get('format', 'pdf').upper()} at {options.get('dpi')} DPI",
|
|
91
|
+
"",
|
|
92
|
+
"SOURCES",
|
|
93
|
+
]
|
|
94
|
+
for source in document["sources"].values():
|
|
95
|
+
name = source["display_name"] or source["datasource"] or source["source_id"]
|
|
96
|
+
size = source["image"]
|
|
97
|
+
scale = (f"{source['pixel_size']['value']:g} {source['pixel_size']['unit']}/px "
|
|
98
|
+
f"({source['pixel_size']['source']})"
|
|
99
|
+
if source["pixel_size"] else "scale information unavailable")
|
|
100
|
+
out.append(f" {name} — {size['width']} × {size['height']} px — {scale}")
|
|
101
|
+
if not document["sources"]:
|
|
102
|
+
out.append(" (none)")
|
|
103
|
+
|
|
104
|
+
out += ["", "PANELS"]
|
|
105
|
+
for panel, result in results:
|
|
106
|
+
out.extend(_panel_lines(document, panel, result))
|
|
107
|
+
|
|
108
|
+
limitations = _limitations(results)
|
|
109
|
+
if limitations:
|
|
110
|
+
out += ["", "NOT REPRODUCED IN THIS EXPORT"]
|
|
111
|
+
out += [f" {line}" for line in limitations]
|
|
112
|
+
out += [
|
|
113
|
+
"",
|
|
114
|
+
" These are stated rather than omitted. The panels above were",
|
|
115
|
+
" re-rendered from the source image at the resolution requested;",
|
|
116
|
+
" anything listed here was drawn by a tool whose data a figure does",
|
|
117
|
+
" not store, and appears only in the on-screen preview.",
|
|
118
|
+
]
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _panel_lines(document, panel, result):
|
|
123
|
+
source = document["sources"].get(panel["source_id"], {})
|
|
124
|
+
viewport = panel["scene"]["viewport"]
|
|
125
|
+
label = result.get("label") or panel["panel_id"]
|
|
126
|
+
name = source.get("display_name") or source.get("datasource") or "unknown source"
|
|
127
|
+
|
|
128
|
+
out = [f" {label} — {name}",
|
|
129
|
+
f" region x {viewport['x']:.1f} y {viewport['y']:.1f} "
|
|
130
|
+
f"w {viewport['w']:.1f} h {viewport['h']:.1f} (full-resolution image pixels)"]
|
|
131
|
+
|
|
132
|
+
pixel_size = source.get("pixel_size")
|
|
133
|
+
if pixel_size:
|
|
134
|
+
across = viewport["w"] * pixel_size["value"]
|
|
135
|
+
down = viewport["h"] * pixel_size["value"]
|
|
136
|
+
out.append(f" field {compose.format_microns(across)} × "
|
|
137
|
+
f"{compose.format_microns(down)}")
|
|
138
|
+
else:
|
|
139
|
+
out.append(" field scale information unavailable")
|
|
140
|
+
|
|
141
|
+
if result.get("effective_dpi"):
|
|
142
|
+
out.append(f" source detail {result['effective_dpi']:g} DPI at this size"
|
|
143
|
+
+ (f" (requested {result['requested_dpi']})"
|
|
144
|
+
if result.get("requested_dpi") else ""))
|
|
145
|
+
|
|
146
|
+
for channel in panel["scene"]["channels"]:
|
|
147
|
+
colour = channel["color"]
|
|
148
|
+
out.append(
|
|
149
|
+
f" channel {channel['fullname_at_capture'] or channel['key']}"
|
|
150
|
+
f" window {channel['window'][0]:g}–{channel['window'][1]:g}"
|
|
151
|
+
f" rgb({colour['r']},{colour['g']},{colour['b']})")
|
|
152
|
+
|
|
153
|
+
for name, contribution in panel["scene"]["plugins"].items():
|
|
154
|
+
out.append(f" overlay {name} {contribution['version']}")
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _limitations(results):
|
|
159
|
+
channels = set()
|
|
160
|
+
overlays = set()
|
|
161
|
+
for _, result in results:
|
|
162
|
+
channels.update(result.get("missing_channels") or [])
|
|
163
|
+
overlays.update(result.get("missing_overlays") or [])
|
|
164
|
+
out = []
|
|
165
|
+
if channels:
|
|
166
|
+
out.append("channels no longer present in the source: " + ", ".join(sorted(channels)))
|
|
167
|
+
if overlays:
|
|
168
|
+
out.append("overlays drawn by a tool rather than by the image: "
|
|
169
|
+
+ ", ".join(sorted(overlays)))
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _now():
|
|
174
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Re-rendering a captured panel from the source pixels.
|
|
2
|
+
|
|
3
|
+
This is the half of the product claim that the preview raster is not. A panel
|
|
4
|
+
captured at 300 screen pixels exports at whatever the page size and the DPI ask
|
|
5
|
+
for -- two thousand, four thousand -- because the scene records the region in
|
|
6
|
+
full-resolution image coordinates and the windows in raw units, and both survive
|
|
7
|
+
the screen they were chosen on.
|
|
8
|
+
|
|
9
|
+
**Nothing here goes through `data_model`.** That module holds ONE loaded
|
|
10
|
+
datasource behind a lock, and it is the one the user is looking at; a render
|
|
11
|
+
that called `load_datasource` would evict their session to draw a figure, and a
|
|
12
|
+
figure spanning four images would evict it four times. Reading the file directly
|
|
13
|
+
is what makes exporting a multi-image figure possible at all.
|
|
14
|
+
|
|
15
|
+
## The compositing, and why it looks like this
|
|
16
|
+
|
|
17
|
+
It is the numpy transcription of `client/src/shaders/frag.glsl` plus the
|
|
18
|
+
`lighter` blend `viewerManager.js` composites channels with. Per channel:
|
|
19
|
+
|
|
20
|
+
t = clip((raw - lo) / (hi - lo), 0, 1) # the shader's range_clamp
|
|
21
|
+
rgb = colour * t * ALPHA # u_tile_color * pixel_val
|
|
22
|
+
accumulate, then clip # canvas "lighter"
|
|
23
|
+
|
|
24
|
+
Written out rather than approximated because an export that does not match what
|
|
25
|
+
the user was looking at is worse than no export: they chose those windows by
|
|
26
|
+
eye, against that arithmetic.
|
|
27
|
+
|
|
28
|
+
## What this does NOT re-render
|
|
29
|
+
|
|
30
|
+
Overlays -- coloured cells, ROI outlines -- are not reproduced. They cannot be,
|
|
31
|
+
from what a figure stores: a cell layer's colours are a lookup table over every
|
|
32
|
+
cell in the image, and a figure deliberately holds no derived data of that size.
|
|
33
|
+
Reproducing them would mean re-running the plugin that computed them, server
|
|
34
|
+
side, which is a different feature.
|
|
35
|
+
|
|
36
|
+
So an export reports them, per panel, instead of quietly dropping them. See
|
|
37
|
+
`missing_overlays`. The microscopy image itself is fully re-rendered, which is
|
|
38
|
+
the part that could not be recovered any other way.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import math
|
|
44
|
+
|
|
45
|
+
import numpy as np
|
|
46
|
+
|
|
47
|
+
from plexora import api
|
|
48
|
+
|
|
49
|
+
#: The alpha every channel is drawn with, from frag.glsl's `u8_r_range(0.9)` /
|
|
50
|
+
#: `u16_rg_range(0.9)`. Not a style choice here -- it is what the user was
|
|
51
|
+
#: looking at when they chose their windows.
|
|
52
|
+
CHANNEL_ALPHA = 0.9
|
|
53
|
+
|
|
54
|
+
#: Ceiling on one rendered panel, in pixels per side. A 300 mm page at 1200 DPI
|
|
55
|
+
#: is ~14,000 px; past this a single panel is gigabytes of float32 and the
|
|
56
|
+
#: request is a mistake rather than a figure.
|
|
57
|
+
MAX_PANEL_PIXELS = 16_000
|
|
58
|
+
|
|
59
|
+
#: Most source pixels one panel may read before it is refused. A whole-slide
|
|
60
|
+
#: overview at level 0 is 10^10 pixels; the level chooser normally avoids that,
|
|
61
|
+
#: and this catches the cases where it cannot.
|
|
62
|
+
MAX_SOURCE_PIXELS = 120_000_000
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RenderError(Exception):
|
|
66
|
+
"""This panel cannot be rendered, with a reason worth showing the user."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SourceImage:
|
|
70
|
+
"""One image file, opened once and read from many times.
|
|
71
|
+
|
|
72
|
+
Held open across a whole export rather than reopened per panel: a figure is
|
|
73
|
+
routinely eight panels from one slide, and reopening a pyramidal TIFF eight
|
|
74
|
+
times is eight directory walks for the same answer.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, datasource):
|
|
78
|
+
import tifffile
|
|
79
|
+
import zarr
|
|
80
|
+
|
|
81
|
+
dataset = api.dataset(datasource)
|
|
82
|
+
source = dataset.image.source
|
|
83
|
+
if source is None or not source.path:
|
|
84
|
+
raise RenderError(f"{datasource} has no image file on disk")
|
|
85
|
+
|
|
86
|
+
self.datasource = datasource
|
|
87
|
+
self.channels = list(dataset.image.channels)
|
|
88
|
+
self._file = tifffile.TiffFile(source.path, is_ome=False)
|
|
89
|
+
self._zarr = zarr.open(self._file.series[0].aszarr(), mode="r")
|
|
90
|
+
self._is_array = hasattr(self._zarr, "shape")
|
|
91
|
+
self.levels = 1 if self._is_array else len(list(self._zarr))
|
|
92
|
+
width, height = dataset.image.size
|
|
93
|
+
self.width = int(width or 0)
|
|
94
|
+
self.height = int(height or 0)
|
|
95
|
+
|
|
96
|
+
def close(self):
|
|
97
|
+
try:
|
|
98
|
+
self._file.close()
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
def __enter__(self):
|
|
103
|
+
return self
|
|
104
|
+
|
|
105
|
+
def __exit__(self, *exc):
|
|
106
|
+
self.close()
|
|
107
|
+
|
|
108
|
+
def level(self, index):
|
|
109
|
+
"""One pyramid level as an array-like, level 0 being full resolution."""
|
|
110
|
+
if self._is_array:
|
|
111
|
+
return self._zarr
|
|
112
|
+
return self._zarr[str(index)]
|
|
113
|
+
|
|
114
|
+
def channel_index(self, key):
|
|
115
|
+
"""Where a channel sits in the pyramid, from its stable URL key.
|
|
116
|
+
|
|
117
|
+
The pyramid holds only real image channels -- 'Area' is a Plexora-side
|
|
118
|
+
placeholder for a segmentation mask and was never part of the file -- so
|
|
119
|
+
the index is the position among those, which is exactly what
|
|
120
|
+
`ImageHandle.channels` gives.
|
|
121
|
+
"""
|
|
122
|
+
for index, channel in enumerate(self.channels):
|
|
123
|
+
src = str(channel.get("src") or "").rstrip("/")
|
|
124
|
+
if src.rsplit("/", 1)[-1] == key:
|
|
125
|
+
return index
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
def read(self, channel_index, level, box):
|
|
129
|
+
"""A rectangle of one channel, at one level, as a 2-D array.
|
|
130
|
+
|
|
131
|
+
`box` is in THAT LEVEL's pixels. Clipped to the array rather than
|
|
132
|
+
refused: a capture that runs a few pixels off the edge of the slide is
|
|
133
|
+
an ordinary thing to have drawn, and the result is the region that
|
|
134
|
+
exists with black where the image does not.
|
|
135
|
+
"""
|
|
136
|
+
array = self.level(level)
|
|
137
|
+
plane = array[channel_index] if array.ndim == 3 else array
|
|
138
|
+
height, width = plane.shape[-2], plane.shape[-1]
|
|
139
|
+
|
|
140
|
+
x0 = max(0, min(int(math.floor(box[0])), width))
|
|
141
|
+
y0 = max(0, min(int(math.floor(box[1])), height))
|
|
142
|
+
x1 = max(x0, min(int(math.ceil(box[2])), width))
|
|
143
|
+
y1 = max(y0, min(int(math.ceil(box[3])), height))
|
|
144
|
+
if x1 <= x0 or y1 <= y0:
|
|
145
|
+
return np.zeros((1, 1), dtype=np.float32), (x0, y0, x1, y1)
|
|
146
|
+
if (x1 - x0) * (y1 - y0) > MAX_SOURCE_PIXELS:
|
|
147
|
+
raise RenderError(
|
|
148
|
+
"this panel covers more of the image than one render can read; "
|
|
149
|
+
"export it at a lower DPI"
|
|
150
|
+
)
|
|
151
|
+
return np.asarray(plane[y0:y1, x0:x1]), (x0, y0, x1, y1)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def choose_level(source, viewport_width, target_pixels):
|
|
155
|
+
"""The cheapest pyramid level that still has the detail being asked for.
|
|
156
|
+
|
|
157
|
+
The largest level index whose pixels across the region still meet or exceed
|
|
158
|
+
the target, so a 400-pixel-wide panel of a whole slide reads a few hundred
|
|
159
|
+
kilobytes instead of the level-0 gigabyte that would be downsampled away.
|
|
160
|
+
Level 0 when nothing else is big enough, which is also when the warning
|
|
161
|
+
below applies.
|
|
162
|
+
"""
|
|
163
|
+
best = 0
|
|
164
|
+
for level in range(max(1, source.levels)):
|
|
165
|
+
if viewport_width / (2 ** level) >= target_pixels:
|
|
166
|
+
best = level
|
|
167
|
+
else:
|
|
168
|
+
break
|
|
169
|
+
return best
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def effective_dpi(viewport_width_px, width_mm):
|
|
173
|
+
"""How many dots per inch this panel's SOURCE can actually supply.
|
|
174
|
+
|
|
175
|
+
Reported rather than enforced: a panel below the threshold still exports,
|
|
176
|
+
because a reviewer's deadline is a real thing and a slightly soft inset is
|
|
177
|
+
usually the right trade. Silently upscaling and saying nothing is not.
|
|
178
|
+
"""
|
|
179
|
+
if width_mm <= 0:
|
|
180
|
+
return 0.0
|
|
181
|
+
return viewport_width_px / (width_mm / 25.4)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def render_panel(source, scene, target_width, target_height):
|
|
185
|
+
"""One panel's image, as an (H, W, 3) uint8 array.
|
|
186
|
+
|
|
187
|
+
`target_width`/`target_height` are the pixels the page asks for at the
|
|
188
|
+
chosen DPI. The level is picked from the width; the result is resampled to
|
|
189
|
+
exactly the requested size, so the page composition never has to reason
|
|
190
|
+
about what the pyramid happened to hold.
|
|
191
|
+
"""
|
|
192
|
+
from PIL import Image
|
|
193
|
+
|
|
194
|
+
if max(target_width, target_height) > MAX_PANEL_PIXELS:
|
|
195
|
+
raise RenderError(
|
|
196
|
+
f"a panel of {target_width}x{target_height} pixels is past what one "
|
|
197
|
+
f"render can produce (max {MAX_PANEL_PIXELS} a side)")
|
|
198
|
+
|
|
199
|
+
viewport = scene["viewport"]
|
|
200
|
+
level = choose_level(source, viewport["w"], target_width)
|
|
201
|
+
divisor = 2 ** level
|
|
202
|
+
box = (viewport["x"] / divisor, viewport["y"] / divisor,
|
|
203
|
+
(viewport["x"] + viewport["w"]) / divisor,
|
|
204
|
+
(viewport["y"] + viewport["h"]) / divisor)
|
|
205
|
+
|
|
206
|
+
accumulator = None
|
|
207
|
+
rendered = 0
|
|
208
|
+
for channel in scene.get("channels") or []:
|
|
209
|
+
if not channel.get("visible", True):
|
|
210
|
+
continue
|
|
211
|
+
index = source.channel_index(channel["key"])
|
|
212
|
+
if index is None:
|
|
213
|
+
# Reported by the caller through `missing_channels`; nothing is
|
|
214
|
+
# substituted, because a substituted channel produces a panel that
|
|
215
|
+
# looks right and is wrong.
|
|
216
|
+
continue
|
|
217
|
+
plane, _ = source.read(index, level, box)
|
|
218
|
+
if accumulator is None:
|
|
219
|
+
accumulator = np.zeros((plane.shape[0], plane.shape[1], 3), dtype=np.float32)
|
|
220
|
+
elif plane.shape != accumulator.shape[:2]:
|
|
221
|
+
# Levels of a pyramid can be off by a pixel against each other.
|
|
222
|
+
plane = plane[:accumulator.shape[0], :accumulator.shape[1]]
|
|
223
|
+
|
|
224
|
+
low, high = float(channel["window"][0]), float(channel["window"][1])
|
|
225
|
+
span = high - low
|
|
226
|
+
if span <= 0:
|
|
227
|
+
continue
|
|
228
|
+
# The shader's range_clamp, in raw units -- identical arithmetic to
|
|
229
|
+
# dividing both sides by 65535 first, and without the rounding.
|
|
230
|
+
scaled = np.clip((plane.astype(np.float32) - low) / span, 0.0, 1.0)
|
|
231
|
+
colour = channel["color"]
|
|
232
|
+
weight = CHANNEL_ALPHA / 255.0
|
|
233
|
+
for offset, key in enumerate(("r", "g", "b")):
|
|
234
|
+
value = float(colour[key])
|
|
235
|
+
if value:
|
|
236
|
+
accumulator[:scaled.shape[0], :scaled.shape[1], offset] += scaled * (value * weight)
|
|
237
|
+
rendered += 1
|
|
238
|
+
|
|
239
|
+
if accumulator is None:
|
|
240
|
+
# Every channel was gone, or the panel had none. Black, which is what
|
|
241
|
+
# the viewer shows for the same state, rather than an error: the panel's
|
|
242
|
+
# labels and scale bar are still worth exporting.
|
|
243
|
+
accumulator = np.zeros((max(1, target_height), max(1, target_width), 3),
|
|
244
|
+
dtype=np.float32)
|
|
245
|
+
|
|
246
|
+
image = Image.fromarray((np.clip(accumulator, 0.0, 1.0) * 255.0).astype(np.uint8), "RGB")
|
|
247
|
+
if image.size != (target_width, target_height):
|
|
248
|
+
# LANCZOS both ways. Downsampling a bright-on-black fluorescence image
|
|
249
|
+
# with a box filter loses thin structures; upsampling with nearest
|
|
250
|
+
# produces the blocky look that gives "exported from a screenshot" away.
|
|
251
|
+
image = image.resize((max(1, target_width), max(1, target_height)), Image.LANCZOS)
|
|
252
|
+
return image, {"level": level, "channels_rendered": rendered}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def panel_report(source, scene, width_mm, dpi):
|
|
256
|
+
"""What is worth telling the user about this panel before they export it.
|
|
257
|
+
|
|
258
|
+
Computed separately from the render so the export dialog can show it without
|
|
259
|
+
reading a single pixel.
|
|
260
|
+
"""
|
|
261
|
+
viewport = scene["viewport"]
|
|
262
|
+
missing = [channel.get("fullname_at_capture") or channel["key"]
|
|
263
|
+
for channel in scene.get("channels") or []
|
|
264
|
+
if source.channel_index(channel["key"]) is None]
|
|
265
|
+
overlays = []
|
|
266
|
+
for layer in (scene.get("core_overlays") or {}).get("cell_layers") or []:
|
|
267
|
+
if layer.get("visible") and layer.get("mode") not in (None, "", "none"):
|
|
268
|
+
overlays.append(layer["name"])
|
|
269
|
+
overlays.extend(name for name in (scene.get("plugins") or {}) if name not in overlays)
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
"effective_dpi": round(effective_dpi(viewport["w"], width_mm), 1),
|
|
273
|
+
"requested_dpi": dpi,
|
|
274
|
+
"missing_channels": missing,
|
|
275
|
+
# Named, not silently dropped: an export that omits the phenotype
|
|
276
|
+
# colouring a figure was made to show has to say so.
|
|
277
|
+
"missing_overlays": sorted(set(overlays)),
|
|
278
|
+
}
|