class SIFTMultiChannelTiledGeolocatedMixin(SIFTTiledGeolocatedMixin): def _normalize_data(self, data_arrays): if not isinstance(data_arrays, (list, tuple)): return super()._normalize_data(data_arrays) new_data = [] for data in data_arrays: new_data.append(super()._normalize_data(data)) return new_data def _init_geo_parameters(self, origin_x, origin_y, cell_width, cell_height, projection, texture_shape, tile_shape, wrap_lon, shape, data_arrays): if shape is None: shape = self._compute_shape(shape, data_arrays) ndim = len(shape) or [x for x in data_arrays if x is not None][0].ndim data = ArrayProxy(ndim, shape) super()._init_geo_parameters( origin_x, origin_y, cell_width, cell_height, projection, texture_shape, tile_shape, wrap_lon, shape, data, ) self.set_channels( data_arrays, shape=shape, cell_width=cell_width, cell_height=cell_height, origin_x=origin_x, origin_y=origin_y, ) def set_channels(self, data_arrays, shape=None, cell_width=None, cell_height=None, origin_x=None, origin_y=None): assert (shape or data_arrays is not None), "`data` or `shape` must be provided" if cell_width is not None: self.cell_width = cell_width if cell_height: self.cell_height = cell_height # Note: cell_height is usually negative if origin_x: self.origin_x = origin_x if origin_y: self.origin_y = origin_y self.shape = self._compute_shape(shape, data_arrays) assert None not in (self.cell_width, self.cell_height, self.origin_x, self.origin_y, self.shape) # how many of the higher resolution channel tiles (smaller geographic area) make # up a low resolution channel tile self._channel_factors = tuple( self.shape[0] / float(chn.shape[0]) if chn is not None else 1. for chn in data_arrays) self._lowest_factor = max(self._channel_factors) self._lowest_rez = Resolution( abs(self.cell_height * self._lowest_factor), abs(self.cell_width * self._lowest_factor)) # Where does this image lie in this lonely world self.calc = TileCalculator(self.name, self.shape, Point(x=self.origin_x, y=self.origin_y), Resolution(dy=abs(self.cell_height), dx=abs(self.cell_width)), self.tile_shape, self.texture_shape, wrap_lon=self.wrap_lon) # Reset texture state, if we change things to know which texture # don't need to be updated then this can be removed/changed self.texture_state.reset() self._need_texture_upload = True self._need_vertex_update = True # Reset the tiling logic to force a retile # even though we might be looking at the exact same spot self._latest_tile_box = None @staticmethod def _compute_shape(shape, data_arrays): return shape or max(data.shape for data in data_arrays if data is not None) def _get_stride(self, view_box): s = self.calc.calc_stride(view_box, texture=self._lowest_rez) return Point(np.int64(s[0] * self._lowest_factor), np.int64(s[1] * self._lowest_factor)) def _slice_texture_tile(self, data_arrays, y_slice, x_slice): new_data = [] for data in data_arrays: if data is not None: # explicitly ask for the parent class of MultiBandTextureAtlas2D data = super()._slice_texture_tile(data, y_slice, x_slice) new_data.append(data) return new_data
def test_calc_stride(tc_params, v, t, exp): """Test calculated stride value is correct given world geometry and sampling.""" tile_calc = TileCalculator(*tc_params) res = tile_calc.calc_stride(v, t) assert res == exp
class SIFTTiledGeolocatedMixin: def __init__(self, data, *area_params, tile_shape=(DEFAULT_TILE_HEIGHT, DEFAULT_TILE_WIDTH), texture_shape=(DEFAULT_TEXTURE_HEIGHT, DEFAULT_TEXTURE_WIDTH), wrap_lon=False, projection=DEFAULT_PROJECTION, **visual_kwargs): origin_x, origin_y, cell_width, cell_height = area_params if visual_kwargs.get("method", "subdivide") != "subdivide": raise ValueError("Only 'subdivide' drawing method is supported.") visual_kwargs["method"] = "subdivide" if "grid" in visual_kwargs: raise ValueError( "The 'grid' keyword argument is not supported with the tiled mixin." ) # visual nodes already have names, so be careful if not hasattr(self, "name"): self.name = visual_kwargs.pop("name", None) self._init_geo_parameters( origin_x, origin_y, cell_width, cell_height, projection, texture_shape, tile_shape, wrap_lon, visual_kwargs.get('shape'), data, ) # Call the init of the Visual super().__init__(data, **visual_kwargs) def _init_geo_parameters(self, origin_x, origin_y, cell_width, cell_height, projection, texture_shape, tile_shape, wrap_lon, shape, data): self._viewable_mesh_mask = None self._ref1 = None self._ref2 = None self.origin_x = origin_x self.origin_y = origin_y self.cell_width = cell_width self.cell_height = cell_height # Note: cell_height is usually negative self.texture_shape = texture_shape self.tile_shape = tile_shape self.num_tex_tiles = self.texture_shape[0] * self.texture_shape[1] self._stride = (0, 0) self._latest_tile_box = None self.wrap_lon = wrap_lon self._tiles = {} assert (shape or data is not None), "`data` or `shape` must be provided" self.shape = shape or data.shape self.ndim = len(self.shape) or data.ndim # Where does this image lie in this lonely world self.calc = TileCalculator( self.name, self.shape, Point(x=self.origin_x, y=self.origin_y), Resolution(dy=abs(self.cell_height), dx=abs(self.cell_width)), self.tile_shape, self.texture_shape, wrap_lon=self.wrap_lon, projection=projection, ) # What tiles have we used and can we use self.texture_state = TextureTileState(self.num_tex_tiles) def _normalize_data(self, data): if data is not None and data.dtype == np.float64: data = data.astype(np.float32) return data def _build_texture_tiles(self, data, stride, tile_box: Box): """Prepare and organize strided data in to individual tiles with associated information. """ data = self._normalize_data(data) LOG.debug("Uploading texture data for %d tiles (%r)", (tile_box.bottom - tile_box.top) * (tile_box.right - tile_box.left), tile_box) # Tiles start at upper-left so go from top to bottom tiles_info = [] for tiy in range(tile_box.top, tile_box.bottom): for tix in range(tile_box.left, tile_box.right): already_in = (stride, tiy, tix) in self.texture_state # Update the age if already in there # Assume that texture_state does not change from the main thread if this is run in another tex_tile_idx = self.texture_state.add_tile((stride, tiy, tix)) if already_in: # FIXME: we should make a list/set of the tiles we need to add before this continue # Assume we were given a total image worth of this stride y_slice, x_slice = self.calc.calc_tile_slice(tiy, tix, stride) tile_data = self._slice_texture_tile(data, y_slice, x_slice) tiles_info.append((stride, tiy, tix, tex_tile_idx, tile_data)) return tiles_info def _slice_texture_tile(self, data, y_slice, x_slice): # force a copy of the data from the content array (provided by the workspace) # to a vispy-compatible contiguous float array # this can be a potentially time-expensive operation since content array is # often huge and always memory-mapped, so paging may occur # we don't want this paging deferred until we're back in the GUI thread pushing data to OpenGL! return np.array(data[y_slice, x_slice], dtype=np.float32) def _set_texture_tiles(self, tiles_info): for tile_info in tiles_info: stride, tiy, tix, tex_tile_idx, data = tile_info self._texture.set_tile_data(tex_tile_idx, data) def _build_vertex_tiles(self, preferred_stride, tile_box: Box): """Rebuild the vertex buffers used for rendering the image when using the subdivide method. SIFT Note: Copied from 0.5.0dev original ImageVisual class """ total_num_tiles = (tile_box.bottom - tile_box.top) * (tile_box.right - tile_box.left) if total_num_tiles <= 0: # we aren't looking at this image # FIXME: What's the correct way to stop drawing here raise RuntimeError( "View calculations determined a negative number of tiles are visible" ) elif total_num_tiles > self.num_tex_tiles: LOG.warning( "Current view sees more tiles than can be held in the GPU") # We continue on, showing as many tiles as we can tex_coords = np.empty( (6 * total_num_tiles * (TESS_LEVEL * TESS_LEVEL), 2), dtype=np.float32) vertices = np.empty( (6 * total_num_tiles * (TESS_LEVEL * TESS_LEVEL), 2), dtype=np.float32) # What tile are we currently describing out of all the tiles being viewed used_tile_idx = -1 LOG.debug("Building vertex data for %d tiles (%r)", total_num_tiles, tile_box) tl = TESS_LEVEL * TESS_LEVEL # Tiles start at upper-left so go from top to bottom for tiy in range(tile_box.top, tile_box.bottom): for tix in range(tile_box.left, tile_box.right): # Update the index here because we have multiple exit/continuation points used_tile_idx += 1 # Check if the tile we want to draw is actually in the GPU # if not (atlas too small?) fill with zeros and keep going if (preferred_stride, tiy, tix) not in self.texture_state: # THIS SHOULD NEVER HAPPEN IF TEXTURE BUILDING IS DONE CORRECTLY AND THE ATLAS IS BIG ENOUGH tile_start = TESS_LEVEL * TESS_LEVEL * used_tile_idx * 6 tile_end = TESS_LEVEL * TESS_LEVEL * (used_tile_idx + 1) * 6 tex_coords[tile_start:tile_end, :] = 0 vertices[tile_start:tile_end, :] = 0 continue # we should have already loaded the texture data in to the GPU so get the index of that texture tex_tile_idx = self.texture_state[(preferred_stride, tiy, tix)] factor_rez, offset_rez = self.calc.calc_tile_fraction( tiy, tix, preferred_stride) tex_coords[tl * used_tile_idx * 6: tl * (used_tile_idx + 1) * 6, :] = \ self.calc.calc_texture_coordinates(tex_tile_idx, factor_rez, offset_rez, tessellation_level=TESS_LEVEL) vertices[tl * used_tile_idx * 6:tl * (used_tile_idx + 1) * 6, :] = self.calc.calc_vertex_coordinates( tiy, tix, preferred_stride[0], preferred_stride[1], factor_rez, offset_rez, tessellation_level=TESS_LEVEL) return vertices, tex_coords def _set_vertex_tiles(self, vertices, tex_coords): self._subdiv_position.set_data(vertices.astype('float32')) self._subdiv_texcoord.set_data(tex_coords.astype('float32')) def determine_reference_points(self): # Image points transformed to canvas coordinates img_cmesh = self.transforms.get_transform().map(self.calc.image_mesh) # Mask any points that are really far off screen (can't be transformed) valid_mask = (np.abs(img_cmesh[:, 0]) < CANVAS_EPSILON) & (np.abs( img_cmesh[:, 1]) < CANVAS_EPSILON) # The image mesh projected to canvas coordinates (valid only) img_cmesh = img_cmesh[valid_mask] # The image mesh of only valid "viewable" projected coordinates img_vbox = self.calc.image_mesh[valid_mask] if not img_cmesh[:, 0].size or not img_cmesh[:, 1].size: self._viewable_mesh_mask = None self._ref1, self._ref2 = None, None return x_cmin, x_cmax = img_cmesh[:, 0].min(), img_cmesh[:, 0].max() y_cmin, y_cmax = img_cmesh[:, 1].min(), img_cmesh[:, 1].max() center_x = (x_cmax - x_cmin) / 2. + x_cmin center_y = (y_cmax - y_cmin) / 2. + y_cmin dist = img_cmesh.copy() dist[:, 0] = center_x - img_cmesh[:, 0] dist[:, 1] = center_y - img_cmesh[:, 1] self._viewable_mesh_mask = valid_mask self._ref1, self._ref2 = get_reference_points(dist, img_vbox) def get_view_box(self): """Calculate shown portion of image and image units per pixel This method utilizes a precomputed "mesh" of relatively evenly spaced points over the entire image space. This mesh is transformed to the canvas space (-1 to 1 user-viewed space) to figure out which portions of the image are currently being viewed and which portions can actually be projected on the viewed projection. While the result of the chosen method may not always be completely accurate, it should work for all possible viewing cases. """ if self._viewable_mesh_mask is None or self.canvas.size[ 0] == 0 or self.canvas.size[1] == 0: raise ValueError("Image '%s' is not viewable in this projection" % (self.name, )) # Image points transformed to canvas coordinates img_cmesh = self.transforms.get_transform().map(self.calc.image_mesh) # The image mesh projected to canvas coordinates (valid only) img_cmesh = img_cmesh[self._viewable_mesh_mask] # The image mesh of only valid "viewable" projected coordinates img_vbox = self.calc.image_mesh[self._viewable_mesh_mask] ref_idx_1, ref_idx_2 = get_reference_points(img_cmesh, img_vbox) dx, dy = calc_pixel_size(img_cmesh[(self._ref1, self._ref2), :], img_vbox[(self._ref1, self._ref2), :], self.canvas.size) view_extents = self.calc.calc_view_extents(img_cmesh[ref_idx_1], img_vbox[ref_idx_1], self.canvas.size, dx, dy) return ViewBox(*view_extents, dx=dx, dy=dy) def _get_stride(self, view_box): return self.calc.calc_stride(view_box) def assess(self): """Determine if a retile is needed. Tell workspace we will be needed """ try: view_box = self.get_view_box() preferred_stride = self._get_stride(view_box) tile_box = self.calc.visible_tiles(view_box, stride=preferred_stride, extra_tiles_box=Box(1, 1, 1, 1)) except ValueError as e: # If image is outside of canvas, then an exception will be raised LOG.warning( "Could not determine viewable image area for '{}': {}".format( self.name, e)) return False, self._stride, self._latest_tile_box num_tiles = (tile_box.bottom - tile_box.top) * (tile_box.right - tile_box.left) LOG.debug( "Assessment: Prefer '%s' have '%s', was looking at %r, now looking at %r", preferred_stride, self._stride, self._latest_tile_box, tile_box) # If we zoomed out or we panned need_retile = (num_tiles > 0) and (preferred_stride != self._stride or self._latest_tile_box != tile_box) return need_retile, preferred_stride, tile_box def retile(self, data, preferred_stride, tile_box): """Get data from workspace and retile/retexture as needed. """ tiles_info = self._build_texture_tiles(data, preferred_stride, tile_box) vertices, tex_coords = self._build_vertex_tiles( preferred_stride, tile_box) return tiles_info, vertices, tex_coords def set_retiled(self, preferred_stride, tile_box, tiles_info, vertices, tex_coords): self._set_texture_tiles(tiles_info) self._set_vertex_tiles(vertices, tex_coords) # don't update here, the caller will do that # Store the most recent level of detail that we've done self._stride = preferred_stride self._latest_tile_box = tile_box