-
Notifications
You must be signed in to change notification settings - Fork 49
/
Copy pathmultiscale-image-layer.js
248 lines (236 loc) · 10.5 KB
/
multiscale-image-layer.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
import { CompositeLayer } from '@deck.gl/core';
import { Matrix4 } from '@math.gl/core';
import GL from '@luma.gl/constants';
import MultiscaleImageLayerBase from './multiscale-image-layer-base';
import ImageLayer from '../image-layer';
import { getImageSize, isInterleaved, SIGNAL_ABORTED } from '@vivjs/loaders';
import { ColorPaletteExtension } from '@vivjs/extensions';
/**
* Here we create an object for checking clipping the black border of incoming tiles
* at low resolutions i.e for zarr tiles which are padded by zarr.
* We need to check a few things before trimming:
* 1. The height/width of the full image at the current resolution
* produces an image smaller than the current tileSize
* 2. The incoming image is padded to the tile size i.e one of its dimensions matches the tile size
* Once these have been confirmed, we trim the tile by going over it in row major order,
* keeping only the data that is not out of the clipped bounds.
* @param {{
* loader: PixelSource[],
* resolution: number,
* tileSize: number,
* }}
* @return {{ clip: function, height: number, width: number }}
*/
const createTileClipper = ({ loader, resolution, tileSize }) => {
const planarSize = Object.values(getImageSize(loader[0]));
const [clippedHeight, clippedWidth] = planarSize.map(size =>
Math.floor(size / 2 ** resolution)
);
const isHeightUnderTileSize = clippedHeight < tileSize;
const isWidthUnderTileSize = clippedWidth < tileSize;
return {
clip: ({ data, height, width }) => {
if (
(isHeightUnderTileSize && height === tileSize) ||
(width === tileSize && isWidthUnderTileSize)
) {
return data.filter((d, ind) => {
return !(
(ind % tileSize >= clippedWidth && isWidthUnderTileSize) ||
(isHeightUnderTileSize &&
Math.floor(ind / tileSize) >= clippedHeight)
);
});
}
return data;
},
height: clippedHeight,
width: clippedWidth
};
};
const defaultProps = {
pickable: { type: 'boolean', value: true, compare: true },
onHover: { type: 'function', value: null, compare: false },
contrastLimits: { type: 'array', value: [], compare: true },
channelsVisible: { type: 'array', value: [], compare: true },
domain: { type: 'array', value: [], compare: true },
viewportId: { type: 'string', value: '', compare: true },
maxRequests: { type: 'number', value: 10, compare: true },
onClick: { type: 'function', value: null, compare: true },
refinementStrategy: { type: 'string', value: null, compare: true },
excludeBackground: { type: 'boolean', value: false, compare: true },
extensions: {
type: 'array',
value: [new ColorPaletteExtension()],
compare: true
}
};
/**
* @typedef LayerProps
* @type {object}
* @property {Array.<Array.<number>>} contrastLimits List of [begin, end] values to control each channel's ramp function.
* @property {Array.<boolean>} channelsVisible List of boolean values for each channel for whether or not it is visible.
* @property {Array} loader Image pyramid. PixelSource[], where each PixelSource is decreasing in shape.
* @property {Array} selections Selection to be used for fetching data.
* @property {Array.<Array.<number>>=} domain Override for the possible max/min values (i.e something different than 65535 for uint16/'<u2').
* @property {string=} viewportId Id for the current view. This needs to match the viewState id in deck.gl and is necessary for the lens.
* @property {String=} id Unique identifier for this layer.
* @property {function=} onTileError Custom override for handle tile fetching errors.
* @property {function=} onHover Hook function from deck.gl to handle hover objects.
* @property {number=} maxRequests Maximum parallel ongoing requests allowed before aborting.
* @property {function=} onClick Hook function from deck.gl to handle clicked-on objects.
* @property {Object=} modelMatrix Math.gl Matrix4 object containing an affine transformation to be applied to the image.
* @property {string=} refinementStrategy 'best-available' | 'no-overlap' | 'never' will be passed to TileLayer. A default will be chosen based on opacity.
* @property {boolean=} excludeBackground Whether to exclude the background image. The background image is also excluded for opacity!=1.
* @property {Array=} extensions [deck.gl extensions](https://deck.gl/docs/developer-guide/custom-layers/layer-extensions) to add to the layers.
*/
/**
* @type {{ new <S extends string[]>(...props: import('@vivjs/types').Viv<LayerProps, S>[]) }}
* @ignore
*/
const MultiscaleImageLayer = class extends CompositeLayer {
renderLayers() {
const {
loader,
selections,
opacity,
viewportId,
onTileError,
onHover,
id,
onClick,
modelMatrix,
excludeBackground,
refinementStrategy
} = this.props;
// Get properties from highest resolution
const { tileSize, dtype } = loader[0];
// This is basically to invert:
// https://github.com/visgl/deck.gl/pull/4616/files#diff-4d6a2e500c0e79e12e562c4f1217dc80R128
// The z level can be wrong for showing the correct scales because of the calculation deck.gl does
// so we need to invert it for fetching tiles and minZoom/maxZoom.
const getTileData = async ({ index: { x, y, z }, signal }) => {
// Early return if no selections
if (!selections || selections.length === 0) {
return null;
}
// I don't fully undertstand why this works, but I have a sense.
// It's basically to cancel out:
// https://github.com/visgl/deck.gl/pull/4616/files#diff-4d6a2e500c0e79e12e562c4f1217dc80R128,
// which felt odd to me to beign with.
// The image-tile example works without, this but I have a feeling there is something
// going on with our pyramids and/or rendering that is different.
const resolution = Math.round(-z);
const getTile = selection => {
const config = { x, y, selection, signal };
return loader[resolution].getTile(config);
};
const clipper = createTileClipper({ loader, resolution, tileSize });
try {
/*
* Try to request the tile data. The pixels sources can throw
* special SIGNAL_ABORTED string that we pick up in the catch
* block to return null to deck.gl.
*
* This means that our pixels sources _always_ have the same
* return type, and optional throw for performance.
*/
const tiles = await Promise.all(selections.map(getTile));
const tile = {
data: tiles.map(d =>
clipper.clip({
data: d.data,
width: tiles[0].width,
height: tiles[0].height
})
),
width:
clipper.width < tileSize && tiles[0].width === tileSize
? clipper.width
: tiles[0].width,
height:
clipper.height < tileSize && tiles[0].height === tileSize
? clipper.height
: tiles[0].height
};
if (isInterleaved(loader[resolution].shape)) {
// eslint-disable-next-line prefer-destructuring
tile.data = tile.data[0];
if (tile.data.length === tile.width * tile.height * 3) {
tile.format = GL.RGB;
tile.dataFormat = GL.RGB; // is this not properly inferred?
}
// can just return early, no need to check for webgl2
return tile;
}
return tile;
} catch (err) {
/*
* Signal is aborted. We handle the custom value thrown
* by our pixel sources here and return falsy to deck.gl.
*/
if (err === SIGNAL_ABORTED) {
return null;
}
// We should propagate all other thrown values/errors
throw err;
}
};
const { height, width } = getImageSize(loader[0]);
const tiledLayer = new MultiscaleImageLayerBase(this.props, {
id: `Tiled-Image-${id}`,
getTileData,
dtype,
tileSize,
// If you scale a matrix up or down, that is like zooming in or out. zoomOffset controls
// how the zoom level you fetch tiles at is offset, allowing us to fetch higher resolution tiles
// while at a lower "absolute" zoom level. If you didn't use this prop, an image that is scaled
// up would always look "low resolution" no matter the level of the image pyramid you are looking at.
zoomOffset: Math.round(
Math.log2(modelMatrix ? modelMatrix.getScale()[0] : 1)
),
extent: [0, 0, width, height],
// See the above note within for why the use of zoomOffset and the rounding necessary.
minZoom: Math.round(-(loader.length - 1)),
maxZoom: 0,
// We want a no-overlap caching strategy with an opacity < 1 to prevent
// multiple rendered sublayers (some of which have been cached) from overlapping
refinementStrategy:
refinementStrategy || (opacity === 1 ? 'best-available' : 'no-overlap'),
// TileLayer checks `changeFlags.updateTriggersChanged.getTileData` to see if tile cache
// needs to be re-created. We want to trigger this behavior if the loader changes.
// https://github.com/uber/deck.gl/blob/3f67ea6dfd09a4d74122f93903cb6b819dd88d52/modules/geo-layers/src/tile-layer/tile-layer.js#L50
updateTriggers: {
getTileData: [loader, selections]
},
onTileError: onTileError || loader[0].onTileError
});
// This gives us a background image and also solves the current
// minZoom funny business. We don't use it for the background if we have an opacity
// paramteter set to anything but 1, but we always use it for situations where
// we are zoomed out too far.
const lowestResolution = loader[loader.length - 1];
const implementsGetRaster =
typeof lowestResolution.getRaster === 'function';
const layerModelMatrix = modelMatrix ? modelMatrix.clone() : new Matrix4();
const baseLayer =
implementsGetRaster &&
!excludeBackground &&
new ImageLayer(this.props, {
id: `Background-Image-${id}`,
loader: lowestResolution,
modelMatrix: layerModelMatrix.scale(2 ** (loader.length - 1)),
visible: !viewportId || this.context.viewport.id === viewportId,
onHover,
onClick,
// Background image is nicest when LINEAR in my opinion.
interpolation: GL.LINEAR,
onViewportLoad: null
});
const layers = [baseLayer, tiledLayer];
return layers;
}
};
MultiscaleImageLayer.layerName = 'MultiscaleImageLayer';
MultiscaleImageLayer.defaultProps = defaultProps;
export default MultiscaleImageLayer;